Initial commit: Receipt Studio desktop application
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.png binary
|
||||||
|
*.ico binary
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
# Dependencies and generated output
|
||||||
|
/node_modules/
|
||||||
|
/dist/
|
||||||
|
/release/
|
||||||
|
/coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Local receipts, stamps, preferences, and test output
|
||||||
|
/.local-data/
|
||||||
|
/.qa/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-*
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
|
|
||||||
|
# Local credentials and environment configuration
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.*.example
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
/.aws/
|
||||||
|
|
||||||
|
# Local editor and assistant settings
|
||||||
|
/.idea/
|
||||||
|
/.vscode/
|
||||||
|
/.codex/
|
||||||
|
/.agents/
|
||||||
|
|
||||||
|
# Logs, caches, and operating system files
|
||||||
|
*.log
|
||||||
|
*.dmp
|
||||||
|
.cache/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# 收据工坊
|
||||||
|
|
||||||
|
面向 Windows 10 x64 的本地收据工具。安装后无需登录或联网,无需安装 Excel。
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
1. 打开“偏好设置”,选择导出目录和印章文件夹,填写默认出纳、复核人。
|
||||||
|
2. 在“印章管理”选择本批印章;也可以明确选择不使用印章。
|
||||||
|
3. 在工作台下载 Excel 模板,每行填写一条收费项目,再导入;在“按分组核对收据”中按学校、年级、收费项目整组勾选,核对张数、金额和待修改条数。
|
||||||
|
4. 核对错误行、选中数据与右侧预览,选择 PDF、PNG、A4 打印版后生成。A4 预览按已选分组显示对应页,不跨组混排。
|
||||||
|
5. 补打请到“生成记录”,按学校、年级、收费项目,或输入姓名、任意收据编号查找,预览匹配的原收据。重新导出沿用原编号、数据和印章,导出该批全部收据。
|
||||||
|
6. 生成记录支持逐批删除,确认后移除批次数据;已导出的文件保留,流水号继续递增。删除后不能在列表中补打该批次。
|
||||||
|
|
||||||
|
生成记录默认每页 5 条,可切换为每页 10、20 或 50 条,并记住上次选择。列表底部显示总条数、当前范围和页码,可使用首页、上一页、页码、下一页和末页翻页。改变筛选条件或每页条数会回到第一页;删除末页最后一条记录后自动回到有效页。分页覆盖全部生成记录。
|
||||||
|
|
||||||
|
A4 打印版默认上下两张,奇数张时最后半页留空,也可选每页一张。单张 PDF/PNG 页面为 190 × 140 mm;收据主体为 180 × 120 mm。打印 A4 文件时使用 A4、实际大小或 100% 比例。首批请在实际打印机上试打一页。
|
||||||
|
|
||||||
|
每次导出自动建立独立批次文件夹,生成成功后自动打开输出目录,这两项行为无需设置。批次保留原有命名,内部按“学校 → 年级 → 收费项目”分层。单张 PDF、PNG 以及该组的 A4 打印版放在同一个项目文件夹;每组独立排版,组内保持 Excel 顺序,每页两张时奇数张的最后半页留空。生成清单保留在批次根目录,记录文件的相对路径。历史记录缺少学校或年级时,使用“未分类学校”“未分类年级”目录。文件名在“偏好设置 → 文件与常用选择 → 导出的文件叫什么名字”中调整,修改后立即显示文件名示例。
|
||||||
|
|
||||||
|
```text
|
||||||
|
原有批次文件夹/
|
||||||
|
巴南中学/
|
||||||
|
高2028届/
|
||||||
|
秋季学期服务费/
|
||||||
|
SJ00000001_巴南中学 高2028届 张三.pdf
|
||||||
|
SJ00000001_巴南中学 高2028届 张三.png
|
||||||
|
A4打印版_每页2张.pdf
|
||||||
|
生成清单.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Excel 必填列:收款日期、学校、年级、姓名、收费项目、金额。收据上的客户名称自动按“学校 年级 姓名”拼接,三列去除首尾空白后用一个空格分隔。选填列:出纳、复核。金额为正数,最多两位小数,上限 9,999,999.99 元。单次最多 5000 条,仅支持 .xlsx。印章支持 PNG/JPG/WebP,每张不超过 8 MB,列表最多加载 100 张;建议透明 PNG。
|
||||||
|
|
||||||
|
## 编号与本地数据
|
||||||
|
|
||||||
|
默认编号 SJ00000001,按前缀独立递增。预览不占号,正式生成前先持久保存编号;失败、取消和重启后可从生成记录按原编号重试。重复导入相同文件会提示;只有明确继续才会生成新编号。
|
||||||
|
|
||||||
|
设置文件和 SQLite 记录保存在 Windows 用户数据目录。开发数据在 .local-data/app,自动测试数据在 .qa/profile,生产数据与二者隔离。升级及默认卸载保留用户数据。
|
||||||
|
|
||||||
|
多台电脑离线使用时,给每台设置不同编号前缀。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
本地调试无需安装程序,双击项目根目录的“启动本地调试.cmd”即可。入口优先使用这台电脑已有的兼容 Node 运行环境,不改变系统默认 Node 版本。
|
||||||
|
|
||||||
|
请在启动后自动弹出的“收据工坊”独立桌面窗口中调试。桌面窗口按 F12 打开调试工具;界面修改会自动更新。浏览器直接打开 127.0.0.1:5173 只会加载前端资源,完整的导入、印章和导出功能需要桌面程序。修改 src/main、src/core 或 src/shared 中影响主进程的代码后,关闭窗口并重新启动。开发设置和编号保存在 .local-data/app。
|
||||||
|
|
||||||
|
常规命令需要 Node.js 24 LTS(最低 22.13)、npm,以及 Windows 10 x64。
|
||||||
|
|
||||||
|
- npm install
|
||||||
|
- npm run dev:桌面开发环境
|
||||||
|
- npm test:核心行为测试
|
||||||
|
- npm run build:类型检查和构建
|
||||||
|
- npm run test:desktop:真实 Electron 集成验证(隐藏窗口,产物在 .qa)
|
||||||
|
- npm run test:loading:加载时机、持续旋转及取消选择验证(隔离测试数据)
|
||||||
|
- npm run test:history:记录分页、每页条数记忆、筛选和删除末页验证(隔离测试数据)
|
||||||
|
- npm run test:print:较大印章批量打印、错误提示和原编号重试验证(独立测试数据)
|
||||||
|
- npm run package:生成 Windows x64 安装程序
|
||||||
|
- npm run package:dir:生成免安装应用目录
|
||||||
|
|
||||||
|
依赖版本由 package-lock.json 锁定。应用、字体回退、PDF 渲染资源均使用本地资源。开发依赖下载需要网络,已安装的应用运行不依赖网络。
|
||||||
|
|
||||||
|
## 扩展
|
||||||
|
|
||||||
|
详细模块边界与扩展方法见 [架构说明](docs/ARCHITECTURE.md)。
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,101 @@
|
|||||||
|
# 架构与扩展
|
||||||
|
|
||||||
|
应用采用“桌面界面 → 白名单 IPC → 应用服务 → 业务规则与存储”的结构。Renderer 不直接访问文件系统或数据库;主进程不接受界面传来的任意 HTML、SQL 或脚本。
|
||||||
|
|
||||||
|
## 模块边界
|
||||||
|
|
||||||
|
| 位置 | 职责 |
|
||||||
|
| --- | --- |
|
||||||
|
| src/shared | 接口类型、设置契约、输入校验;不依赖 Electron |
|
||||||
|
| src/shared/receipt-groups.ts | 共用分组层级、分组摘要、勾选统计及 A4 预览选页 |
|
||||||
|
| src/shared/file-names.ts | 文件名规则、字符处理;设置示例和正式导出共用 |
|
||||||
|
| src/core/money.ts | 整数分、金额上限、人民币大写、金额格 |
|
||||||
|
| src/core/date.ts | 日期与 Excel 日期制 |
|
||||||
|
| src/core/templates.ts | 模板接口、注册器、打印页面组合;与桌面环境解耦 |
|
||||||
|
| src/main/workbooks.ts | WorkbookReader 接口及 .xlsx 适配器 |
|
||||||
|
| src/main/ledger.ts | 持久编号、批次快照及删除记录 |
|
||||||
|
| src/main/settings-store.ts | 版本化设置及原子写入 |
|
||||||
|
| src/main/confirmations.ts | 主进程确认请求、回复匹配与取消 |
|
||||||
|
| src/renderer/components/ConfirmDialog.vue | 基于 Reka UI 的统一确认弹窗 |
|
||||||
|
| src/renderer/components/ActionButton.vue | 操作触发、禁用及行内目标匹配 |
|
||||||
|
| src/renderer/components/LoadingRegion.vue | 整块区域遮罩、转圈、区域错误及取消入口 |
|
||||||
|
| src/renderer/components/ReceiptGroupTree.vue | 可折叠的整组勾选、部分选中状态及按需显示 |
|
||||||
|
| src/renderer/loading.ts | 操作任务与板块加载状态映射 |
|
||||||
|
| src/renderer/confirmations.ts | 界面确认状态及焦点恢复 |
|
||||||
|
| src/main/seals.ts | 本地图片校验、缓存、快照 |
|
||||||
|
| src/main/pdf-engine.ts | PdfEngine 接口及 Electron 渲染适配器 |
|
||||||
|
| src/main/print-documents.ts | 内存打印页面、随机本地地址及内容释放 |
|
||||||
|
| src/main/exporter.ts | 导出编排、批量分块、取消、清单与进度 |
|
||||||
|
| src/main/export-layout.ts | 学校/年级/收费项目分组、目录名称及冲突处理 |
|
||||||
|
| src/main/index.ts | 桌面生命周期、原生对话框、IPC 接线 |
|
||||||
|
| src/renderer | Vue 页面、交互状态、收据预览及 PDF 转 PNG |
|
||||||
|
|
||||||
|
界面划分为工作台、印章、记录、设置。收据 HTML 不直接从 Vue 界面截图,预览与导出都使用同一模板注册器,防止两套排版逐渐分离。
|
||||||
|
|
||||||
|
## 操作反馈
|
||||||
|
|
||||||
|
操作反馈位于对应板块。LoadingRegion 通过 as 保留原表单、面板和网格结构,显示整块浅色遮罩与居中转圈。指示器按板块与视口的交集定位,长页面滚动后仍可见。加载时 region-content 使用 inert 阻止鼠标与键盘操作,完成后恢复内容;页面顶部不再显示独立成功或错误提示条。
|
||||||
|
|
||||||
|
usePanelState 将设置、印章、生成记录、导入数据、预览与导出六个板块映射到任务。actions.task 在启动时绑定所属板块,防止同类操作重入;操作立即执行,保存和手动刷新等反馈从首帧绘制后计时,至少可见 500 毫秒;较慢的操作显示到真实完成。loadingTasks 与 busy 分开:选择/打开文件夹、切换印章及后台同步不会产生人为等待或整块遮罩。主进程通过 onProcessing 在导入、模板保存和删除的选择/确认步骤结束后通知界面开始转圈,取消选择直接结束。错误写入 panelErrors 并显示在对应区域,可关闭;启动错误显示在启动区域。
|
||||||
|
|
||||||
|
ActionButton 负责事件和禁用状态,图标与文字保持稳定。导出遮罩使用 loading-actions 插槽提供取消按钮,该插槽位于 inert 内容之外。取消请求发出后显示“正在取消”,直至主进程报告结束。预览版本号保证旧请求不能关闭新请求的加载反馈。必要的进度转圈使用独立的 region-spinner 动画,在 prefers-reduced-motion 下仍旋转;其余装饰过渡遵循系统偏好。scripts/loading-test.mjs 在可见的测试窗口逐帧检查旋转角度和持续时间,使用隔离配置及文件夹验证选择器、取消和处理阶段。
|
||||||
|
|
||||||
|
## 确认弹窗
|
||||||
|
|
||||||
|
使用 Reka UI AlertDialog,并封装统一的标题、说明、详情、提示与按钮。弹窗提供焦点约束、默认聚焦取消和 Esc 取消,业务内容通过 Vue 文本绑定呈现。删除、重复生成和退出确认共用同一组件;离开未保存设置使用同一界面服务。
|
||||||
|
|
||||||
|
主进程通过 ConfirmationService 发送带随机 id 的请求,等待白名单 IPC 回复。仅匹配当前请求的回复有效,同时只允许一个主进程请求。页面导航、进程异常或窗口关闭会取消等待中的确认。操作执行仍保留主进程的数据校验与任务状态检查。新增主进程确认调用 confirmations.ask,新增纯界面确认调用 askConfirmation。
|
||||||
|
|
||||||
|
Reka UI 按需打包到本地前端,运行不依赖网络;许可声明随构建输出到 renderer/licenses/reka-ui.txt。
|
||||||
|
|
||||||
|
## 新增收据模板
|
||||||
|
|
||||||
|
实现 ReceiptTemplate 的 id、name、css、render,并在 TemplateRegistry 构造时注册。id 是持久标识,发布后保持稳定;已有模板发生版式不兼容变化时使用新 id,保留旧实现以支持历史重打。设置页自动从注册器读取可用模板。
|
||||||
|
|
||||||
|
模板使用物理毫米布局,单张页面与 A4 页面由 buildDocument 统一组合。render 中所有业务文字经过 escapeHtml,图片仅接受已校验本地文件转换的数据 URL。
|
||||||
|
|
||||||
|
## 新增导入来源
|
||||||
|
|
||||||
|
实现 WorkbookReader.read,将新来源映射为 ImportDraft、ImportRow 和 ReceiptInput。收据编号由 Ledger 分配,导入适配器不创建编号。导入层保留 school、grade、studentName 三个来源字段,并生成以单个空格分隔的 customer 展示文字。来源字段对旧记录为可选,历史客户名称继续按快照读取,避免升级后影响补打。CSV、旧 .xls 或其他来源都可以增加适配器,再在入口按扩展名选择。
|
||||||
|
|
||||||
|
## 新增导出格式
|
||||||
|
|
||||||
|
ExportCoordinator 只依赖 PdfEngine 接口。可以替换渲染器而不改变编号与导入。新增格式在 ExportOptions 中明确声明,输入经共享校验器校验;禁止将任意文件路径和脚本通过 IPC 暴露给网页。
|
||||||
|
|
||||||
|
打印 HTML 通过随机的 receipt://print/ 地址在内存中提供,避免把整页及重复印章编码进地址而触发长度限制。每次 PDF 导出完成或失败后释放页面内容;不写临时 HTML 文件。错误提示与历史摘要会省略内嵌页面内容并限制长度。
|
||||||
|
|
||||||
|
PNG 使用已生成 PDF 的第一页渲染,保持字体和版式一致。A4 批量打印每次最多渲染 40 张收据再合并 PDF;分块大小为偶数,以免每个分块引入额外空白半页。
|
||||||
|
|
||||||
|
导出目录保留现有批次外层,内部由 groupReceipts 按 school、grade、item 三个来源字段逐层规划。目录层级定义集中在 shared/receipt-groups.ts 的 receiptGroupLevels 中,导出路径、界面核对和历史分类共用这一规则。缺失的历史字段使用未分类目录,不拆解或修改旧客户名称。目录名过滤 Windows 非法字符与保留名,截断或替换后的名称加稳定摘要,并处理大小写冲突,避免不同分组合并。
|
||||||
|
|
||||||
|
单张 PDF/PNG 按原收据顺序写入所属目录,每组 A4 单独生成,组内仍按原顺序每 40 张分块。单组失败后继续其余分组,仅打印时按分组累计成功/失败张数。生成清单保留在批次根目录,receipts.directory、receipts.files 和 printFiles 使用相对路径;重新导出从原快照重新规划目录,沿用原编号并保留同名文件追加后缀的行为。
|
||||||
|
|
||||||
|
## 分组核对与历史查找
|
||||||
|
|
||||||
|
importGroupTree 按三级分组统计有效行、待修改行、选中张数和金额;选择学校或年级时只切换有效行。界面使用原生 details 和复选框,支持部分选中状态;多于 20 个有效分组时减少初始展开,同级每次显示 50 项,关闭节点的子树按需创建。勾选直接更新原有 selected 集合。
|
||||||
|
|
||||||
|
A4 预览使用 printPreviewRows 在已选同组收据中定位当前行所在页,并按全局已选顺序展示待分配编号。预览查询不修改流水号。设置页面的实时文件名示例和正式导出使用相同的 receiptFileStem,保持前缀、命名方式和字符替换一致。
|
||||||
|
|
||||||
|
记录列表通过 listBatchPage 传递经校验的学校、年级、项目、文本、页码和每页条数,返回当前页、真实总条数及有效页码。无筛选时使用数据库 COUNT/LIMIT/OFFSET;有筛选时逐批匹配同一张收据,统计所有匹配批次并仅构建当前页摘要,末页删除后自动钳制到有效页。学校、姓名和任意编号查找仍覆盖全库。原 listBatches 继续为已有调用提供最多 200 条摘要,bootstrap 提供全库分类选项和真实总数。
|
||||||
|
|
||||||
|
界面默认每页 5 条,可选 5/10/20/50;historyPageSize 在设置校验器中为旧配置补默认值,修改后持久保存。筛选或每页条数变化时回到第一页,刷新保留当前页;使用请求版本号保持最新结果。显式翻页沿用整块加载反馈,自动筛选合并短时间内的输入变化。过滤结果仍对应完整批次,重新导出维持原有批次语义。原收据预览可指定匹配编号,旧批次输出目录经过全库归属检查。
|
||||||
|
|
||||||
|
## 编号一致性
|
||||||
|
|
||||||
|
应用使用单实例锁;主进程生成任务互斥。Ledger 内部串行事务分配流水,先原子持久保存数据库,再写导出文件。编号不因取消或失败而回收。原批次重试不再次 reserve。
|
||||||
|
|
||||||
|
批次保存原数据、默认姓名生效后的内容、模板 id、设置及印章图片快照。更换印章或移动图片不改变历史批次。导出位置与导出选项可以变化,正式编号和内容保持原值。
|
||||||
|
|
||||||
|
批次记录支持删除。主进程在确认前后检查生成任务状态,Ledger 在事务中拒绝删除 reserved/running 批次,仅移除批次快照,保留 receipt_numbers 和 sequences。已导出的文件不会由此功能删除。删除后清理当前草稿对该批次的引用。
|
||||||
|
|
||||||
|
数据损坏时停止启动和分配编号,保留原文件,不静默创建空数据库。
|
||||||
|
|
||||||
|
## 设置与升级
|
||||||
|
|
||||||
|
设置包含 schemaVersion。增加可选字段时为共享校验器和 defaultSettings 同时补默认值,保证旧配置可加载。更改数据库结构需在 Ledger.open 中增加显式事务迁移,再写兼容性测试。不要通过删除用户数据解决升级问题。
|
||||||
|
|
||||||
|
设置页面保留文件、打印、印章和编号四个板块,单张文件命名方式直接显示在文件板块。独立批次目录与成功后打开目录为固定启用行为,无界面开关;SettingsStore 在读取和保存时将旧配置中的 createBatchFolder、openAfterExport 归一为 true,其余偏好保持原值。Exporter 每次都创建批次目录,包括从旧快照重新导出。备份和恢复的界面、IPC、预加载接口、状态操作及数据库合并代码已移除。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
核心测试覆盖金额边界、日期、工作簿错误、模板转义、持久编号、旧配置兼容、分块排版、取消和重试。桌面测试实际点击界面并检查 PDF 页数、A4 尺寸、PNG 分辨率、编号重启、设置持久化及自动打开目录。实际打印机的可打印边距需在交付时试打确认。
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# 版本验证记录
|
||||||
|
|
||||||
|
版本:1.0.1。验证日期:2026-10-16。
|
||||||
|
|
||||||
|
环境:Windows 10 专业版 x64,版本 10.0.19045。开发运行时 Node.js 24.19.0。
|
||||||
|
|
||||||
|
- 类型检查与生产构建通过。
|
||||||
|
- 新版 Excel 模板使用学校、年级、姓名三列;各列去除首尾空白后,以一个空格拼接收据客户名称。必填校验、列顺序调整和来源字段持久化均通过验证;旧记录仍可补打。
|
||||||
|
- 31 项核心测试通过:金额精度和越界、金额大写、Excel 日期制、错误行、重复行、模板转义、编号持久化、旧备份合并、分块打印、取消重试、不覆盖同名文件、PNG 物理密度。
|
||||||
|
- 实际 Electron 界面验证通过:保存偏好、绑定和选择本地印章、导出模板、导入含错误行的工作簿、独立 PDF 和 PNG、A4 一页两张、奇数张留空、原编号补打、备份与重启。
|
||||||
|
- 三张收据输出为三份独立 PDF、三份 300 DPI PNG,以及两页 A4 打印 PDF。
|
||||||
|
- 打包后的独立程序验证通过:不依赖项目 node_modules,启动、Excel 导入、盖章、PDF、PNG 均通过;PNG 分辨率标记为 11811 像素/米,约为 300 DPI。
|
||||||
|
|
||||||
|
安装包:release/ReceiptStudio-1.0.1-win-x64-setup.exe。
|
||||||
|
免安装目录:release/win-unpacked(整个目录一起使用)。
|
||||||
|
|
||||||
|
尚未进行实体打印机试打。交付后请由财务使用 A4、实际大小或 100% 比例试打一页,核对印章与裁切位置。
|
||||||
|
|
||||||
|
当前安装包未配置代码签名证书。
|
||||||
|
|
||||||
|
## 本地调试版:批量打印修复(2026-10-16)
|
||||||
|
|
||||||
|
- 复现旧版 ERR_INVALID_URL:较大印章重复嵌入批量页面,data 地址中的内容达到约 11.7 百万个字符,打印加载失败。
|
||||||
|
- 打印页面改用内存中的短本地地址,导出成功或失败后释放内容。类型检查、构建和 31 项测试通过。
|
||||||
|
- 实际 Electron 验证:41 张带印章收据,跨 40 张分块边界,输出 21 页 A4;原编号重试输出 41 份单张 PDF、41 份 PNG 和 1 份 A4 PDF,下一编号保持 REG00000042。
|
||||||
|
- 模拟页面加载失败,错误提示保留错误码、隐藏页面内容,失败不显示绿色成功提示;随后原编号重试成功。
|
||||||
|
- 验证产物:.qa/print-regression/result.json。使用独立测试目录,未改写用户设置、记录及编号。
|
||||||
|
- 本次修复更新源码与本地构建,未重新生成安装包。重新启动“启动本地调试.cmd”后生效。
|
||||||
|
|
||||||
|
## 本地调试版:记录表与批次删除(2026-10-16)
|
||||||
|
|
||||||
|
- 生成记录表头由 10px 调整为 12px,提高字重与文字对比度。
|
||||||
|
- 每条批次增加删除按钮和确认对话框。删除批次快照后,导出文件、编号归属与流水保留;生成中的批次禁止删除。
|
||||||
|
- 备份合并保留已删除批次的编号归属;删除前备份可恢复原批次,并拒绝其他批次占用同一编号。
|
||||||
|
- 类型检查、构建和 35 项测试通过。实际 Electron 验证删除取消、删除确认、文件保留、重启后删除生效及流水继续;1920 与 1120 像素窗口均检查表头和操作布局。
|
||||||
|
- 验证产物:.qa/history-delete-check/result.json。使用独立测试数据,未删除用户现有批次。
|
||||||
|
- 新增桌面接口和预加载桥接,需关闭程序并重新启动本地调试后使用删除功能。本次未重新生成安装包。
|
||||||
|
|
||||||
|
## 本地调试版:统一确认弹窗(2026-10-16)
|
||||||
|
|
||||||
|
- 引入开源 Reka UI 2.11.0,统一删除、重复生成、恢复备份、未保存设置和生成中退出的确认样式;组件随应用本地打包。
|
||||||
|
- 删除弹窗采用居中卡片、图标、批次详情、保留文件说明和明确操作按钮。取消默认聚焦,Tab 焦点保持在弹窗内,Esc 取消。
|
||||||
|
- 主进程请求使用随机 id 匹配回复,取消不批准操作,界面重载或关闭时撤销等待中的确认,防止遗留请求。
|
||||||
|
- 类型检查、构建和 39 项测试通过。实际 Electron 验证重复生成取消/继续、删除取消/确认、恢复备份取消/确认、未保存设置留存/放弃、界面重载后再次确认、生成中关闭窗口后继续生成。
|
||||||
|
- 文件和编号保持验证通过,未触发业务原生提示框,无界面脚本异常。验证记录:.qa/dialogs-check/result.json;外观截图:.qa/dialogs-check/delete-dialog.png。
|
||||||
|
- 使用独立测试配置,用户现有记录未改动。更新后需要重新启动本地调试,安装包未重新生成。
|
||||||
|
|
||||||
|
## 本地调试版:操作与刷新反馈(2026-10-16)
|
||||||
|
|
||||||
|
- 保存设置、列表刷新、Excel 导入与模板保存、印章切换、目录选择、备份恢复、批次查看/删除/重新导出均使用统一操作反馈。
|
||||||
|
- 操作时显示旋转图标与处理中说明,禁止重复点击;成功后短暂打勾。快速操作的反馈保持可见,相同成功提示再次触发入场动画。取消和失败不显示成功。
|
||||||
|
- 手动刷新保留原列表并淡化,完成后说明记录/印章数量;后台同步独立处理。预览显示加载覆盖层,导出取消反馈持续至任务结束。支持减少动画的系统偏好。
|
||||||
|
- 类型检查、构建与 39 项测试通过。实际 Electron 验证保存转圈/禁用/持久化/完成打勾/重复保存提示、印章刷新计数与切换反馈、刷新失败无成功状态、生成记录刷新、取消导入无成功提示;无界面脚本异常。
|
||||||
|
- 完整桌面操作验证通过:模板导入、错误行、选择印章、单张 PDF、300 DPI PNG、A4 排版、原编号重新导出、备份与重启。
|
||||||
|
- 验证记录:.qa/feedback-check/result.json、.qa/result.json。使用独立测试目录,用户记录未改动。本次只更新源码与本地构建。
|
||||||
|
|
||||||
|
## 本地调试版:改为板块整体加载(2026-10-16)
|
||||||
|
|
||||||
|
- 根据使用反馈,加载动画改为整个对应板块的浅色遮罩与居中转圈,处理完成后恢复;移除页面顶部的独立成功、错误提示条及按钮成功打勾反馈。
|
||||||
|
- 设置区域、印章区域、生成记录、收据数据、预览和导出分别关联自己的加载状态;加载内容使用 inert 阻止重复操作,错误留在对应板块内。
|
||||||
|
- 长页面滚动后,转圈指示器保持在可见区域;导出遮罩保留可操作的取消入口,取消后等实际任务结束才恢复。
|
||||||
|
- 类型检查与构建通过。实际 Electron 验证整块遮罩尺寸、内容不可交互、滚动后指示器可见、取消导入恢复、导出遮罩中可取消、刷新失败显示在板块内;未出现界面脚本异常。
|
||||||
|
- 完整桌面导入/设置/PDF/PNG/A4/原编号补打/备份/重启验证通过。1920、1440、1120 像素窗口及 A4 预览的列表铺满与底部分页布局验证通过。
|
||||||
|
- 验证记录:.qa/regions-check/result.json、.qa/result.json、.qa/layout-check/result.json。使用独立测试配置,用户记录未改动;未重新生成安装包。
|
||||||
|
|
||||||
|
## 本地调试版:确保转圈与合理的加载时机(2026-10-16)
|
||||||
|
|
||||||
|
- 使用独立的持续旋转动画,修复系统减少动态效果时进度图标停止的问题;保存、手动刷新等操作从首帧绘制后至少显示 500 毫秒。加载仍覆盖对应板块,不增加顶部提示条。
|
||||||
|
- 选择/打开文件夹、绑定印章目录及切换印章不增加人为加载等待。预览自动更新保留短延迟以避免闪烁,工作台手动刷新直接显示完整半秒反馈。
|
||||||
|
- 增加主进程处理阶段通知:导入、保存模板、备份、恢复和删除在选好文件或确认之后才显示加载,取消选择或确认直接结束。
|
||||||
|
- 新增 test:loading 实际窗口逐帧验证。普通和减少动态效果两种设置下旋转角度持续改变,保存、三处刷新入口、导入、模板、备份恢复和确认删除实测约 520~534 毫秒;取消文件夹选择约 4 毫秒,未产生遮罩。
|
||||||
|
- 构建、类型检查、39 项单元测试和完整桌面导入/PDF/PNG/A4/原编号重导出/备份/重启验证通过。删除确认后保留已分配编号;无界面脚本异常。
|
||||||
|
- 验证记录:.qa/loading/result.json、.qa/result.json。全部使用独立测试配置,用户记录未改动。需要重新启动本地调试加载新的处理阶段接口;未重新生成安装包。
|
||||||
|
|
||||||
|
## 本地调试版:按学校、年级、收费项目分层导出(2026-10-19)
|
||||||
|
|
||||||
|
- 保留原有最外层批次目录命名,内部按“学校 → 年级 → 收费项目”创建目录;单张 PDF、PNG 和对应分组的 A4 打印版保存在项目目录,生成清单保留在批次根目录。
|
||||||
|
- 分组打印独立排版,组内保持 Excel 顺序,非连续行可归入同组;每组仍按 40 张分块,奇数张只在本组最后一页留空半页。打印分组失败后继续其他分组,并累计正确的成功/失败张数。
|
||||||
|
- 目录名处理非法字符、保留名、超长名称和大小写冲突。旧记录缺少学校或年级时使用未分类目录,不修改原客户名称。重新导出沿用原编号,保留同名文件追加后缀的规则。
|
||||||
|
- 43 项测试通过,覆盖三层文件位置、混合分组、清单相对路径、旧记录、目录冲突、取消重试、分组失败与 81 张分块排版。类型检查和构建通过。
|
||||||
|
- 实际 Electron 桌面验证导入、设置、单张 PDF、300 DPI PNG、三个独立 A4 分组、原编号分层重新导出、备份与重启通过。较大印章 41 张同组收据正确生成 21 页 A4,失败后原编号重试生成 42 个 PDF、41 个 PNG,通过错误摘要检查。
|
||||||
|
- 验证记录:.qa/result.json、.qa/print-regression/result.json。测试使用隔离配置与目录,用户记录未改动。重启本地调试后生效;未重新生成安装包。
|
||||||
|
|
||||||
|
## 本地调试版:简化设置与移除备份(2026-10-19)
|
||||||
|
|
||||||
|
- 移除备份、恢复备份和“更多偏好与数据备份”折叠区域,同时移除预加载接口、IPC 处理、界面操作和数据库备份合并代码。单张文件命名设置移到“文件与常用选择”板块。
|
||||||
|
- 独立批次目录和成功后自动打开输出目录改为固定启用,不再显示开关。旧配置曾保存为关闭时,读取后自动启用;其余偏好保留。下次保存设置时持久化这两项启用状态,旧记录和编号不变。
|
||||||
|
- 从含旧关闭状态的历史快照重新导出也创建新的独立批次目录;原导出文件保留。
|
||||||
|
- 类型检查、构建和 41 项测试通过,包含旧配置读取/保存兼容及取消后原编号独立目录重试。已移除不再适用的备份测试。
|
||||||
|
- 实际 Electron 验证设置页面入口和开关已移除、单张命名设置直接可见、备份 API 不再暴露、旧配置自动启用、新生成与重新导出均自动打开正确批次目录、重启后设置和编号连续。完整 PDF/PNG/A4/分层导出验证通过,设置页面截图已检查。
|
||||||
|
- 加载动画实际窗口验证通过,保存/刷新仍连续旋转半秒,选择文件夹不显示遮罩,删除确认和取消行为正常。
|
||||||
|
- 验证记录:.qa/result.json、.qa/loading/result.json;截图:.qa/02-settings.png。测试使用隔离目录,用户记录及已有备份文件未改动;未重新生成安装包。重启本地调试后生效。
|
||||||
|
|
||||||
|
## 本地调试版:命名示例、分组核对与记录查找(2026-10-19)
|
||||||
|
|
||||||
|
- 文件命名设置改为“导出的文件叫什么名字”,三种规则用日常表述展示;按当前前缀、选中数据和格式实时给出示例,正式导出与示例共用命名和字符处理函数。
|
||||||
|
- 导入后展示学校、年级、收费项目三级核对树。支持整组勾选、取消和部分选中状态,张数与金额实时更新;错误行单独计数且不可生成。多组数据按需展开,同级每次显示 50 项,减少初次渲染量。
|
||||||
|
- A4 预览按已选同组收据定位当前行所在页,保持实际导出的分组、空白半页和编号顺序,不推进流水号。
|
||||||
|
- 生成记录增加分类摘要及学校/年级/项目筛选,文本查找支持文件名、姓名和任意收据编号。组合条件匹配同一收据,最多返回 200 个匹配批次,覆盖更早记录;匹配信息显示姓名、编号和张数,预览直接显示匹配的原收据。重新导出维持完整批次语义,界面有明确说明。
|
||||||
|
- 类型检查、构建与 45 项测试通过,覆盖非连续行分组、错误行排除、A4 预览选页、组合条件、批次中间编号、姓名及 200 批以前的旧记录和目录归属;历史查找和预览不分配新编号。
|
||||||
|
- 实际 Electron 验证三种命名示例、分组勾选及父级部分选中、错误组禁用、分组 A4 预览、组合筛选无误命中、中间编号及姓名查找、匹配原收据预览、清空筛选、60 所学校按需加载及整组取消。完整 PDF/PNG/A4、原编号重导出和重启验证通过,无界面脚本异常。
|
||||||
|
- 加载动画验证通过,保存与刷新继续显示半秒持续旋转,选择文件夹、导入取消及删除确认保持原交互。
|
||||||
|
- 验证记录:.qa/result.json、.qa/loading/result.json;页面截图:.qa/02-settings.png、.qa/03-import-preview.png、.qa/05-history-filter.png。使用隔离测试配置,用户现有记录未改动;未重新生成安装包。重启本地调试后生效。
|
||||||
|
|
||||||
|
## 本地调试版:生成记录分页(2026-10-19)
|
||||||
|
|
||||||
|
- 生成记录默认每页 5 条,可选 5/10/20/50,列表底部显示真实总条数、当前条目范围、首页/上一页/页码/下一页/末页。每页条数保存在偏好设置中,切换板块或重启后继续使用上次选择。
|
||||||
|
- 新增 listBatchPage 查询当前页及总数,分页覆盖全部历史记录;保留原 listBatches 的调用兼容。无筛选采用数据库 COUNT/LIMIT/OFFSET,有筛选按全部匹配批次计数,避免先截取 200 条再分页。侧边栏同步显示真实总数。
|
||||||
|
- 筛选条件或每页条数改变后回到第一页;刷新保留当前页,删除末页记录后自动回到有效页。显式翻页沿用板块整体加载反馈,保留查询版本检查。
|
||||||
|
- 类型检查、构建及 46 项测试通过,覆盖默认条数、分页不重复、每页 10/50 条、筛选总数、空结果、页码越界、删除末页、201 条记录的最后一页以及旧配置默认值与条数保存。
|
||||||
|
- 新增隔离桌面验证 test:history,使用 13 个测试批次实际验证五条分页、范围和边界按钮、翻页、条数切换、筛选回首页、清空筛选、跨板块/重启记忆、逐条删除末页自动回退,流水号保持不变;截图已检查,无界面脚本异常。完整桌面导入/分组/PDF/PNG/A4/原编号补打/重启验证通过。
|
||||||
|
- 验证记录:.qa/history-pagination/result.json、.qa/result.json;截图:.qa/history-pagination/pagination.png。测试使用隔离配置,用户记录未改动;未重新生成安装包。重启本地调试后生效。
|
||||||
Generated
+6550
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"name": "receipt-studio",
|
||||||
|
"version": "1.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "离线收据生成与批量打印工具",
|
||||||
|
"author": "Receipt Studio",
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"main": "dist/main/index.cjs",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node scripts/dev.mjs",
|
||||||
|
"typecheck": "vue-tsc --noEmit && tsc -p tsconfig.main.json --noEmit",
|
||||||
|
"build": "npm run typecheck && vite build && node scripts/build-main.mjs",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:desktop": "node scripts/desktop-test.mjs",
|
||||||
|
"test:loading": "node scripts/loading-test.mjs",
|
||||||
|
"test:history": "node scripts/history-pagination-test.mjs",
|
||||||
|
"test:print": "node scripts/print-batch-test.mjs",
|
||||||
|
"start": "electron .",
|
||||||
|
"package": "npm run build && electron-builder --win nsis --x64",
|
||||||
|
"package:dir": "npm run build && electron-builder --win dir --x64",
|
||||||
|
"test:package": "node scripts/package-test.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@lucide/vue": "^1.58.0",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pdfjs-dist": "^5.4.149",
|
||||||
|
"reka-ui": "^2.11.0",
|
||||||
|
"sql.js": "^1.13.0",
|
||||||
|
"vue": "^3.5.22",
|
||||||
|
"zod": "^4.1.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.56.0",
|
||||||
|
"@types/node": "^22.18.10",
|
||||||
|
"@types/sql.js": "^1.4.9",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"electron": "^42.0.0",
|
||||||
|
"electron-builder": "^26.0.12",
|
||||||
|
"esbuild": "^0.25.11",
|
||||||
|
"pngjs": "^7.0.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.1.10",
|
||||||
|
"vitest": "^3.2.4",
|
||||||
|
"vue-tsc": "^3.1.1"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "cn.receiptstudio.desktop",
|
||||||
|
"productName": "收据工坊",
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"package.json",
|
||||||
|
"!node_modules/**/*"
|
||||||
|
],
|
||||||
|
"asar": true,
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "nsis",
|
||||||
|
"arch": [
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icon": "build/icon.ico",
|
||||||
|
"signExecutable": false
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"perMachine": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"createDesktopShortcut": true,
|
||||||
|
"deleteAppDataOnUninstall": false,
|
||||||
|
"artifactName": "ReceiptStudio-${version}-win-x64-setup.${ext}"
|
||||||
|
},
|
||||||
|
"electronDist": "node_modules/electron/dist"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.13.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { build } from 'esbuild'
|
||||||
|
import './make-icon.mjs'
|
||||||
|
await build({ entryPoints: ['src/main/index.ts'], outfile: 'dist/main/index.cjs', bundle: true, platform: 'node', target: 'node22', format: 'cjs', external: ['electron'], sourcemap: true })
|
||||||
|
await build({ entryPoints: ['src/main/preload.ts'], outfile: 'dist/main/preload.cjs', bundle: true, platform: 'node', target: 'node22', format: 'cjs', external: ['electron'] })
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { _electron as electron,expect } from '@playwright/test'
|
||||||
|
import electronPath from 'electron'
|
||||||
|
import ExcelJS from 'exceljs'
|
||||||
|
import { PNG } from 'pngjs'
|
||||||
|
import { PDFDocument } from 'pdf-lib'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
const root=process.cwd(),qa=path.join(root,'.qa'),profile=path.join(qa,'profile'),fixtures=path.join(qa,'fixtures'),output=path.join(qa,'output'),seals=path.join(fixtures,'seals')
|
||||||
|
if(!profile.startsWith(root+path.sep))throw Error('Invalid QA path')
|
||||||
|
await fs.rm(profile,{recursive:true,force:true})
|
||||||
|
await fs.mkdir(profile,{recursive:true})
|
||||||
|
await fs.writeFile(path.join(profile,'settings.json'),JSON.stringify({createBatchFolder:false,openAfterExport:false}))
|
||||||
|
await fs.mkdir(seals,{recursive:true});await fs.mkdir(output,{recursive:true})
|
||||||
|
const image=new PNG({width:240,height:240})
|
||||||
|
for(let y=0;y<240;y++)for(let x=0;x<240;x++){const i=(y*240+x)*4,d=Math.hypot(x-120,y-120),red=(d>99&&d<106)||(d>88&&d<91)||(x>65&&x<175&&y>105&&y<133);image.data[i]=195;image.data[i+1]=30;image.data[i+2]=35;image.data[i+3]=red?255:0}
|
||||||
|
await fs.writeFile(path.join(seals,'测试印章.png'),PNG.sync.write(image))
|
||||||
|
const book=new ExcelJS.Workbook(),sheet=book.addWorksheet('收据数据')
|
||||||
|
sheet.addRow(['收款日期','学校','年级','姓名','收费项目','金额','出纳','复核'])
|
||||||
|
sheet.addRow(['2026-10-16','某某学校','初二年级','张三','秋季学期服务费',3500])
|
||||||
|
sheet.addRow(['2026-10-16','某某学校','高2028届','<script>alert(1)</script>','资料费',12.34])
|
||||||
|
sheet.addRow(['2026-10-17','较长名称测试:某某学校课程服务中心','高三年级','李四','课程服务项目及相关资料费用',10001.05])
|
||||||
|
sheet.addRow(['2026-02-30','某某学校','高2028届','无效行','服务费','12.345'])
|
||||||
|
const excelFile=path.join(fixtures,'收据测试数据.xlsx');await book.xlsx.writeFile(excelFile)
|
||||||
|
const env={...process.env};delete env.ELECTRON_RUN_AS_NODE
|
||||||
|
const errors=[]
|
||||||
|
let desktop
|
||||||
|
async function launch(){
|
||||||
|
desktop=await electron.launch({executablePath:electronPath,args:['.','--test-mode'],cwd:root,env,timeout:60000})
|
||||||
|
desktop.process().stderr.on('data',data=>{const text=data.toString();if(!text.includes('Autofill'))process.stderr.write(text)})
|
||||||
|
const page=await desktop.firstWindow();page.setDefaultTimeout(20000)
|
||||||
|
page.on('pageerror',e=>errors.push(e.message))
|
||||||
|
await expect(page.getByRole('heading',{name:'收据工作台',exact:true})).toBeVisible({timeout:30000})
|
||||||
|
await expect(page.getByText('把表格交给收据工坊')).toBeVisible({timeout:30000})
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
try{
|
||||||
|
const page=await launch()
|
||||||
|
await desktop.evaluate(({dialog,shell},p)=>{
|
||||||
|
globalThis.openedDirectories=[]
|
||||||
|
shell.openPath=async folder=>{globalThis.openedDirectories.push(folder);return ''}
|
||||||
|
dialog.showOpenDialog=async(_window,options)=>({canceled:false,filePaths:[options.title==='选择印章文件夹'?p.seals:options.title==='选择导出文件夹'?p.output:p.excelFile]})
|
||||||
|
dialog.showSaveDialog=async()=>({canceled:false,filePath:p.template})
|
||||||
|
dialog.showMessageBox=async()=>({response:1,checkboxChecked:false})
|
||||||
|
},{seals,output,excelFile,template:path.join(fixtures,'模板.xlsx')})
|
||||||
|
await page.screenshot({path:path.join(qa,'01-workspace.png')})
|
||||||
|
await page.getByRole('button',{name:'下载模板',exact:true}).click()
|
||||||
|
await expect.poll(async()=>!!(await fs.stat(path.join(fixtures,'模板.xlsx')).catch(()=>null))).toBe(true)
|
||||||
|
await page.getByRole('button',{name:'偏好设置',exact:true}).click()
|
||||||
|
await expect(page.getByRole('button',{name:'更多偏好与数据备份'})).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('button',{name:'备份数据'})).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('button',{name:'恢复备份'})).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('switch',{name:'每次导出建立独立批次文件夹'})).toHaveCount(0)
|
||||||
|
await expect(page.getByRole('switch',{name:'生成完成后自动打开输出目录'})).toHaveCount(0)
|
||||||
|
await expect(page.getByLabel('导出的文件叫什么名字')).toBeVisible()
|
||||||
|
expect(await page.evaluate(()=>({backup:typeof window.receipts.backup,restore:typeof window.receipts.restore}))).toEqual({backup:'undefined',restore:'undefined'})
|
||||||
|
expect((await page.evaluate(()=>window.receipts.bootstrap())).settings).toMatchObject({createBatchFolder:true,openAfterExport:true})
|
||||||
|
await page.locator('[data-task="settings-folder"][data-task-target="output"]').click()
|
||||||
|
await page.locator('[data-task="settings-folder"][data-task-target="seal"]').click()
|
||||||
|
await expect(page.locator('[data-region="settings"]')).not.toHaveAttribute('aria-busy','true')
|
||||||
|
await page.getByLabel('默认出纳',{exact:true}).fill('李出纳')
|
||||||
|
await page.getByLabel('默认复核人',{exact:true}).fill('王复核')
|
||||||
|
await page.getByLabel('编号前缀',{exact:true}).fill('QA')
|
||||||
|
await page.getByLabel('导出的文件叫什么名字').selectOption('number')
|
||||||
|
await expect(page.locator('.filename-example')).toContainText('QA00000001.pdf')
|
||||||
|
await page.getByLabel('导出的文件叫什么名字').selectOption('customer_number')
|
||||||
|
await expect(page.locator('.filename-example')).toContainText('巴南中学 高2028届 张三_QA00000001.pdf')
|
||||||
|
await page.getByLabel('导出的文件叫什么名字').selectOption('number_customer')
|
||||||
|
await expect(page.locator('.filename-example')).toContainText('QA00000001_巴南中学 高2028届 张三.pdf')
|
||||||
|
await page.getByRole('button',{name:'保存偏好设置',exact:true}).click()
|
||||||
|
await expect(page.locator('[data-region="settings"]')).not.toHaveAttribute('aria-busy','true')
|
||||||
|
await expect(page.locator('.main-content > .feedback')).toHaveCount(0)
|
||||||
|
await page.screenshot({path:path.join(qa,'02-settings.png'),fullPage:true})
|
||||||
|
await page.getByRole('button',{name:'印章管理',exact:true}).click()
|
||||||
|
await page.getByRole('button',{name:/测试印章/}).click()
|
||||||
|
await page.getByRole('button',{name:'收据工作台',exact:true}).click()
|
||||||
|
await page.getByRole('button',{name:'导入 Excel',exact:true}).click()
|
||||||
|
await expect(page.getByText('已选择 3 条',{exact:true})).toBeVisible()
|
||||||
|
await expect(page.getByText('待修改',{exact:true})).toBeVisible()
|
||||||
|
const group=page.getByRole('checkbox',{name:'选择分组:某某学校 / 初二年级 / 秋季学期服务费',exact:true})
|
||||||
|
await group.uncheck();await expect(page.getByText('已选择 2 条',{exact:true})).toBeVisible()
|
||||||
|
await expect(page.getByRole('checkbox',{name:'选择分组:某某学校',exact:true})).toHaveJSProperty('indeterminate',true)
|
||||||
|
await group.check();await expect(page.getByText('已选择 3 条',{exact:true})).toBeVisible()
|
||||||
|
await expect(page.getByRole('checkbox',{name:'选择分组:某某学校 / 高2028届 / 服务费',exact:true})).toBeDisabled()
|
||||||
|
await page.getByLabel('单张 PNG',{exact:true}).check()
|
||||||
|
await page.frameLocator('iframe').getByText('李出纳',{exact:false}).waitFor()
|
||||||
|
await expect(page.frameLocator('iframe').getByText('某某学校 初二年级 张三',{exact:true})).toBeVisible()
|
||||||
|
await page.getByRole('button',{name:'A4 打印排版',exact:true}).click()
|
||||||
|
await expect(page.frameLocator('iframe').getByText('某某学校 初二年级 张三',{exact:true})).toBeVisible()
|
||||||
|
await expect(page.frameLocator('iframe').getByText('某某学校 高2028届 <script>alert(1)</script>',{exact:true})).toHaveCount(0)
|
||||||
|
await page.getByRole('button',{name:'单张收据',exact:true}).click()
|
||||||
|
await page.screenshot({path:path.join(qa,'03-import-preview.png'),fullPage:true})
|
||||||
|
await page.getByRole('button',{name:/生成 3 张收据/}).click()
|
||||||
|
await expect.poll(async()=>page.evaluate(async()=> (await window.receipts.listBatches())[0]?.status),{timeout:120000,intervals:[500,1000]}).toBe('completed')
|
||||||
|
const batches=await page.evaluate(()=>window.receipts.listBatches()),batch=batches[0]
|
||||||
|
await expect.poll(()=>desktop.evaluate(()=>globalThis.openedDirectories)).toContain(batch.outputDirectory)
|
||||||
|
expect(batch.count).toBe(3);expect(batch.firstNumber).toBe('QA00000001');expect(batch.lastNumber).toBe('QA00000003')
|
||||||
|
const files=await fs.readdir(batch.outputDirectory,{recursive:true})
|
||||||
|
expect(files.filter(f=>f.endsWith('.pdf'))).toHaveLength(6);expect(files.filter(f=>f.endsWith('.png'))).toHaveLength(3)
|
||||||
|
const printFiles=files.filter(f=>path.basename(f).startsWith('A4打印版'))
|
||||||
|
expect(printFiles).toHaveLength(3)
|
||||||
|
const printFile=printFiles.find(f=>f.includes('秋季学期服务费'))
|
||||||
|
const pdf=await PDFDocument.load(await fs.readFile(path.join(batch.outputDirectory,printFile)))
|
||||||
|
expect(pdf.getPageCount()).toBe(1)
|
||||||
|
expect(Math.abs(pdf.getPage(0).getWidth()-595.28)).toBeLessThan(1)
|
||||||
|
const singleFile=files.find(f=>path.basename(f).startsWith('QA00000001')&&f.endsWith('.pdf'))
|
||||||
|
expect(path.dirname(singleFile)).toBe(path.join('某某学校','初二年级','秋季学期服务费'))
|
||||||
|
expect((await PDFDocument.load(await fs.readFile(path.join(batch.outputDirectory,singleFile)))).getPageCount()).toBe(1)
|
||||||
|
const pngFile=files.find(f=>path.basename(f).startsWith('QA00000001')&&f.endsWith('.png'))
|
||||||
|
const raster=PNG.sync.read(await fs.readFile(path.join(batch.outputDirectory,pngFile)))
|
||||||
|
expect(raster.width).toBeGreaterThan(2200);expect(raster.height).toBeGreaterThan(1600)
|
||||||
|
await fs.copyFile(path.join(batch.outputDirectory,pngFile),path.join(qa,'04-receipt-300dpi.png'))
|
||||||
|
await page.getByLabel('单张 PNG',{exact:true}).uncheck();await page.getByLabel('A4 打印版',{exact:true}).uncheck()
|
||||||
|
await page.getByRole('button',{name:/生成记录/}).click()
|
||||||
|
await expect(page.getByRole('heading',{name:'最近生成的批次',exact:false})).toBeVisible()
|
||||||
|
await expect(page.locator('.history-category')).toContainText('某某学校')
|
||||||
|
await page.getByLabel('按学校查找记录').selectOption('某某学校')
|
||||||
|
await page.getByLabel('按年级查找记录').selectOption('初二年级')
|
||||||
|
await page.getByLabel('按收费项目查找记录').selectOption('资料费')
|
||||||
|
await expect(page.locator('.history-table tbody tr')).toHaveCount(0)
|
||||||
|
await page.getByRole('button',{name:'清空筛选',exact:true}).click()
|
||||||
|
await page.getByLabel('搜索生成记录').fill('QA00000002')
|
||||||
|
await expect(page.locator('.history-match')).toContainText('QA00000002')
|
||||||
|
await page.getByRole('button',{name:'查看原收据',exact:true}).click()
|
||||||
|
await expect(page.locator('.modal-card iframe')).toBeVisible()
|
||||||
|
await expect(page.frameLocator('.modal-card iframe').getByText('QA00000002',{exact:false})).toBeVisible()
|
||||||
|
await page.getByRole('button',{name:'关闭原收据预览',exact:true}).click()
|
||||||
|
await expect(page.locator('[data-region="history"]')).not.toHaveAttribute('aria-busy','true')
|
||||||
|
await page.getByLabel('搜索生成记录').fill('李四')
|
||||||
|
await expect(page.locator('.history-match')).toContainText('QA00000003')
|
||||||
|
await page.screenshot({path:path.join(qa,'05-history-filter.png'),fullPage:true})
|
||||||
|
await page.getByRole('button',{name:'清空筛选',exact:true}).click()
|
||||||
|
await expect(page.locator('.history-table tbody tr')).toHaveCount(1)
|
||||||
|
const originalDirectory=batch.outputDirectory
|
||||||
|
await page.getByRole('button',{name:'重新导出',exact:true}).first().click()
|
||||||
|
await expect.poll(async()=>page.evaluate(async old => {const b=(await window.receipts.listBatches())[0];return b?.outputDirectory!==old&&b?.status==='completed'},originalDirectory),{timeout:120000}).toBe(true)
|
||||||
|
const boot=await page.evaluate(()=>window.receipts.bootstrap())
|
||||||
|
expect(boot.nextNumber).toBe('QA00000004')
|
||||||
|
expect(boot.batches).toHaveLength(1)
|
||||||
|
const retryFiles=await fs.readdir(boot.batches[0].outputDirectory,{recursive:true})
|
||||||
|
expect(retryFiles.filter(f=>f.endsWith('.pdf'))).toHaveLength(3)
|
||||||
|
expect(retryFiles).toContain(path.join('某某学校','初二年级','秋季学期服务费',path.basename(singleFile)))
|
||||||
|
await expect.poll(()=>desktop.evaluate(()=>globalThis.openedDirectories)).toContain(boot.batches[0].outputDirectory)
|
||||||
|
// A large number of schools should not mount every nested checkbox at once.
|
||||||
|
const largeBook=new ExcelJS.Workbook(),largeSheet=largeBook.addWorksheet('收据数据')
|
||||||
|
largeSheet.addRow(['收款日期','学校','年级','姓名','收费项目','金额'])
|
||||||
|
for(let i=1;i<=60;i++)largeSheet.addRow(['2026-10-19','分组测试学校'+i,'高一','测试姓名','服务费',100])
|
||||||
|
const largeFile=path.join(fixtures,'多学校分组.xlsx');await largeBook.xlsx.writeFile(largeFile)
|
||||||
|
await desktop.evaluate(({dialog},file)=>{dialog.showOpenDialog=async()=>({canceled:false,filePaths:[file]})},largeFile)
|
||||||
|
await page.getByRole('button',{name:'收据工作台',exact:true}).click()
|
||||||
|
await page.locator('.panel-heading [data-task="import"]').click()
|
||||||
|
await expect(page.getByText('已选择 60 条',{exact:true})).toBeVisible()
|
||||||
|
await expect(page.locator('.group-tree-root > .receipt-group-node')).toHaveCount(50)
|
||||||
|
await page.getByRole('button',{name:'显示更多分组(还有 10 项)',exact:true}).click()
|
||||||
|
await expect(page.locator('.group-tree-root > .receipt-group-node')).toHaveCount(60)
|
||||||
|
await page.getByRole('checkbox',{name:'选择分组:分组测试学校60',exact:true}).uncheck()
|
||||||
|
await expect(page.getByText('已选择 59 条',{exact:true})).toBeVisible()
|
||||||
|
await desktop.close()
|
||||||
|
const restarted=await launch()
|
||||||
|
const boot2=await restarted.evaluate(()=>window.receipts.bootstrap())
|
||||||
|
expect(boot2.nextNumber).toBe('QA00000004');expect(boot2.settings.sealDirectory).toBe(seals);expect(boot2.batches).toHaveLength(1)
|
||||||
|
expect(boot2.settings).toMatchObject({createBatchFolder:true,openAfterExport:true})
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
await fs.writeFile(path.join(qa,'result.json'),JSON.stringify({passed:true,checks:['Excel 模板','错误行定位','印章文件夹读取','设置持久化','文件命名实时示例','学校/年级/项目分组勾选与错误行禁用','大批分组按需展开与加载','分组 A4 预览','单张 PDF','300 DPI PNG','学校/年级/收费项目分层','分组 A4 打印版','历史组合条件同收据匹配','中间编号及姓名查找','匹配原收据预览','原编号分层重试','备份及更多偏好区域已移除','旧关闭配置自动启用','成功后自动打开批次目录','重启编号连续'],output:batch.outputDirectory,printGroups:printFiles.length,printPages:pdf.getPageCount(),png:{width:raster.width,height:raster.height},rendererErrors:errors},null,2))
|
||||||
|
console.log('Desktop verification passed. Artifacts: '+qa)
|
||||||
|
}finally{if(desktop)await desktop.close().catch(()=>{})}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { createServer } from 'vite'
|
||||||
|
import { build } from 'esbuild'
|
||||||
|
import { _electron as electron,expect } from '@playwright/test'
|
||||||
|
import electronPath from 'electron'
|
||||||
|
import path from 'node:path'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
for(const entry of ['index','preload'])await build({entryPoints:['src/main/'+entry+'.ts'],outfile:'dist/main/'+entry+'.cjs',bundle:true,platform:'node',format:'cjs',external:['electron'],sourcemap:true})
|
||||||
|
const server=await createServer({server:{host:'127.0.0.1',port:5174,strictPort:true}})
|
||||||
|
await server.listen()
|
||||||
|
const profile=path.join(process.cwd(),'.qa','dev-window-profile')
|
||||||
|
await fs.mkdir(profile,{recursive:true})
|
||||||
|
const env={...process.env,ELECTRON_RENDERER_URL:'http://127.0.0.1:5174'}
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE
|
||||||
|
let desktop
|
||||||
|
try{
|
||||||
|
desktop=await electron.launch({executablePath:electronPath,args:['.','--user-data-dir='+profile],cwd:process.cwd(),env,timeout:30000})
|
||||||
|
const page=await desktop.firstWindow();page.setDefaultTimeout(15000)
|
||||||
|
await expect(page.getByText('把表格交给收据工坊')).toBeVisible({timeout:30000})
|
||||||
|
expect(await page.evaluate(()=>typeof window.receipts?.importExcel)).toBe('function')
|
||||||
|
expect(await desktop.evaluate(({BrowserWindow})=>BrowserWindow.getAllWindows()[0].isVisible())).toBe(true)
|
||||||
|
await desktop.evaluate(({BrowserWindow})=>{const w=BrowserWindow.getAllWindows().find(w=>w.webContents.getURL().includes('/index.html'));w.focus();w.webContents.sendInputEvent({type:'keyDown',keyCode:'F12'});w.webContents.sendInputEvent({type:'keyUp',keyCode:'F12'})})
|
||||||
|
await expect.poll(()=>desktop.evaluate(({BrowserWindow})=>BrowserWindow.getAllWindows().find(w=>w.webContents.getURL().includes('/index.html'))?.webContents.isDevToolsOpened())).toBe(true)
|
||||||
|
await desktop.evaluate(({BrowserWindow})=>BrowserWindow.getAllWindows().find(w=>w.webContents.getURL().includes('/index.html'))?.webContents.closeDevTools())
|
||||||
|
await fs.writeFile('.qa/dev-window-result.json',JSON.stringify({passed:true,visible:true,rendererReady:true,desktopApi:true,f12:true},null,2))
|
||||||
|
console.log('Development window verified: visible, ready, desktop bridge and F12.')
|
||||||
|
}finally{if(desktop)await desktop.close();await server.close()}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { createServer } from 'vite'
|
||||||
|
import { build } from 'esbuild'
|
||||||
|
import electron from 'electron'
|
||||||
|
const args=process.argv.slice(2),smoke=args.includes('--smoke-test')
|
||||||
|
console.log('正在准备收据工坊的本地开发窗口…')
|
||||||
|
for (const entry of ['index', 'preload']) await build({ entryPoints: ['src/main/'+entry+'.ts'], outfile: 'dist/main/'+entry+'.cjs', bundle: true, platform: 'node', format: 'cjs', external: ['electron'], sourcemap:true })
|
||||||
|
const server = await createServer(); await server.listen()
|
||||||
|
const env={ ...process.env, ELECTRON_RENDERER_URL: 'http://127.0.0.1:5173' }
|
||||||
|
delete env.ELECTRON_RUN_AS_NODE
|
||||||
|
const child = spawn(electron, ['.',...args], { stdio: 'inherit', windowsHide: smoke, env })
|
||||||
|
let closing=false
|
||||||
|
async function close(code=0){
|
||||||
|
if(closing)return;closing=true
|
||||||
|
if(child.exitCode===null)child.kill()
|
||||||
|
await server.close()
|
||||||
|
process.exit(code)
|
||||||
|
}
|
||||||
|
child.on('error',error=>{console.error('无法启动桌面程序:',error.message);void close(1)})
|
||||||
|
child.on('exit',code=>void close(code??0))
|
||||||
|
process.on('SIGINT',()=>void close())
|
||||||
|
process.on('SIGTERM',()=>void close())
|
||||||
|
console.log('请在自动打开的“收据工坊”桌面窗口中操作,按 F12 打开调试工具。')
|
||||||
|
console.log('界面修改会自动更新;主程序修改后请关闭并重新启动。')
|
||||||
|
if(smoke)setTimeout(()=>void close(),8000).unref()
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import {_electron as electron,expect} from '@playwright/test'
|
||||||
|
import electronPath from 'electron'
|
||||||
|
import {build} from 'esbuild'
|
||||||
|
import {createRequire} from 'node:module'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const root=process.cwd(),qa=path.join(root,'.qa','history-pagination')
|
||||||
|
await fs.mkdir(qa,{recursive:true})
|
||||||
|
const run=await fs.mkdtemp(path.join(qa,'run-')),profile=path.join(run,'profile'),output=path.join(run,'output'),runtime=path.join(run,'fixture.cjs')
|
||||||
|
await fs.mkdir(profile);await fs.mkdir(output)
|
||||||
|
await build({stdin:{contents:"export {Ledger} from './src/main/ledger';export {defaultSettings} from './src/shared/settings'",resolveDir:root},outfile:runtime,bundle:true,platform:'node',format:'cjs',logLevel:'silent'})
|
||||||
|
const {Ledger,defaultSettings}=createRequire(import.meta.url)(runtime)
|
||||||
|
const settings={...defaultSettings(output),numberPrefix:'PAGE'}
|
||||||
|
await fs.writeFile(path.join(profile,'settings.json'),JSON.stringify(settings))
|
||||||
|
const ledger=new Ledger(path.join(profile,'receipts.sqlite'));await ledger.open()
|
||||||
|
for(let i=1;i<=13;i++){
|
||||||
|
const school=i%2?'分页甲校':'分页乙校',studentName='学生'+String(i).padStart(2,'0')
|
||||||
|
const batch=await ledger.reserve({name:'分页测试'+String(i).padStart(2,'0')+'.xlsx',hash:String(i)},[{date:'2026-10-19',school,grade:'高一',studentName,customer:school+' 高一 '+studentName,item:'服务费',amountCents:10000,cashier:'',reviewer:''}],settings,null)
|
||||||
|
batch.status='completed';batch.outputDirectory=output;await ledger.update(batch)
|
||||||
|
}
|
||||||
|
const env={...process.env};delete env.ELECTRON_RUN_AS_NODE;delete env.ELECTRON_RENDERER_URL
|
||||||
|
let desktop
|
||||||
|
const errors=[],checks=[]
|
||||||
|
async function launch(){
|
||||||
|
desktop=await electron.launch({executablePath:electronPath,args:['.','--test-mode','--user-data-dir='+profile],cwd:root,env,timeout:60000})
|
||||||
|
const page=await desktop.firstWindow();page.setDefaultTimeout(20000);page.on('pageerror',error=>errors.push(error.message))
|
||||||
|
await page.getByText('把表格交给收据工坊').waitFor()
|
||||||
|
await page.getByRole('button',{name:/^生成记录/}).click()
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
try{
|
||||||
|
let page=await launch()
|
||||||
|
const rows=()=>page.locator('.history-table tbody tr')
|
||||||
|
const position=()=>page.locator('.history-page-position')
|
||||||
|
async function idle(){
|
||||||
|
await expect(page.locator('[data-region="history"]')).not.toHaveAttribute('aria-busy','true')
|
||||||
|
await expect(page.locator('.history-search>span')).not.toContainText('正在')
|
||||||
|
}
|
||||||
|
await expect(rows()).toHaveCount(5);await idle()
|
||||||
|
await expect(page.getByLabel('生成记录每页条数')).toHaveValue('5')
|
||||||
|
await expect(position()).toHaveText('第 1 / 3 页')
|
||||||
|
await expect(page.locator('.history-range')).toContainText('共 13 条')
|
||||||
|
await expect(page.getByRole('button',{name:'生成记录上一页',exact:true})).toBeDisabled()
|
||||||
|
await expect(rows().first()).toContainText('分页测试13.xlsx')
|
||||||
|
await page.screenshot({path:path.join(qa,'pagination.png'),fullPage:true})
|
||||||
|
await page.getByRole('button',{name:'生成记录下一页',exact:true}).click();await idle()
|
||||||
|
await expect(position()).toHaveText('第 2 / 3 页');await expect(rows()).toHaveCount(5)
|
||||||
|
await expect(rows().first()).toContainText('分页测试08.xlsx')
|
||||||
|
await expect(page.locator('.history-range')).toContainText('第 6–10 条')
|
||||||
|
await page.getByRole('button',{name:'生成记录末页',exact:true}).click();await idle()
|
||||||
|
await expect(rows()).toHaveCount(3);await expect(position()).toHaveText('第 3 / 3 页')
|
||||||
|
await expect(page.getByRole('button',{name:'生成记录下一页',exact:true})).toBeDisabled()
|
||||||
|
checks.push('Default five rows, accurate total/range, next/last page and disabled boundaries')
|
||||||
|
await page.getByLabel('生成记录每页条数').selectOption('10');await idle()
|
||||||
|
await expect(rows()).toHaveCount(10);await expect(position()).toHaveText('第 1 / 2 页')
|
||||||
|
await page.getByLabel('生成记录每页条数').selectOption('20');await idle()
|
||||||
|
await expect(rows()).toHaveCount(13);await expect(position()).toHaveText('第 1 / 1 页')
|
||||||
|
await page.getByLabel('生成记录每页条数').selectOption('5');await idle()
|
||||||
|
await page.getByLabel('按学校查找记录').selectOption('分页甲校');await idle()
|
||||||
|
await expect(page.locator('.history-range')).toContainText('共 7 条')
|
||||||
|
await page.getByRole('button',{name:'生成记录下一页',exact:true}).click();await idle()
|
||||||
|
await expect(rows()).toHaveCount(2);await expect(position()).toHaveText('第 2 / 2 页')
|
||||||
|
await page.getByLabel('搜索生成记录').fill('学生01');await idle()
|
||||||
|
await expect(rows()).toHaveCount(1);await expect(position()).toHaveText('第 1 / 1 页')
|
||||||
|
await expect(page.locator('.history-match')).toContainText('学生01')
|
||||||
|
await page.getByRole('button',{name:'清空筛选',exact:true}).click();await idle()
|
||||||
|
await expect(rows()).toHaveCount(5);await expect(page.locator('.history-range')).toContainText('共 13 条')
|
||||||
|
checks.push('Page-size changes and filters reset to page one; matching totals span all pages')
|
||||||
|
await page.getByLabel('生成记录每页条数').selectOption('10');await idle()
|
||||||
|
await page.getByRole('button',{name:'收据工作台',exact:true}).click()
|
||||||
|
await page.getByRole('button',{name:/^生成记录/}).click();await idle()
|
||||||
|
await expect(rows()).toHaveCount(10);await expect(page.getByLabel('生成记录每页条数')).toHaveValue('10')
|
||||||
|
await desktop.close();desktop=undefined
|
||||||
|
page=await launch();await idle()
|
||||||
|
await expect(rows()).toHaveCount(10);await expect(page.getByLabel('生成记录每页条数')).toHaveValue('10')
|
||||||
|
expect((await page.evaluate(()=>window.receipts.bootstrap())).settings.historyPageSize).toBe(10)
|
||||||
|
checks.push('Rows per page remembered across navigation and application restart')
|
||||||
|
await page.getByLabel('生成记录每页条数').selectOption('5');await idle()
|
||||||
|
await page.getByRole('button',{name:'生成记录末页',exact:true}).click();await idle()
|
||||||
|
for(let remaining=12;remaining>=10;remaining--){
|
||||||
|
await rows().last().getByRole('button',{name:'删除',exact:true}).click()
|
||||||
|
await page.getByRole('alertdialog').getByRole('button',{name:'删除记录',exact:true}).click();await idle()
|
||||||
|
await expect(page.locator('.history-range')).toContainText('共 '+remaining+' 条')
|
||||||
|
}
|
||||||
|
await expect(position()).toHaveText('第 2 / 2 页');await expect(rows()).toHaveCount(5)
|
||||||
|
expect((await page.evaluate(()=>window.receipts.bootstrap())).nextNumber).toBe('PAGE00000014')
|
||||||
|
await page.getByLabel('搜索生成记录').fill('没有对应的学生');await idle()
|
||||||
|
await expect(rows()).toHaveCount(0);await expect(position()).toHaveText('第 1 / 1 页')
|
||||||
|
await expect(page.locator('.history-range')).toContainText('共 0 条')
|
||||||
|
await expect(page.getByRole('button',{name:'生成记录下一页',exact:true})).toBeDisabled()
|
||||||
|
checks.push('Deleting the last page clamps to a valid page; no-result filters and issued numbers remain correct')
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
await fs.writeFile(path.join(qa,'result.json'),JSON.stringify({passed:true,checks,rendererErrors:errors},null,2))
|
||||||
|
console.log('History pagination verified: '+checks.join('; '))
|
||||||
|
}finally{
|
||||||
|
if(desktop){await desktop.evaluate(({app})=>app.exit(0)).catch(()=>{});await desktop.close().catch(()=>{})}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import {createServer} from 'vite'
|
||||||
|
import {_electron as electron,expect} from '@playwright/test'
|
||||||
|
import electronPath from 'electron'
|
||||||
|
import ExcelJS from 'exceljs'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
// All data and dialog results belong to this isolated run.
|
||||||
|
const root=process.cwd(),qa=path.join(root,'.qa','loading')
|
||||||
|
await fs.mkdir(qa,{recursive:true})
|
||||||
|
const run=await fs.mkdtemp(path.join(qa,'run-')),profile=path.join(run,'profile'),seals=path.join(run,'seals')
|
||||||
|
await fs.mkdir(seals)
|
||||||
|
const file=path.join(run,'动画验证.xlsx'),template=path.join(run,'模板.xlsx')
|
||||||
|
const book=new ExcelJS.Workbook(),sheet=book.addWorksheet('收据数据')
|
||||||
|
sheet.addRow(['收款日期','学校','年级','姓名','收费项目','金额'])
|
||||||
|
sheet.addRow(['2026-10-16','测试学校','高2028届','测试姓名','服务费',3500])
|
||||||
|
await book.xlsx.writeFile(file)
|
||||||
|
const server=await createServer({server:{host:'127.0.0.1',port:5176,strictPort:true}});await server.listen()
|
||||||
|
const env={...process.env,ELECTRON_RENDERER_URL:'http://127.0.0.1:5176'};delete env.ELECTRON_RUN_AS_NODE
|
||||||
|
const checks=[],frames=[],errors=[]
|
||||||
|
let desktop
|
||||||
|
try{
|
||||||
|
desktop=await electron.launch({executablePath:electronPath,args:['.','--test-mode','--user-data-dir='+profile],cwd:root,env})
|
||||||
|
const page=await desktop.firstWindow();page.setDefaultTimeout(15000);page.on('pageerror',e=>errors.push(e.message))
|
||||||
|
await page.getByText('把表格交给收据工坊').waitFor()
|
||||||
|
// Hidden Chromium windows do not advance CSS timelines, so inspect a visible, unfocused test window.
|
||||||
|
await desktop.evaluate(({BrowserWindow,dialog,shell})=>{
|
||||||
|
BrowserWindow.getAllWindows()[0].showInactive()
|
||||||
|
const picker=()=>new Promise(resolve=>{globalThis.finishPicker=resolve})
|
||||||
|
dialog.showOpenDialog=picker;dialog.showSaveDialog=picker
|
||||||
|
shell.openPath=async()=>new Promise(resolve=>{globalThis.finishOpen=()=>resolve('')})
|
||||||
|
})
|
||||||
|
async function arm(scope){
|
||||||
|
await page.evaluate(scope=>{
|
||||||
|
window.loadingTrace=new Promise((resolve,reject)=>{
|
||||||
|
let start
|
||||||
|
const transforms=[],deadline=performance.now()+10000
|
||||||
|
function sample(now){
|
||||||
|
const region=document.querySelector('[data-region="'+scope+'"]'),spinner=region?.querySelector('.region-spinner')
|
||||||
|
if(spinner&®ion.getAttribute('aria-busy')==='true'){
|
||||||
|
start??=now;transforms.push(getComputedStyle(spinner).transform)
|
||||||
|
}else if(start!==undefined){resolve({duration:now-start,frames:transforms.length,uniqueTransforms:new Set(transforms).size});return}
|
||||||
|
if(now>deadline){reject(Error('No loading cycle in '+scope));return}
|
||||||
|
requestAnimationFrame(sample)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(sample)
|
||||||
|
})
|
||||||
|
},scope)
|
||||||
|
}
|
||||||
|
async function checkTrace(name){
|
||||||
|
const result=await page.evaluate(()=>window.loadingTrace)
|
||||||
|
expect(result.duration).toBeGreaterThanOrEqual(490)
|
||||||
|
expect(result.duration).toBeLessThan(1800)
|
||||||
|
expect(result.frames).toBeGreaterThan(8);expect(result.uniqueTransforms).toBeGreaterThan(5)
|
||||||
|
frames.push({name,...result})
|
||||||
|
await expect(page.getByRole('status')).toHaveCount(0)
|
||||||
|
}
|
||||||
|
async function noLoading(scope){
|
||||||
|
const region=page.locator('[data-region="'+scope+'"]')
|
||||||
|
await expect(region).not.toHaveAttribute('aria-busy','true')
|
||||||
|
await expect(region.locator('.region-loading')).toHaveCount(0)
|
||||||
|
expect(await region.locator('.region-content').evaluate(el=>el.inert)).toBe(false)
|
||||||
|
}
|
||||||
|
async function resolvePicker(value){
|
||||||
|
await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function')
|
||||||
|
await desktop.evaluate((_,value)=>{globalThis.finishPicker(value);delete globalThis.finishPicker},value)
|
||||||
|
}
|
||||||
|
async function watchNoMasks(){
|
||||||
|
await page.evaluate(()=>{
|
||||||
|
window.maskCount=0
|
||||||
|
window.maskObserver=new MutationObserver(records=>{
|
||||||
|
for(const r of records)for(const n of r.addedNodes)if(n instanceof Element&&(n.matches('.region-loading')||n.querySelector('.region-loading')))window.maskCount++
|
||||||
|
})
|
||||||
|
window.maskObserver.observe(document.body,{childList:true,subtree:true})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function stopWatching(){
|
||||||
|
const count=await page.evaluate(()=>{window.maskObserver.disconnect();return window.maskCount})
|
||||||
|
expect(count).toBe(0)
|
||||||
|
}
|
||||||
|
await page.getByRole('button',{name:'偏好设置',exact:true}).click()
|
||||||
|
for(const reducedMotion of ['no-preference','reduce']){
|
||||||
|
await page.emulateMedia({reducedMotion})
|
||||||
|
await page.getByLabel('默认出纳',{exact:true}).fill('动画验证 '+reducedMotion)
|
||||||
|
await arm('settings');await page.locator('button[data-task="settings"]').click()
|
||||||
|
await checkTrace('save / '+reducedMotion)
|
||||||
|
}
|
||||||
|
checks.push('Real fast saves rotate continuously for at least 500 ms with both motion preferences')
|
||||||
|
// A pending native folder picker must leave the form clear and avoid a delayed mask on cancellation.
|
||||||
|
await watchNoMasks()
|
||||||
|
await page.locator('[data-task="settings-folder"][data-task-target="output"]').click()
|
||||||
|
await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function')
|
||||||
|
await noLoading('settings')
|
||||||
|
expect(await page.getByLabel('默认出纳',{exact:true}).isEditable()).toBe(true)
|
||||||
|
await page.evaluate(()=>{window.pickerCancelledAt=performance.now()})
|
||||||
|
await resolvePicker({canceled:true,filePaths:[]})
|
||||||
|
await expect.poll(()=>page.evaluate(async()=>!(await import('/state.ts')).state.busy.has('settings-folder'))).toBe(true)
|
||||||
|
const cancelMs=await page.evaluate(()=>performance.now()-window.pickerCancelledAt)
|
||||||
|
expect(cancelMs).toBeLessThan(450);await stopWatching()
|
||||||
|
checks.push('Folder selection has no mask; cancellation adds no 500 ms wait')
|
||||||
|
// Opening a folder should also leave the form interactive.
|
||||||
|
await watchNoMasks()
|
||||||
|
await page.evaluate(async()=>{
|
||||||
|
const {actions,state}=await import('/state.ts')
|
||||||
|
void actions.open(state.dataDirectory)
|
||||||
|
})
|
||||||
|
await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishOpen)).toBe('function')
|
||||||
|
await noLoading('settings')
|
||||||
|
await desktop.evaluate(()=>{globalThis.finishOpen()})
|
||||||
|
await desktop.evaluate(({shell})=>{shell.openPath=async()=>''})
|
||||||
|
await expect.poll(()=>page.evaluate(async()=>!(await import('/state.ts')).state.busy.has('open'))).toBe(true)
|
||||||
|
await stopWatching()
|
||||||
|
checks.push('Opening local folders has no mask')
|
||||||
|
await page.getByRole('button',{name:'印章管理',exact:true}).click()
|
||||||
|
await arm('seals');await page.locator('[data-task="refresh-seals"]').click();await checkTrace('refresh seals')
|
||||||
|
// Binding a folder includes saving its path and reloading images, without a synthetic loading mask.
|
||||||
|
await watchNoMasks()
|
||||||
|
await page.locator('.panel-heading [data-task="folder"]').click()
|
||||||
|
await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function')
|
||||||
|
await noLoading('seals')
|
||||||
|
await resolvePicker({canceled:false,filePaths:[seals]})
|
||||||
|
await expect.poll(()=>page.evaluate(async()=>!(await import('/state.ts')).state.busy.has('folder'))).toBe(true)
|
||||||
|
await stopWatching()
|
||||||
|
checks.push('Choosing and binding a stamp folder has no mask')
|
||||||
|
await page.getByRole('button',{name:/^生成记录/}).click()
|
||||||
|
await arm('history');await page.locator('[data-task="refresh-records"]').click();await checkTrace('refresh history')
|
||||||
|
checks.push('Both manual refresh actions show half a second of real rotation')
|
||||||
|
await page.getByRole('button',{name:'收据工作台',exact:true}).click()
|
||||||
|
await arm('preview');await page.locator('[data-region="preview"] [data-task="refresh-seals"]').click();await checkTrace('refresh seals from workbench')
|
||||||
|
await page.locator('.panel-heading [data-task="import"]').click()
|
||||||
|
await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function')
|
||||||
|
await noLoading('data')
|
||||||
|
await resolvePicker({canceled:true,filePaths:[]})
|
||||||
|
await expect.poll(()=>page.evaluate(async()=>!(await import('/state.ts')).state.busy.has('import'))).toBe(true)
|
||||||
|
await noLoading('data')
|
||||||
|
await page.locator('.panel-heading [data-task="import"]').click()
|
||||||
|
await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function')
|
||||||
|
await noLoading('data');await arm('data')
|
||||||
|
await resolvePicker({canceled:false,filePaths:[file]})
|
||||||
|
await checkTrace('import after file chosen')
|
||||||
|
await expect(page.getByText('测试学校 高2028届 测试姓名',{exact:true})).toBeVisible()
|
||||||
|
checks.push('Import rotates during processing only, after accepting the file picker')
|
||||||
|
await page.locator('[data-task="template"]').click()
|
||||||
|
await expect.poll(()=>desktop.evaluate(()=>typeof globalThis.finishPicker)).toBe('function')
|
||||||
|
await noLoading('data');await arm('data')
|
||||||
|
await resolvePicker({canceled:false,filePath:template})
|
||||||
|
await checkTrace('template after destination chosen')
|
||||||
|
expect((await fs.stat(template)).size).toBeGreaterThan(0)
|
||||||
|
checks.push('Template starts loading after accepting its picker')
|
||||||
|
// Generate one isolated batch to verify that deleting waits for confirmation before animating.
|
||||||
|
await page.evaluate(async output=>{
|
||||||
|
const {actions,state}=await import('/state.ts')
|
||||||
|
await window.receipts.saveSettings({...state.settings,outputDirectory:output,exportPdf:true,exportPng:false,exportPrint:false})
|
||||||
|
await actions.reload()
|
||||||
|
},path.join(run,'output'))
|
||||||
|
await page.getByRole('button',{name:'收据工作台',exact:true}).click()
|
||||||
|
await page.locator('button.export-button').click()
|
||||||
|
await expect.poll(()=>page.evaluate(async()=>(await window.receipts.listBatches())[0]?.status),{timeout:30000}).toBe('completed')
|
||||||
|
await page.getByRole('button',{name:/^生成记录/}).click()
|
||||||
|
await page.locator('[data-task="delete-batch"]').click()
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible();await noLoading('history')
|
||||||
|
await page.getByRole('alertdialog').getByRole('button',{name:'取消',exact:true}).click()
|
||||||
|
await expect(page.getByRole('alertdialog')).toHaveCount(0)
|
||||||
|
await expect.poll(()=>page.evaluate(async()=>!(await import('/state.ts')).state.busy.has('delete-batch'))).toBe(true)
|
||||||
|
await noLoading('history')
|
||||||
|
expect((await page.evaluate(()=>window.receipts.listBatches())).length).toBe(1)
|
||||||
|
await page.locator('[data-task="delete-batch"]').click()
|
||||||
|
await expect(page.getByRole('alertdialog')).toBeVisible();await noLoading('history')
|
||||||
|
await arm('history')
|
||||||
|
await page.getByRole('alertdialog').getByRole('button',{name:'删除记录',exact:true}).click()
|
||||||
|
await checkTrace('delete after confirmation')
|
||||||
|
expect((await page.evaluate(()=>window.receipts.listBatches())).length).toBe(0)
|
||||||
|
expect((await page.evaluate(()=>window.receipts.bootstrap())).nextNumber).toBe('SJ00000002')
|
||||||
|
checks.push('Delete cancellation is immediate; confirmed deletion rotates and preserves reserved numbers')
|
||||||
|
await expect(page.locator('.main-content > .feedback')).toHaveCount(0)
|
||||||
|
expect(errors).toEqual([])
|
||||||
|
await fs.writeFile(path.join(qa,'result.json'),JSON.stringify({passed:true,checks,frames,folderCancellationMs:cancelMs,rendererErrors:errors},null,2))
|
||||||
|
console.log(JSON.stringify({passed:true,checks,frames,folderCancellationMs:cancelMs},null,2))
|
||||||
|
}finally{
|
||||||
|
if(desktop){await desktop.evaluate(({app})=>app.exit(0)).catch(()=>{});await desktop.close().catch(()=>{})}
|
||||||
|
await server.close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {PNG} from 'pngjs'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
const png=new PNG({width:256,height:256})
|
||||||
|
for(let y=0;y<256;y++)for(let x=0;x<256;x++){
|
||||||
|
const i=(y*256+x)*4,dx=Math.max(38-x,0,x-217),dy=Math.max(38-y,0,y-217)
|
||||||
|
let color=[22,133,118,dx*dx+dy*dy<=38*38?255:0]
|
||||||
|
if(x>=64&&x<=192&&y>=39&&y<=216)color=[255,255,255,255]
|
||||||
|
if(x>=163&&y<=68&&y<39+(x-163))color=[22,133,118,255]
|
||||||
|
if((y>=89&&y<=95||y>=112&&y<=118||y>=135&&y<=141)&&x>=83&&x<=171)color=[144,199,185,255]
|
||||||
|
const r=Math.hypot(x-158,y-183)
|
||||||
|
if(r>=18&&r<=23)color=[22,133,118,255]
|
||||||
|
for(let c=0;c<4;c++)png.data[i+c]=color[c]
|
||||||
|
}
|
||||||
|
const data=PNG.sync.write(png),head=Buffer.alloc(22)
|
||||||
|
head.writeUInt16LE(1,2);head.writeUInt16LE(1,4);head[6]=0;head[7]=0;head.writeUInt16LE(1,10);head.writeUInt16LE(32,12);head.writeUInt32LE(data.length,14);head.writeUInt32LE(22,18)
|
||||||
|
await fs.mkdir('build',{recursive:true});await fs.mkdir('dist',{recursive:true})
|
||||||
|
await fs.writeFile('build/icon.png',data);await fs.writeFile('build/icon.ico',Buffer.concat([head,data]));await fs.writeFile('dist/icon.png',data)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { _electron as electron,expect } from '@playwright/test'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {PDFDocument} from 'pdf-lib'
|
||||||
|
const profile=path.join(process.cwd(),'.qa','packaged-profile-1.0.1')
|
||||||
|
await fs.mkdir(profile,{recursive:true})
|
||||||
|
const env={...process.env};delete env.ELECTRON_RUN_AS_NODE
|
||||||
|
let app
|
||||||
|
try{
|
||||||
|
app=await electron.launch({executablePath:path.join(process.cwd(),'release','win-unpacked','收据工坊.exe'),args:['--smoke-test','--user-data-dir='+profile],env,timeout:60000})
|
||||||
|
const page=await app.firstWindow();page.setDefaultTimeout(20000)
|
||||||
|
await expect(page.getByRole('heading',{name:'收据工作台',exact:true})).toBeVisible({timeout:30000})
|
||||||
|
await expect(page.getByText('把表格交给收据工坊')).toBeVisible({timeout:30000})
|
||||||
|
const boot=await page.evaluate(()=>window.receipts.bootstrap())
|
||||||
|
expect(boot.dataDirectory).toBe(profile)
|
||||||
|
expect(boot.nextNumber).toBe('SJ00000001')
|
||||||
|
const sealFolder=path.join(process.cwd(),'.qa','fixtures','seals'),output=path.join(process.cwd(),'.qa','packaged-output')
|
||||||
|
await fs.mkdir(output,{recursive:true})
|
||||||
|
await app.evaluate(({shell})=>{shell.openPath=async()=>''})
|
||||||
|
await page.evaluate(async({output,sealFolder})=>{const b=await window.receipts.bootstrap();await window.receipts.saveSettings({...b.settings,outputDirectory:output,sealDirectory:sealFolder,numberPrefix:'PACK'})},{output,sealFolder})
|
||||||
|
const seals=await page.evaluate(()=>window.receipts.listSeals());expect(seals.images.length).toBeGreaterThan(0)
|
||||||
|
await app.evaluate(({dialog},file)=>{dialog.showOpenDialog=async()=>({canceled:false,filePaths:[file]})},path.join(process.cwd(),'.qa','fixtures','收据测试数据.xlsx'))
|
||||||
|
const draft=await page.evaluate(()=>window.receipts.importExcel())
|
||||||
|
expect(draft.rows[0].receipt.customer).toBe('某某学校 初二年级 张三')
|
||||||
|
expect(draft.rows[0].receipt.school).toBe('某某学校')
|
||||||
|
expect(draft.rows[0].receipt.grade).toBe('初二年级')
|
||||||
|
expect(draft.rows[0].receipt.studentName).toBe('张三')
|
||||||
|
await page.evaluate(async({id,sealId})=>window.receipts.exportReceipts({draftId:id,rowIndices:[0],sealId,options:{pdf:true,png:true,print:true,receiptsPerPage:2,cutLine:true}}),{id:draft.id,sealId:seals.images[0].id})
|
||||||
|
await expect.poll(async()=>page.evaluate(async()=> (await window.receipts.listBatches())[0]?.status),{timeout:90000}).toBe('completed')
|
||||||
|
const batch=(await page.evaluate(()=>window.receipts.listBatches()))[0]
|
||||||
|
const files=await fs.readdir(batch.outputDirectory,{recursive:true})
|
||||||
|
expect(files.filter(n=>n.endsWith('.png'))).toHaveLength(1)
|
||||||
|
const png=await fs.readFile(path.join(batch.outputDirectory,files.find(n=>n.endsWith('.png'))))
|
||||||
|
expect(png.readUInt32BE(png.indexOf('pHYs')+4)).toBe(11811)
|
||||||
|
const pdf=await PDFDocument.load(await fs.readFile(path.join(batch.outputDirectory,files.find(n=>path.basename(n).startsWith('A4')))))
|
||||||
|
expect(pdf.getPageCount()).toBe(1)
|
||||||
|
await fs.writeFile('.qa/package-result.json',JSON.stringify({passed:true,platform:'Windows 10 x64',packaged:true,renderedPdf:true,renderedPng:true,dpi:300,profile},null,2))
|
||||||
|
console.log('Packaged application verified: standalone startup, Excel, seals, PDF and PNG.')
|
||||||
|
}finally{if(app)await app.close().catch(()=>{})}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { _electron as electron,expect } from '@playwright/test'
|
||||||
|
import electronPath from 'electron'
|
||||||
|
import ExcelJS from 'exceljs'
|
||||||
|
import { PNG } from 'pngjs'
|
||||||
|
import { PDFDocument } from 'pdf-lib'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
const root=process.cwd(),qa=path.join(root,'.qa','print-regression'),profile=path.join(qa,'profile'),output=path.join(qa,'output'),seals=path.join(qa,'seals')
|
||||||
|
if(!qa.startsWith(root+path.sep))throw Error('Invalid QA path')
|
||||||
|
await fs.rm(qa,{recursive:true,force:true})
|
||||||
|
await fs.mkdir(seals,{recursive:true});await fs.mkdir(output,{recursive:true})
|
||||||
|
const image=new PNG({width:256,height:256})
|
||||||
|
let seed=731
|
||||||
|
for(let i=0;i<image.data.length;i+=4){
|
||||||
|
seed=(Math.imul(seed,1664525)+1013904223)>>>0
|
||||||
|
image.data[i]=130+(seed&127);image.data[i+1]=(seed>>>8)&63;image.data[i+2]=(seed>>>16)&63;image.data[i+3]=100+(seed>>>24)%156
|
||||||
|
}
|
||||||
|
await fs.writeFile(path.join(seals,'批量打印测试章.png'),PNG.sync.write(image))
|
||||||
|
const book=new ExcelJS.Workbook(),sheet=book.addWorksheet('收据数据')
|
||||||
|
sheet.addRow(['收款日期','学校','年级','姓名','收费项目','金额','出纳','复核'])
|
||||||
|
for(let i=1;i<=41;i++)sheet.addRow(['2026-10-16','测试学校','高2028届','测试学生'+i,'测试收费项目',3500,'测试出纳','测试复核'])
|
||||||
|
const excelFile=path.join(qa,'批量测试.xlsx');await book.xlsx.writeFile(excelFile)
|
||||||
|
const env={...process.env};delete env.ELECTRON_RUN_AS_NODE;delete env.ELECTRON_RENDERER_URL
|
||||||
|
let desktop
|
||||||
|
try{
|
||||||
|
desktop=await electron.launch({executablePath:electronPath,args:['.','--test-mode','--user-data-dir='+profile],cwd:root,env,timeout:60000})
|
||||||
|
const page=await desktop.firstWindow();page.setDefaultTimeout(30000)
|
||||||
|
await expect(page.getByRole('heading',{name:'收据工作台',exact:true})).toBeVisible()
|
||||||
|
await expect.poll(()=>page.evaluate(async()=>!!(await window.receipts.bootstrap()).settings)).toBe(true)
|
||||||
|
await desktop.evaluate(({dialog,shell},p)=>{dialog.showOpenDialog=async()=>({canceled:false,filePaths:[p.excelFile]});shell.openPath=async()=>''},{excelFile})
|
||||||
|
await page.evaluate(async p=>{
|
||||||
|
const boot=await window.receipts.bootstrap()
|
||||||
|
await window.receipts.saveSettings({...boot.settings,outputDirectory:p.output,sealDirectory:p.seals,numberPrefix:'REG',exportPdf:false,exportPng:false,exportPrint:true})
|
||||||
|
},{output,seals})
|
||||||
|
const sealCharacters=await page.evaluate(async()=> (await window.receipts.listSeals()).images[0].dataUrl.length)
|
||||||
|
expect(sealCharacters*40).toBeGreaterThan(2*1024*1024)
|
||||||
|
const batchId=await page.evaluate(async()=>{
|
||||||
|
const draft=await window.receipts.importExcel(),seal=(await window.receipts.listSeals()).images[0]
|
||||||
|
window.printTestProgress=[]
|
||||||
|
window.receipts.onProgress(p=>window.printTestProgress.push({finished:p.finished,status:p.status,errorLength:p.errors.join('').length}))
|
||||||
|
return window.receipts.exportReceipts({draftId:draft.id,rowIndices:draft.rows.map(r=>r.index),sealId:seal.id,options:{pdf:false,png:false,print:true,receiptsPerPage:2,cutLine:true}})
|
||||||
|
})
|
||||||
|
await expect.poll(()=>page.evaluate(async()=> (await window.receipts.listBatches())[0]),{timeout:120000,intervals:[500,1000]}).toMatchObject({status:process.argv.includes('--expect-failure')?'failed':'completed'})
|
||||||
|
const summary=await page.evaluate(async()=> (await window.receipts.listBatches())[0])
|
||||||
|
if(process.argv.includes('--expect-failure')){
|
||||||
|
expect(summary.message).toContain('ERR_INVALID_URL')
|
||||||
|
console.log(JSON.stringify({reproduced:true,receiptCount:summary.count,messageCharacters:summary.message.length,imageCharactersInFirstChunk:sealCharacters*40}))
|
||||||
|
}else{
|
||||||
|
const files=await fs.readdir(summary.outputDirectory,{recursive:true})
|
||||||
|
const pdf=await PDFDocument.load(await fs.readFile(path.join(summary.outputDirectory,files.find(f=>f.endsWith('.pdf')))))
|
||||||
|
expect(pdf.getPageCount()).toBe(21);expect(Math.abs(pdf.getPage(0).getWidth()-595.28)).toBeLessThan(1)
|
||||||
|
const manifest=JSON.parse(await fs.readFile(path.join(summary.outputDirectory,'生成清单.json'),'utf8'))
|
||||||
|
expect(manifest.errors).toEqual([]);expect(manifest.receipts).toHaveLength(41)
|
||||||
|
expect((await page.evaluate(()=>window.receipts.bootstrap())).nextNumber).toBe('REG00000042')
|
||||||
|
const firstDirectory=summary.outputDirectory
|
||||||
|
await desktop.evaluate(({BrowserWindow})=>{
|
||||||
|
const original=BrowserWindow.prototype.loadURL
|
||||||
|
BrowserWindow.prototype.receiptTestLoadURL=original
|
||||||
|
BrowserWindow.prototype.loadURL=function(url,...args){
|
||||||
|
if(url.startsWith('receipt://print/'))return Promise.reject(new Error("ERR_INVALID_URL (-300) loading 'data:text/html;charset=utf-8,"+'sensitive'.repeat(300000)+"'"))
|
||||||
|
return original.call(this,url,...args)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await page.evaluate(async id=>window.receipts.exportReceipts({batchId:id,options:{pdf:false,png:false,print:true,receiptsPerPage:2,cutLine:true}}),batchId)
|
||||||
|
await expect.poll(()=>page.evaluate(async()=> (await window.receipts.listBatches())[0].status),{timeout:30000}).toBe('failed')
|
||||||
|
await expect(page.getByRole('alert')).toContainText('ERR_INVALID_URL')
|
||||||
|
expect((await page.getByRole('alert').innerText()).length).toBeLessThan(450)
|
||||||
|
await expect(page.getByRole('status')).toHaveCount(0)
|
||||||
|
const failed=(await page.evaluate(()=>window.receipts.listBatches()))[0]
|
||||||
|
expect(failed.message.length).toBeLessThan(450);expect(failed.message).not.toContain('sensitive')
|
||||||
|
await desktop.evaluate(({BrowserWindow})=>{BrowserWindow.prototype.loadURL=BrowserWindow.prototype.receiptTestLoadURL;delete BrowserWindow.prototype.receiptTestLoadURL})
|
||||||
|
await page.evaluate(async id=>window.receipts.exportReceipts({batchId:id,options:{pdf:true,png:true,print:true,receiptsPerPage:2,cutLine:true}}),batchId)
|
||||||
|
await expect.poll(()=>page.evaluate(async old=>{const b=(await window.receipts.listBatches())[0];return b.outputDirectory!==old&&b.status==='completed'},firstDirectory),{timeout:180000,intervals:[1000]}).toBe(true)
|
||||||
|
const retry=(await page.evaluate(()=>window.receipts.listBatches()))[0],retryFiles=await fs.readdir(retry.outputDirectory,{recursive:true})
|
||||||
|
expect(retryFiles.filter(f=>f.endsWith('.pdf'))).toHaveLength(42);expect(retryFiles.filter(f=>f.endsWith('.png'))).toHaveLength(41)
|
||||||
|
expect((await page.evaluate(()=>window.receipts.bootstrap())).nextNumber).toBe('REG00000042')
|
||||||
|
await fs.writeFile(path.join(qa,'result.json'),JSON.stringify({passed:true,receiptCount:41,printPages:pdf.getPageCount(),imageCharactersInFirstChunk:sealCharacters*40,retryPdfCount:42,retryPngCount:41,nextNumber:'REG00000042',shortErrorVerified:true,failureNoticeVerified:true,output:retry.outputDirectory},null,2))
|
||||||
|
console.log('Large stamp batch printing and original-number retry passed.')
|
||||||
|
}
|
||||||
|
}finally{if(desktop)await desktop.close().catch(()=>{})}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export function parseReceiptDate(value: unknown, date1904=false): string {
|
||||||
|
if(value instanceof Date) return check(value.getUTCFullYear(),value.getUTCMonth()+1,value.getUTCDate())
|
||||||
|
if(typeof value==='number' && Number.isFinite(value)) {
|
||||||
|
if(!date1904 && Math.floor(value)===60) throw Error('无效日期:1900-02-29')
|
||||||
|
const base=Date.UTC(date1904?1904:1899,date1904?0:11,date1904?1:31)
|
||||||
|
const serial=Math.floor(value)-(date1904||value<60?0:1)
|
||||||
|
return parseReceiptDate(new Date(base+serial*86400000))
|
||||||
|
}
|
||||||
|
if(typeof value==='string') {
|
||||||
|
const m=value.trim().match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?$/)
|
||||||
|
if(m) return check(Number(m[1]),Number(m[2]),Number(m[3]))
|
||||||
|
}
|
||||||
|
throw Error('日期请填写为 YYYY-MM-DD,例如 2026-10-16')
|
||||||
|
}
|
||||||
|
function check(y:number,m:number,d:number):string {
|
||||||
|
const date=new Date(Date.UTC(y,m-1,d))
|
||||||
|
if(y<1900||y>2199||date.getUTCFullYear()!==y||date.getUTCMonth()!==m-1||date.getUTCDate()!==d) throw Error('日期无效,请检查年月日')
|
||||||
|
return [String(y),String(m).padStart(2,'0'),String(d).padStart(2,'0')].join('-')
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export const MAX_CENTS = 999_999_999
|
||||||
|
export function parseMoney(value: string | number): number {
|
||||||
|
const raw = String(value).trim().replace(/^[¥¥]\s*/, '')
|
||||||
|
const normalized = raw.includes(',') ? (/^\d{1,3}(,\d{3})*(\.\d{1,2})?$/.test(raw) ? raw.replaceAll(',', '') : '') : raw
|
||||||
|
if (!/^\d{1,7}(\.\d{1,2})?$/.test(normalized)) throw new Error('金额须为正数,最多两位小数,最高 9,999,999.99 元')
|
||||||
|
const [whole, fraction=''] = normalized.split('.')
|
||||||
|
const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0'))
|
||||||
|
if (cents <= 0 || cents > MAX_CENTS) throw new Error('金额须大于 0,且不超过 9,999,999.99 元')
|
||||||
|
return cents
|
||||||
|
}
|
||||||
|
export function formatMoney(cents: number): string { return (cents / 100).toFixed(2) }
|
||||||
|
export function displayMoney(cents: number): string {
|
||||||
|
return (cents / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
const digits = '零壹贰叁肆伍陆柒捌玖'
|
||||||
|
function group(n: number): string {
|
||||||
|
const str=String(n); let result=''; let pendingZero=false
|
||||||
|
for(let i=0;i<str.length;i++) { const d=Number(str[i]); const p=str.length-i-1
|
||||||
|
if(!d) { if(result) pendingZero=true; continue }
|
||||||
|
if(pendingZero) result+='零'
|
||||||
|
result+=digits[d]+['','拾','佰','仟'][p]; pendingZero=false
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
export function chineseMoney(cents: number): string {
|
||||||
|
if(!Number.isSafeInteger(cents)||cents<0||cents>MAX_CENTS) throw Error('金额超出范围')
|
||||||
|
const yuan=Math.floor(cents/100), jiao=Math.floor(cents%100/10), fen=cents%10
|
||||||
|
const high=Math.floor(yuan/10000), low=yuan%10000
|
||||||
|
let text=high ? group(high)+'万'+(low && low<1000?'零':'')+(low?group(low):'') : yuan?group(yuan):'零'
|
||||||
|
text+='元'
|
||||||
|
if(!jiao&&!fen) return text+'整'
|
||||||
|
if(jiao) text+=digits[jiao]+'角'
|
||||||
|
else if(yuan&&fen) text+='零'
|
||||||
|
if(fen) text+=digits[fen]+'分'
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
export function moneyCells(cents: number): string[] {
|
||||||
|
const value=String(cents); const cells=Array(9).fill('') as string[]
|
||||||
|
const start=9-value.length
|
||||||
|
if(start>0) cells[start-1]='¥'
|
||||||
|
value.split('').forEach((v,i)=>cells[start+i]=v)
|
||||||
|
return cells
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { chineseMoney, formatMoney, moneyCells } from './money'
|
||||||
|
import type { AppSettings, Receipt, SealImage } from '../shared/types'
|
||||||
|
export interface TemplateContext { settings: AppSettings; seal: SealImage | null; preview: boolean }
|
||||||
|
export interface ReceiptTemplate { id: string; name: string; css: string; render(receipt: Receipt, context: TemplateContext): string }
|
||||||
|
export const escapeHtml = (text: string) => text.replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]!))
|
||||||
|
const h=escapeHtml
|
||||||
|
const classic: ReceiptTemplate = {
|
||||||
|
id:'classic-v1', name:'标准收款收据',
|
||||||
|
css:`
|
||||||
|
.receipt { width:180mm;height:120mm;position:relative;color:#111;font-family:SimSun,"宋体","Noto Serif SC",serif;font-size:3.3mm;padding:0 1mm; }
|
||||||
|
.receipt-title { height:12mm;text-align:center;padding-top:1mm; }
|
||||||
|
.receipt-title span { font-size:6mm;letter-spacing:4mm;padding:0 2mm 1mm 6mm;border-bottom:1mm double #111; }
|
||||||
|
.receipt-meta {height:9mm;display:flex;align-items:center;justify-content:space-between;font-size:3.2mm;}
|
||||||
|
.receipt-no {font-family:Arial,SimSun,serif;letter-spacing:.15mm;font-size:3mm;}
|
||||||
|
.receipt-table {width:100%;table-layout:fixed;border-collapse:collapse;border:.35mm solid #111;}
|
||||||
|
.receipt-table td,.receipt-table th {border:.25mm solid #111;font-weight:normal;padding:0 1.5mm;vertical-align:middle;}
|
||||||
|
.customer-row {height:11mm;} .customer-value {overflow-wrap:anywhere;line-height:4.2mm;text-align:center;}
|
||||||
|
.side-label {width:13mm;text-align:center;line-height:4.8mm;}
|
||||||
|
.project-heading {height:12mm;text-align:center;}
|
||||||
|
.project-row {height:8.5mm;text-align:center;line-height:3.8mm;}
|
||||||
|
.project-name {font-size:3mm;overflow-wrap:anywhere;}
|
||||||
|
.receipt-table .amount-area {padding:0;width:56mm;}
|
||||||
|
.amount-grid {height:100%;display:grid;grid-template-columns:repeat(9,minmax(0,1fr));text-align:center;}
|
||||||
|
.amount-grid span {border-right:.2mm solid #111;display:flex;align-items:center;justify-content:center;font-family:SimSun,serif;}
|
||||||
|
.amount-grid span:last-child {border:0;}
|
||||||
|
.amount-caption {height:5.5mm;border-bottom:.2mm solid #111;display:flex;align-items:center;justify-content:center;}
|
||||||
|
.amount-units {height:6mm;}
|
||||||
|
.amount-row {height:8.3mm;font-family:Arial,serif;}
|
||||||
|
.total-row {height:14mm;font-size:3mm;line-height:4.2mm;}
|
||||||
|
.total-upper {padding-left:2mm;font-size:3.2mm;}
|
||||||
|
.total-lower {white-space:nowrap;text-align:center;}
|
||||||
|
.receipt-footer {display:flex;padding-top:5mm;line-height:5mm;justify-content:space-between;font-size:3.2mm;}
|
||||||
|
.seal-image {position:absolute;object-fit:contain;max-height:49mm;pointer-events:none;}
|
||||||
|
.preview-mark {position:absolute;inset:35mm 0 auto;text-align:center;transform:rotate(-20deg);font:16mm sans-serif;color:rgba(100,116,139,.09);pointer-events:none;}
|
||||||
|
`,
|
||||||
|
render(r,c) {
|
||||||
|
const [year,month,day]=r.date.split('-')
|
||||||
|
const rows=Array.from({length:5},(_,i)=>`<tr class="project-row"><td class="project-name">${i===0?h(r.item):''}</td><td class="amount-area"><div class="amount-grid amount-row">${(i===0?moneyCells(r.amountCents):Array(9).fill('')).map(v=>'<span>'+v+'</span>').join('')}</div></td></tr>`).join('')
|
||||||
|
const seal=c.seal?`<img class="seal-image" alt="单位印章" src="${c.seal.dataUrl}" style="width:${c.settings.sealWidthMm}mm;right:calc(5mm - ${c.settings.sealOffsetX}mm);bottom:calc(-1mm - ${c.settings.sealOffsetY}mm)">`:''
|
||||||
|
return `<article class="receipt"><div class="receipt-title"><span>收款收据单</span></div>
|
||||||
|
<div class="receipt-meta"><span>填表日期:${year} 年 ${Number(month)} 月 ${Number(day)} 日</span><span class="receipt-no">NO:${h(r.number)}</span></div>
|
||||||
|
<table class="receipt-table"><colgroup><col style="width:13mm"><col><col style="width:56mm"></colgroup>
|
||||||
|
<tr class="customer-row"><td class="side-label">客户<br>名称</td><td colspan="2" class="customer-value">${h(r.customer)}</td></tr>
|
||||||
|
<tr class="project-heading"><td class="side-label" rowspan="6">收款<br>项目</td><td>名 称</td><td class="amount-area"><div class="amount-caption">金额</div><div class="amount-grid amount-units">${'佰拾万仟佰拾元角分'.split('').map(v=>'<span>'+v+'</span>').join('')}</div></td></tr>
|
||||||
|
${rows}<tr class="total-row"><td class="side-label">合计</td><td>人民币(大写)<br><span class="total-upper">${chineseMoney(r.amountCents)}</span></td><td class="total-lower">(小写)¥${formatMoney(r.amountCents)}</td></tr></table>
|
||||||
|
<div class="receipt-footer"><span>出纳:${h(r.cashier)}</span><span>复核:${h(r.reviewer)}</span><span style="width:54mm">单位盖章:</span></div>
|
||||||
|
${seal}${c.preview?'<div class="preview-mark">预览样张</div>':''}</article>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class TemplateRegistry {
|
||||||
|
private readonly entries=new Map<string,ReceiptTemplate>()
|
||||||
|
constructor(templates:ReceiptTemplate[]=[classic]) { for(const t of templates) this.register(t) }
|
||||||
|
register(template:ReceiptTemplate):void { if(this.entries.has(template.id)) throw Error('模板编号重复'); this.entries.set(template.id,template) }
|
||||||
|
get(id:string):ReceiptTemplate { const t=this.entries.get(id); if(!t) throw Error('收据模板不存在,请在设置中重新选择'); return t }
|
||||||
|
list(){ return [...this.entries.values()].map(({id,name})=>({id,name})) }
|
||||||
|
}
|
||||||
|
export function buildDocument(template:ReceiptTemplate,receipts:Receipt[],context:TemplateContext,layout:'single'|'print',perPage:1|2=2,cutLine=true):string {
|
||||||
|
const width=layout==='single'?190:210, height=layout==='single'?140:297
|
||||||
|
const capacity=layout==='single'?1:perPage
|
||||||
|
const pages:string[]=[]
|
||||||
|
for(let i=0;i<receipts.length;i+=capacity) {
|
||||||
|
const group=receipts.slice(i,i+capacity)
|
||||||
|
pages.push('<section class="page">'+Array.from({length:capacity},(_,j)=>'<div class="slot">'+(group[j]?template.render(group[j],context):'')+'</div>').join('')+(layout==='print'&&capacity===2&&cutLine?'<div class="cut-line">沿此线裁切</div>':'')+'</section>')
|
||||||
|
}
|
||||||
|
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'"><style>
|
||||||
|
@page{size:${width}mm ${height}mm;margin:0}
|
||||||
|
*{box-sizing:border-box}html,body{margin:0;padding:0;background:#fff;width:${width}mm}${context.preview?'html,body{overflow:hidden}':''}
|
||||||
|
.page{width:${width}mm;height:${height}mm;position:relative;break-after:page;overflow:hidden}
|
||||||
|
.page:last-child{break-after:auto}.slot{height:${height/capacity}mm;display:flex;align-items:center;justify-content:center}
|
||||||
|
.cut-line{position:absolute;top:148.5mm;width:100%;border-top:.2mm dashed #bbb;text-align:right;padding:1mm 8mm;color:#999;font:2.2mm sans-serif}
|
||||||
|
${template.css}</style></head><body>${pages.join('')}</body></html>`
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import type { ConfirmationOptions,ConfirmationRequest } from '../shared/types'
|
||||||
|
|
||||||
|
// Operations wait for the reply to their own request. Reloading or losing the window cancels it.
|
||||||
|
export class ConfirmationService {
|
||||||
|
private pending:{id:string;resolve:(confirmed:boolean)=>void}|null=null
|
||||||
|
constructor(private send:(request:ConfirmationRequest)=>void){}
|
||||||
|
get hasPending():boolean{return this.pending!==null}
|
||||||
|
ask(options:ConfirmationOptions):Promise<boolean>{
|
||||||
|
if(this.pending)return Promise.reject(Error('请先处理当前确认窗口'))
|
||||||
|
return new Promise((resolve,reject)=>{
|
||||||
|
const id=randomUUID();this.pending={id,resolve}
|
||||||
|
try{this.send({...options,id})}catch(error){this.pending=null;reject(error)}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
answer(id:string,confirmed:boolean):void{
|
||||||
|
if(!this.pending||this.pending.id!==id)throw Error('确认请求已失效,请重试')
|
||||||
|
const pending=this.pending;this.pending=null;pending.resolve(confirmed)
|
||||||
|
}
|
||||||
|
cancel():void{
|
||||||
|
if(!this.pending)return
|
||||||
|
const pending=this.pending;this.pending=null;pending.resolve(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { Receipt } from '../shared/types'
|
||||||
|
import { safeFileName } from './files'
|
||||||
|
import {receiptGroupLabels} from '../shared/receipt-groups'
|
||||||
|
|
||||||
|
export interface ReceiptGroup {
|
||||||
|
directory: string;
|
||||||
|
labels: string[];
|
||||||
|
receipts: Receipt[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupReceipts(receipts: Receipt[]): ReceiptGroup[] {
|
||||||
|
const groups = new Map<string, ReceiptGroup>()
|
||||||
|
const siblingNames = new Map<string, Map<string, string>>()
|
||||||
|
for (const receipt of receipts) {
|
||||||
|
const labels = receiptGroupLabels(receipt)
|
||||||
|
const key = JSON.stringify(labels)
|
||||||
|
let group = groups.get(key)
|
||||||
|
if (!group) {
|
||||||
|
let directory = ''
|
||||||
|
for (const label of labels) {
|
||||||
|
let siblings = siblingNames.get(directory)
|
||||||
|
if (!siblings) siblingNames.set(directory, siblings = new Map())
|
||||||
|
const base = safeFileName(label).slice(0, 60).replace(/[. ]+$/, '') || '未分类'
|
||||||
|
const hash = createHash('sha256').update(label).digest('hex').slice(0, 8)
|
||||||
|
// Sanitized/truncated names get stable suffixes; Windows also ignores letter case.
|
||||||
|
let name = base === label ? base : base + '_' + hash
|
||||||
|
for (let n = 1; siblings.has(name.toLowerCase()) && siblings.get(name.toLowerCase()) !== label; n++) {
|
||||||
|
name = base + '_' + hash + (n === 1 ? '' : '_' + n)
|
||||||
|
}
|
||||||
|
siblings.set(name.toLowerCase(), label)
|
||||||
|
directory = path.join(directory, name)
|
||||||
|
}
|
||||||
|
group = { directory, labels, receipts: [] }
|
||||||
|
groups.set(key, group)
|
||||||
|
}
|
||||||
|
group.receipts.push(receipt)
|
||||||
|
}
|
||||||
|
return [...groups.values()]
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { PDFDocument } from 'pdf-lib'
|
||||||
|
import type { Batch,ExportOptions,ExportProgress } from '../shared/types'
|
||||||
|
import type { PdfEngine } from './pdf-engine'
|
||||||
|
import type { Ledger } from './ledger'
|
||||||
|
import { atomicWrite,uniquePath } from './files'
|
||||||
|
import { receiptFileStem } from '../shared/file-names'
|
||||||
|
import { conciseError } from '../shared/errors'
|
||||||
|
import { groupReceipts } from './export-layout'
|
||||||
|
export class ExportCoordinator {
|
||||||
|
private cancelled=false;active=false;private last:ExportProgress|null=null
|
||||||
|
constructor(private engine:PdfEngine,private ledger:Ledger,private emit:(p:ExportProgress)=>void){}
|
||||||
|
cancel(){this.cancelled=true}
|
||||||
|
progress(){return this.last}
|
||||||
|
async run(batch:Batch,options:ExportOptions,output:string):Promise<void>{
|
||||||
|
this.cancelled=false
|
||||||
|
const errors:string[]=[], manifest:{number:string;directory:string;files:string[];error?:string}[]=[],printFiles:string[]=[]
|
||||||
|
const groups=groupReceipts(batch.receipts)
|
||||||
|
const receiptDirectories=new Map(groups.flatMap(group=>group.receipts.map(receipt=>[receipt.number,group.directory] as const)))
|
||||||
|
const total=batch.receipts.length+(options.print?groups.length:0)
|
||||||
|
let completed=0;batch.successCount=0;batch.failureCount=0
|
||||||
|
const progress=(stage:string,finished=false)=>{
|
||||||
|
this.last={batchId:batch.id,completed,total,stage,finished,status:batch.status,outputDirectory:batch.outputDirectory,errors:[...errors]};this.emit(this.last)
|
||||||
|
}
|
||||||
|
try{
|
||||||
|
await fs.mkdir(output,{recursive:true})
|
||||||
|
const now=new Date(),pad=(value:number)=>String(value).padStart(2,'0')
|
||||||
|
const time=String(now.getFullYear())+pad(now.getMonth()+1)+pad(now.getDate())+'_'+pad(now.getHours())+pad(now.getMinutes())+pad(now.getSeconds())
|
||||||
|
const dir=path.join(output,time+'_'+batch.id.slice(0,8)+'_'+Date.now().toString(36).slice(-4))
|
||||||
|
await fs.mkdir(dir,{recursive:true});batch.outputDirectory=dir;batch.status='running';await this.ledger.update(batch)
|
||||||
|
const relative=(file:string)=>path.relative(dir,file).split(path.sep).join('/')
|
||||||
|
progress('准备生成收据')
|
||||||
|
for(const receipt of batch.receipts){
|
||||||
|
if(this.cancelled)break
|
||||||
|
const receiptDirectory=path.join(dir,receiptDirectories.get(receipt.number)!)
|
||||||
|
const entry:{number:string;directory:string;files:string[];error?:string}={number:receipt.number,directory:relative(receiptDirectory),files:[]}
|
||||||
|
try{
|
||||||
|
if(options.pdf||options.png){
|
||||||
|
const pdf=await this.engine.pdf([receipt],batch.settings,batch.seal,'single',1,false)
|
||||||
|
const pattern=batch.settings.fileNamePattern
|
||||||
|
const stem=receiptFileStem(receipt.number,receipt.customer,pattern)
|
||||||
|
if(options.pdf){const file=await uniquePath(receiptDirectory,stem+'.pdf');await atomicWrite(file,pdf);entry.files.push(relative(file))}
|
||||||
|
if(options.png){const png=await this.engine.png(pdf,batch.settings.pngDpi);const file=await uniquePath(receiptDirectory,stem+'.png');await atomicWrite(file,png);entry.files.push(relative(file))}
|
||||||
|
}
|
||||||
|
if(options.pdf||options.png)batch.successCount++
|
||||||
|
}catch(e){entry.error=conciseError(e);batch.failureCount++;errors.push(receipt.number+':'+entry.error)}
|
||||||
|
manifest.push(entry);completed++;progress('正在生成 '+receipt.number)
|
||||||
|
}
|
||||||
|
if(options.print){
|
||||||
|
for(const group of groups){
|
||||||
|
if(this.cancelled)break
|
||||||
|
const label=group.labels.join(' / ')
|
||||||
|
progress('正在排版 A4 打印文件:'+label)
|
||||||
|
try{
|
||||||
|
const merged=await PDFDocument.create()
|
||||||
|
// Group boundaries always start a new print file; even chunks avoid extra blank half-pages.
|
||||||
|
for(let i=0;i<group.receipts.length;i+=40){
|
||||||
|
if(this.cancelled)break
|
||||||
|
const pdf=await this.engine.pdf(group.receipts.slice(i,i+40),batch.settings,batch.seal,'print',options.receiptsPerPage,options.cutLine)
|
||||||
|
const part=await PDFDocument.load(pdf);const pages=await merged.copyPages(part,part.getPageIndices());for(const p of pages)merged.addPage(p)
|
||||||
|
progress('正在排版打印文件 '+Math.min(i+40,group.receipts.length)+' / '+group.receipts.length+':'+label)
|
||||||
|
}
|
||||||
|
if(!this.cancelled){
|
||||||
|
const file=await uniquePath(path.join(dir,group.directory),'A4打印版_每页'+options.receiptsPerPage+'张.pdf')
|
||||||
|
await atomicWrite(file,await merged.save());printFiles.push(relative(file))
|
||||||
|
if(!options.pdf&&!options.png)batch.successCount+=group.receipts.length
|
||||||
|
}
|
||||||
|
}catch(e){if(!options.pdf&&!options.png)batch.failureCount+=group.receipts.length;errors.push('打印版('+label+'):'+conciseError(e))}
|
||||||
|
if(!this.cancelled)completed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batch.status=this.cancelled?'cancelled':errors.length?(batch.successCount?'partial':'failed'):'completed'
|
||||||
|
batch.message=this.cancelled?'已取消,可在生成记录中使用原编号重试':errors.length?errors.slice(0,5).join(';'):'生成完成'
|
||||||
|
await atomicWrite(await uniquePath(dir,'生成清单.json'),JSON.stringify({batchId:batch.id,createdAt:batch.createdAt,status:batch.status,options,receipts:manifest,printFiles,errors},null,2))
|
||||||
|
}catch(e){batch.status='failed';batch.message=conciseError(e);errors.push(batch.message)}
|
||||||
|
finally{
|
||||||
|
try{await this.ledger.update(batch)}catch(e){batch.status='failed';errors.push('记录保存失败:'+conciseError(e))}
|
||||||
|
this.engine.close();this.active=false;progress(batch.message,true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
export async function atomicWrite(file:string, content:string|Uint8Array):Promise<void> {
|
||||||
|
await fs.mkdir(path.dirname(file),{recursive:true})
|
||||||
|
const temp=file+'.'+randomUUID()+'.tmp'
|
||||||
|
try { await fs.writeFile(temp,content); await fs.rename(temp,file) }
|
||||||
|
catch(error) { await fs.rm(temp,{force:true}).catch(()=>{}); throw error }
|
||||||
|
}
|
||||||
|
export {safeFileName} from '../shared/file-names'
|
||||||
|
export async function uniquePath(folder:string,name:string):Promise<string> {
|
||||||
|
const ext=path.extname(name), stem=name.slice(0,-ext.length)||name
|
||||||
|
for(let n=0;n<10000;n++) {const file=path.join(folder,n?stem+'_'+n+ext:name); try{await fs.access(file)}catch{return file} }
|
||||||
|
throw Error('同名文件过多,请选择其他文件夹')
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { app,BrowserWindow,dialog,ipcMain,net,protocol,session,shell } from 'electron'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { SettingsStore } from './settings-store'
|
||||||
|
import { ConfirmationService } from './confirmations'
|
||||||
|
import { Ledger } from './ledger'
|
||||||
|
import { XlsxReader,createWorkbookTemplate } from './workbooks'
|
||||||
|
import { readSeals,readSeal } from './seals'
|
||||||
|
import { ElectronPdfEngine } from './pdf-engine'
|
||||||
|
import { printDocuments } from './print-documents'
|
||||||
|
import { ExportCoordinator } from './exporter'
|
||||||
|
import { TemplateRegistry,buildDocument } from '../core/templates'
|
||||||
|
import { settingsSchema,exportOptionsSchema,historyPageSizeSchema } from '../shared/settings'
|
||||||
|
import {printPreviewRows} from '../shared/receipt-groups'
|
||||||
|
import type { Batch,ExportRequest,ImportDraft,Receipt,SealImage,ProcessingTask } from '../shared/types'
|
||||||
|
|
||||||
|
protocol.registerSchemesAsPrivileged([{scheme:'receipt',privileges:{standard:true,secure:true,supportFetchAPI:true,corsEnabled:true}}])
|
||||||
|
const testMode=!app.isPackaged&&process.argv.includes('--test-mode')
|
||||||
|
const profileOverride=app.commandLine.getSwitchValue('user-data-dir')
|
||||||
|
const smokeMode=process.argv.includes('--smoke-test')
|
||||||
|
if(profileOverride)app.setPath('userData',path.resolve(profileOverride))
|
||||||
|
else if(testMode)app.setPath('userData',path.join(process.cwd(),'.qa','profile'))
|
||||||
|
else if(!app.isPackaged)app.setPath('userData',path.join(process.cwd(),'.local-data','app'))
|
||||||
|
const locked=testMode||app.requestSingleInstanceLock()
|
||||||
|
if(!locked)app.quit()
|
||||||
|
let mainWindow:BrowserWindow|null=null
|
||||||
|
let coordinator:ExportCoordinator|undefined
|
||||||
|
const templates=new TemplateRegistry()
|
||||||
|
const drafts=new Map<string,ImportDraft>()
|
||||||
|
let forceQuit=false,quitPending=false
|
||||||
|
const confirmations=new ConfirmationService(request=>{
|
||||||
|
if(!mainWindow||mainWindow.isDestroyed())throw Error('确认窗口尚未就绪,请重新打开程序')
|
||||||
|
mainWindow.webContents.send('receipt:confirmation',request)
|
||||||
|
})
|
||||||
|
function requestQuit(){
|
||||||
|
if(quitPending||confirmations.hasPending)return
|
||||||
|
quitPending=true
|
||||||
|
void confirmations.ask({title:'收据仍在生成',description:'现在退出会中断本次生成,已分配的编号会保留。',variant:'warning',note:'下次打开程序后,可以在生成记录中使用原编号重试。',confirmLabel:'退出程序',cancelLabel:'继续生成'}).then(confirmed=>{
|
||||||
|
if(confirmed){forceQuit=true;app.quit()}
|
||||||
|
}).catch(error=>console.error('Quit confirmation failed',error)).finally(()=>{quitPending=false})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(){
|
||||||
|
const dataDir=app.getPath('userData');await fs.mkdir(dataDir,{recursive:true})
|
||||||
|
const settings=new SettingsStore(path.join(dataDir,'settings.json'),path.join(app.getPath('documents'),'收据工坊'))
|
||||||
|
await settings.load();templates.get(settings.get().templateId)
|
||||||
|
const ledger=new Ledger(path.join(dataDir,'receipts.sqlite'));await ledger.open();await ledger.recoverInterrupted()
|
||||||
|
const reader=new XlsxReader()
|
||||||
|
const rendererDirectory=path.join(__dirname,'../renderer')
|
||||||
|
const base=process.env.ELECTRON_RENDERER_URL||'receipt://app'
|
||||||
|
protocol.handle('receipt',async request=>{
|
||||||
|
try{
|
||||||
|
const url=new URL(request.url)
|
||||||
|
if(url.hostname==='print'){
|
||||||
|
const html=request.method==='GET'?printDocuments.read(url.href):undefined
|
||||||
|
return html===undefined?new Response('Not found',{status:404}):new Response(html,{headers:{'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store'}})
|
||||||
|
}
|
||||||
|
if(url.hostname!=='app')return new Response('Not found',{status:404})
|
||||||
|
const file=path.resolve(rendererDirectory,'.'+decodeURIComponent(url.pathname||'/index.html'))
|
||||||
|
if(!file.startsWith(rendererDirectory+path.sep))return new Response('Forbidden',{status:403})
|
||||||
|
return await net.fetch(pathToFileURL(file).toString())
|
||||||
|
}catch{return new Response('Not found',{status:404})}
|
||||||
|
})
|
||||||
|
session.defaultSession.setPermissionRequestHandler((_wc,_permission,callback)=>callback(false))
|
||||||
|
session.defaultSession.webRequest.onBeforeRequest((details,callback)=>{
|
||||||
|
const u=new URL(details.url)
|
||||||
|
const local=['receipt:','data:','blob:','file:','devtools:'].includes(u.protocol)
|
||||||
|
const development=!!process.env.ELECTRON_RENDERER_URL&&['http:','ws:'].includes(u.protocol)&&u.host===new URL(base).host
|
||||||
|
callback({cancel:!local&&!development})
|
||||||
|
})
|
||||||
|
const engine=new ElectronPdfEngine(templates,base)
|
||||||
|
coordinator=new ExportCoordinator(engine,ledger,p=>{if(mainWindow&&!mainWindow.isDestroyed())mainWindow.webContents.send('receipt:progress',p)})
|
||||||
|
const handle=(name:string,fn:(...args:any[])=>unknown)=>{
|
||||||
|
ipcMain.handle('receipt:'+name,async(event,...args)=>{
|
||||||
|
if(!mainWindow||event.sender!==mainWindow.webContents||event.senderFrame!==mainWindow.webContents.mainFrame)throw Error('无效来源')
|
||||||
|
try{return {ok:true,value:await fn(...args)}}catch(e){return {ok:false,error:e instanceof z.ZodError?e.issues.map(i=>i.message).join(';'):(e as Error).message||'操作失败'}}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Notify the renderer only when actual processing starts, after native pickers and confirmations.
|
||||||
|
const processing=(task:ProcessingTask)=>mainWindow?.webContents.send('receipt:processing',task)
|
||||||
|
const choose=async(kind:'output'|'seal')=>{
|
||||||
|
const current=settings.get()
|
||||||
|
const result=await dialog.showOpenDialog(mainWindow!,{title:kind==='seal'?'选择印章文件夹':'选择导出文件夹',defaultPath:kind==='seal'?current.sealDirectory:current.outputDirectory,properties:['openDirectory','createDirectory']})
|
||||||
|
return result.canceled?null:result.filePaths[0]
|
||||||
|
}
|
||||||
|
handle('answerConfirmation',input=>{const answer=z.object({id:z.string().uuid(),confirmed:z.boolean()}).parse(input);confirmations.answer(answer.id,answer.confirmed)})
|
||||||
|
handle('bootstrap',()=>({settings:settings.get(),batches:ledger.summaries(),historyTotal:ledger.countBatches(),historyFilters:ledger.historyFilters(),nextNumber:ledger.nextNumber(settings.get().numberPrefix),dataDirectory:dataDir,version:app.getVersion(),templates:templates.list()}))
|
||||||
|
handle('saveSettings',async value=>{const parsed=settingsSchema.parse(value);templates.get(parsed.templateId);return settings.save(parsed)})
|
||||||
|
handle('chooseFolder',kind=>choose(z.enum(['output','seal']).parse(kind)))
|
||||||
|
handle('listSeals',()=>readSeals(settings.get().sealDirectory))
|
||||||
|
handle('importExcel',async()=>{
|
||||||
|
const current=settings.get()
|
||||||
|
const result=await dialog.showOpenDialog(mainWindow!,{title:'导入收据 Excel',defaultPath:current.lastImportDirectory||undefined,properties:['openFile'],filters:[{name:'Excel 工作簿',extensions:['xlsx']}]})
|
||||||
|
if(result.canceled)return null
|
||||||
|
processing('import')
|
||||||
|
const file=result.filePaths[0],draft=await reader.read(file)
|
||||||
|
draft.previousBatchId=ledger.previous(draft.hash)
|
||||||
|
if(drafts.size>=3)drafts.delete(drafts.keys().next().value!)
|
||||||
|
drafts.set(draft.id,draft)
|
||||||
|
await settings.save({...current,lastImportDirectory:path.dirname(file)})
|
||||||
|
return draft
|
||||||
|
})
|
||||||
|
handle('saveExcelTemplate',async()=>{
|
||||||
|
const result=await dialog.showSaveDialog(mainWindow!,{title:'保存 Excel 导入模板',defaultPath:path.join(settings.get().lastImportDirectory||app.getPath('documents'),'收据导入模板.xlsx'),filters:[{name:'Excel 工作簿',extensions:['xlsx']}]})
|
||||||
|
if(result.canceled||!result.filePath)return null
|
||||||
|
processing('template')
|
||||||
|
await createWorkbookTemplate(result.filePath);return result.filePath
|
||||||
|
})
|
||||||
|
async function findSeal(id?:string):Promise<SealImage|null>{
|
||||||
|
if(!id)return null
|
||||||
|
try{return await readSeal(settings.get().sealDirectory,id)}catch{throw Error('选择的印章已不存在或无法读取,请刷新印章列表后重新选择')}
|
||||||
|
}
|
||||||
|
handle('preview',async input=>{
|
||||||
|
const request=z.object({draftId:z.string().optional(),rowIndex:z.number().int().nonnegative().optional(),rowIndices:z.array(z.number().int().nonnegative()).max(5000).optional(),batchId:z.string().optional(),receiptNumber:z.string().min(1).max(40).optional(),sealId:z.string().optional(),layout:z.enum(['single','print'])}).parse(input)
|
||||||
|
let receipts:Receipt[],seal:SealImage|null,current=settings.get()
|
||||||
|
if(request.batchId){const b=ledger.get(request.batchId),index=request.receiptNumber?b.receipts.findIndex(receipt=>receipt.number===request.receiptNumber):0;if(index<0)throw Error('本批次中没有找到这张收据');receipts=b.receipts.slice(index,index+(request.layout==='single'?1:2));seal=b.seal;current=b.settings}
|
||||||
|
else {
|
||||||
|
const draft=request.draftId?drafts.get(request.draftId):null
|
||||||
|
const row=draft?.rows.find(r=>r.index===request.rowIndex)
|
||||||
|
if(request.draftId&&(!row||row.errors.length))throw Error('请先选择一行有效数据进行预览')
|
||||||
|
const input=row?.receipt??{date:new Date().toLocaleDateString('sv-SE'),customer:'某某学校 初二年级 张三',item:'秋季学期服务费',amountCents:350000,cashier:'',reviewer:''}
|
||||||
|
receipts=[{...input,cashier:input.cashier||current.defaultCashier,reviewer:input.reviewer||current.defaultReviewer,number:ledger.nextNumber(current.numberPrefix)}]
|
||||||
|
seal=await findSeal(request.sealId)
|
||||||
|
if(draft&&request.layout==='print'){
|
||||||
|
const selected=new Set(request.rowIndices??draft.rows.filter(row=>!row.errors.length).map(row=>row.index))
|
||||||
|
const ordered=draft.rows.filter(row=>!row.errors.length&&selected.has(row.index))
|
||||||
|
const shown=printPreviewRows(draft.rows,selected,request.rowIndex??0,current.receiptsPerPage)
|
||||||
|
if(!shown.length)throw Error('请先勾选有效数据再预览打印排版')
|
||||||
|
const start=Number(ledger.nextNumber(current.numberPrefix).slice(current.numberPrefix.length))
|
||||||
|
receipts=shown.map(row=>({...row.receipt,cashier:row.receipt.cashier||current.defaultCashier,reviewer:row.receipt.reviewer||current.defaultReviewer,number:current.numberPrefix+String(start+ordered.indexOf(row)).padStart(8,'0')}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buildDocument(templates.get(current.templateId),receipts,{settings:current,seal,preview:true},request.layout,current.receiptsPerPage,current.cutLine)
|
||||||
|
})
|
||||||
|
handle('exportReceipts',async(input:ExportRequest)=>{
|
||||||
|
if(coordinator!.active)throw Error('已有生成任务正在进行,请等待完成或取消')
|
||||||
|
coordinator!.active=true
|
||||||
|
try{
|
||||||
|
const request=z.object({draftId:z.string().optional(),batchId:z.string().uuid().optional(),rowIndices:z.array(z.number().int().nonnegative()).max(5000).optional(),sealId:z.string().optional(),options:exportOptionsSchema}).parse(input)
|
||||||
|
const current=settings.get();templates.get(current.templateId)
|
||||||
|
if(!current.outputDirectory)throw Error('请先在设置中选择导出文件夹')
|
||||||
|
await fs.mkdir(current.outputDirectory,{recursive:true});await fs.access(current.outputDirectory,fs.constants.W_OK)
|
||||||
|
let batch:Batch
|
||||||
|
if(request.batchId)batch=ledger.get(request.batchId)
|
||||||
|
else {
|
||||||
|
const draft=request.draftId?drafts.get(request.draftId):null
|
||||||
|
if(!draft)throw Error('导入数据已失效,请重新导入 Excel')
|
||||||
|
const indices=new Set(request.rowIndices)
|
||||||
|
const rows=draft.rows.filter(r=>indices.has(r.index))
|
||||||
|
if(!rows.length||rows.length!==indices.size||rows.some(r=>r.errors.length))throw Error('请选择有效的数据行,错误行不能生成')
|
||||||
|
if(draft.previousBatchId){
|
||||||
|
const confirmed=await confirmations.ask({title:'这个文件已经生成过收据',description:'继续生成会为所选数据分配新的收据编号。',variant:'warning',details:[{label:'导入文件',value:draft.fileName}],note:'如需补打,请取消并到生成记录中重新导出,沿用原编号。',confirmLabel:'生成新编号'})
|
||||||
|
if(!confirmed)throw Error('已取消,请在生成记录中重新导出')
|
||||||
|
}
|
||||||
|
const seal=await findSeal(request.sealId)
|
||||||
|
batch=await ledger.reserve({name:draft.fileName,hash:draft.hash},rows.map(r=>r.receipt),current,seal)
|
||||||
|
draft.previousBatchId=batch.id
|
||||||
|
}
|
||||||
|
// Reserve and persist numbers before any output file is created.
|
||||||
|
void coordinator!.run(batch,request.options,current.outputDirectory).then(async()=>{
|
||||||
|
if(current.openAfterExport&&coordinator!.progress()?.status==='completed')await shell.openPath(batch.outputDirectory)
|
||||||
|
}).catch(e=>console.error('Export failed',e))
|
||||||
|
return batch.id
|
||||||
|
}catch(e){coordinator!.active=false;throw e}
|
||||||
|
})
|
||||||
|
handle('cancelExport',()=>{coordinator!.cancel()})
|
||||||
|
const batchQuerySchema=z.object({search:z.string().trim().max(200).optional(),school:z.string().max(80).optional(),grade:z.string().max(80).optional(),item:z.string().max(80).optional()})
|
||||||
|
handle('listBatches',input=>ledger.summaries(batchQuerySchema.parse(input??{})))
|
||||||
|
handle('listBatchPage',input=>ledger.batchPage(batchQuerySchema.extend({page:z.number().int().min(1).max(1000000000).default(1),pageSize:historyPageSizeSchema.default(5)}).parse(input??{})))
|
||||||
|
handle('deleteBatch',async input=>{
|
||||||
|
const id=z.string().uuid().parse(input)
|
||||||
|
if(coordinator!.active)throw Error('生成任务结束后才能删除记录')
|
||||||
|
const batch=ledger.get(id)
|
||||||
|
const confirmed=await confirmations.ask({
|
||||||
|
title:'删除这条生成记录?',description:'删除后,该批次将无法在生成记录中重新导出。',variant:'danger',
|
||||||
|
details:[{label:'生成批次',value:batch.sourceName},{label:'收据编号',value:batch.receipts[0].number+' 至 '+batch.receipts.at(-1)!.number}],
|
||||||
|
note:'已导出的 PDF、图片文件保留,收据编号继续递增。',confirmLabel:'删除记录'
|
||||||
|
})
|
||||||
|
if(!confirmed)return false
|
||||||
|
if(coordinator!.active)throw Error('生成任务结束后才能删除记录')
|
||||||
|
processing('delete-batch')
|
||||||
|
await ledger.deleteBatch(id)
|
||||||
|
for(const draft of drafts.values()){if(draft.previousBatchId===id)draft.previousBatchId=ledger.previous(draft.hash)}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
handle('openFolder',async input=>{
|
||||||
|
const folder=z.string().max(1024).parse(input)
|
||||||
|
if(!path.isAbsolute(folder)||(await fs.stat(folder)).isDirectory()===false)throw Error('文件夹不存在')
|
||||||
|
const allowed=[settings.get().outputDirectory,settings.get().sealDirectory,dataDir].filter(Boolean)
|
||||||
|
if(!allowed.some(p=>path.resolve(p)===path.resolve(folder))&&!ledger.isOutputDirectory(folder))throw Error('请从已绑定的文件夹或生成记录中打开目录')
|
||||||
|
const error=await shell.openPath(folder);if(error)throw Error(error)
|
||||||
|
})
|
||||||
|
mainWindow=new BrowserWindow({width:1440,height:940,minWidth:1120,minHeight:740,show:!testMode&&!smokeMode,icon:path.join(__dirname,'../icon.png'),title:'收据工坊',backgroundColor:'#f5f7fa',autoHideMenuBar:true,webPreferences:{preload:path.join(__dirname,'preload.cjs'),sandbox:true,nodeIntegration:false,contextIsolation:true,backgroundThrottling:false}})
|
||||||
|
mainWindow.on('close',event=>{if(coordinator?.active&&!forceQuit){event.preventDefault();requestQuit()}})
|
||||||
|
mainWindow.on('closed',()=>confirmations.cancel())
|
||||||
|
mainWindow.webContents.on('did-navigate',()=>confirmations.cancel())
|
||||||
|
mainWindow.webContents.on('render-process-gone',()=>confirmations.cancel())
|
||||||
|
if(!app.isPackaged){
|
||||||
|
mainWindow.webContents.on('before-input-event',(event,input)=>{
|
||||||
|
if(input.type==='keyDown'&&(input.key==='F12'||input.control&&input.shift&&input.key.toLowerCase()==='i')){event.preventDefault();mainWindow?.webContents.toggleDevTools()}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(()=>({action:'deny'}))
|
||||||
|
mainWindow.webContents.on('will-navigate',(event,url)=>{if(url!==base+'/index.html'&&url!==base+'/')event.preventDefault()})
|
||||||
|
mainWindow.once('ready-to-show',()=>{if(!testMode&&!smokeMode)mainWindow?.show()})
|
||||||
|
await mainWindow.loadURL(base+'/index.html')
|
||||||
|
if(!testMode&&!smokeMode){mainWindow.show();mainWindow.focus()}
|
||||||
|
if(process.env.ELECTRON_RENDERER_URL)console.log('收据工坊桌面界面已加载。')
|
||||||
|
}
|
||||||
|
if(locked)app.whenReady().then(start).catch(async error=>{console.error(error);await dialog.showMessageBox({type:'error',title:'收据工坊启动失败',message:(error as Error).message});app.exit(1)})
|
||||||
|
app.on('second-instance',()=>{mainWindow?.show();mainWindow?.focus()})
|
||||||
|
app.on('window-all-closed',()=>app.quit())
|
||||||
|
app.on('before-quit',event=>{
|
||||||
|
if(coordinator?.active&&!forceQuit){
|
||||||
|
event.preventDefault()
|
||||||
|
requestQuit()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import initSqlJs from 'sql.js/dist/sql-wasm-inline.js'
|
||||||
|
import type { Database,SqlJsStatic } from 'sql.js'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { settingsSchema } from '../shared/settings'
|
||||||
|
import {historyPageSizes} from '../shared/types'
|
||||||
|
import type { AppSettings,Batch,BatchSummary,BatchQuery,BatchPageQuery,BatchPage,HistoryFilters,ReceiptInput,SealImage } from '../shared/types'
|
||||||
|
import {receiptGroupLabels,summarizeReceiptGroups} from '../shared/receipt-groups'
|
||||||
|
import { atomicWrite } from './files'
|
||||||
|
import { conciseError } from '../shared/errors'
|
||||||
|
const receiptSchema=z.object({number:z.string().min(1).max(40),date:z.string().regex(/^\d{4}-\d{2}-\d{2}$/),school:z.string().max(80).optional(),grade:z.string().max(80).optional(),studentName:z.string().max(80).optional(),customer:z.string().min(1).max(80),item:z.string().min(1).max(80),amountCents:z.number().int().positive().max(999999999),cashier:z.string().max(20),reviewer:z.string().max(20)})
|
||||||
|
const batchSchema=z.object({id:z.string().uuid(),createdAt:z.string(),sourceName:z.string(),sourceHash:z.string(),receipts:z.array(receiptSchema).min(1).max(5000),settings:settingsSchema,seal:z.object({id:z.string(),name:z.string(),dataUrl:z.string().regex(/^data:image\/(png|jpeg|webp);base64,/)}).nullable(),status:z.enum(['reserved','running','completed','partial','failed','cancelled']),outputDirectory:z.string(),successCount:z.number().int().nonnegative(),failureCount:z.number().int().nonnegative(),message:z.string()})
|
||||||
|
export class Ledger {
|
||||||
|
private db!:Database; private sql!:SqlJsStatic;private queue:Promise<unknown>=Promise.resolve()
|
||||||
|
constructor(private readonly file:string){}
|
||||||
|
async open():Promise<void>{
|
||||||
|
this.sql=await initSqlJs()
|
||||||
|
try{this.db=new this.sql.Database(await fs.readFile(this.file));if(this.db.exec('PRAGMA integrity_check')[0]?.values[0]?.[0]!=='ok')throw Error('integrity')}
|
||||||
|
catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')this.db=new this.sql.Database();else throw Error('编号记录无法读取。为避免重复编号,程序已停止分配编号;原文件已保留,请联系维护人员处理。')}
|
||||||
|
this.db.run('CREATE TABLE IF NOT EXISTS sequences (prefix TEXT PRIMARY KEY, next_value INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS batches (id TEXT PRIMARY KEY, created_at TEXT NOT NULL, source_hash TEXT NOT NULL, data TEXT NOT NULL); CREATE TABLE IF NOT EXISTS receipt_numbers (number TEXT PRIMARY KEY, batch_id TEXT NOT NULL);')
|
||||||
|
}
|
||||||
|
private values(sql:string,params:(string|number)[]=[]):unknown[][]{
|
||||||
|
const s=this.db.prepare(sql);try{s.bind(params);const rows:unknown[][]=[];while(s.step())rows.push(s.get());return rows}finally{s.free()}
|
||||||
|
}
|
||||||
|
private mutate<T>(action:()=>T):Promise<T>{
|
||||||
|
const work=this.queue.then(async()=>{
|
||||||
|
const before=this.db.export()
|
||||||
|
try{this.db.run('BEGIN IMMEDIATE');const result=action();this.db.run('COMMIT');await atomicWrite(this.file,this.db.export());return result}
|
||||||
|
catch(e){this.db.close();this.db=new this.sql.Database(before);throw e}
|
||||||
|
});this.queue=work.catch(()=>{});return work
|
||||||
|
}
|
||||||
|
nextNumber(prefix:string):string {return prefix+String(this.values('SELECT next_value FROM sequences WHERE prefix=?',[prefix])[0]?.[0]??1).padStart(8,'0')}
|
||||||
|
async reserve(source:{name:string;hash:string},inputs:ReceiptInput[],settings:AppSettings,seal:SealImage|null):Promise<Batch>{
|
||||||
|
return this.mutate(()=>{
|
||||||
|
if(!inputs.length||inputs.length>5000)throw Error('收据数量无效')
|
||||||
|
const prefix=settings.numberPrefix
|
||||||
|
const start=Number(this.values('SELECT next_value FROM sequences WHERE prefix=?',[prefix])[0]?.[0]??1)
|
||||||
|
if(start+inputs.length-1>99999999)throw Error('当前编号前缀的流水号已用完,请更换前缀')
|
||||||
|
const batch:Batch={id:randomUUID(),createdAt:new Date().toISOString(),sourceName:source.name,sourceHash:source.hash,receipts:inputs.map((r,i)=>({...r,cashier:r.cashier||settings.defaultCashier,reviewer:r.reviewer||settings.defaultReviewer,number:prefix+String(start+i).padStart(8,'0')})),settings:{...settings},seal,status:'reserved',outputDirectory:'',successCount:0,failureCount:0,message:''}
|
||||||
|
batchSchema.parse(batch)
|
||||||
|
this.db.run('INSERT INTO sequences(prefix,next_value) VALUES(?,?) ON CONFLICT(prefix) DO UPDATE SET next_value=excluded.next_value',[prefix,start+inputs.length])
|
||||||
|
this.db.run('INSERT INTO batches VALUES(?,?,?,?)',[batch.id,batch.createdAt,batch.sourceHash,JSON.stringify(batch)])
|
||||||
|
for(const r of batch.receipts)this.db.run('INSERT INTO receipt_numbers VALUES(?,?)',[r.number,batch.id])
|
||||||
|
return batch
|
||||||
|
})
|
||||||
|
}
|
||||||
|
get(id:string):Batch {const row=this.values('SELECT data FROM batches WHERE id=?',[id])[0];if(!row)throw Error('生成记录不存在');return batchSchema.parse(JSON.parse(String(row[0])))}
|
||||||
|
async update(batch:Batch):Promise<void>{await this.mutate(()=>{batchSchema.parse(batch);this.db.run('UPDATE batches SET data=? WHERE id=?',[JSON.stringify(batch),batch.id])})}
|
||||||
|
async deleteBatch(id:string):Promise<void>{
|
||||||
|
await this.mutate(()=>{
|
||||||
|
const batch=this.get(id)
|
||||||
|
if(batch.status==='reserved'||batch.status==='running')throw Error('生成中的批次不能删除,请等待完成或取消')
|
||||||
|
// Keep issued numbers and sequences reserved after removing the batch snapshot.
|
||||||
|
this.db.run('DELETE FROM batches WHERE id=?',[id])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
private *matchingBatches(query:BatchQuery):Generator<{batch:Batch;matches:Batch['receipts']}>{
|
||||||
|
const search=query.search?.trim().toLocaleLowerCase()||''
|
||||||
|
const filtering=!!(search||query.school||query.grade||query.item)
|
||||||
|
const statement=this.db.prepare('SELECT data FROM batches ORDER BY created_at DESC,rowid DESC')
|
||||||
|
try{
|
||||||
|
while(statement.step()){
|
||||||
|
const b=batchSchema.parse(JSON.parse(String(statement.get()[0])))
|
||||||
|
const sourceMatches=!!search&&b.sourceName.toLocaleLowerCase().includes(search)
|
||||||
|
const matches=filtering?b.receipts.filter(receipt=>{
|
||||||
|
const [school,grade,item]=receiptGroupLabels(receipt)
|
||||||
|
return (!query.school||school===query.school)&&(!query.grade||grade===query.grade)&&(!query.item||item===query.item)&&
|
||||||
|
(!search||sourceMatches||[receipt.number,receipt.customer,receipt.studentName||'',school,grade,item].some(value=>value.toLocaleLowerCase().includes(search)))
|
||||||
|
}):b.receipts
|
||||||
|
if(matches.length)yield {batch:b,matches}
|
||||||
|
}
|
||||||
|
}finally{statement.free()}
|
||||||
|
}
|
||||||
|
private summarizeBatch(b:Batch,matches:Batch['receipts'],query:BatchQuery):BatchSummary{
|
||||||
|
const filtering=!!(query.search?.trim()||query.school||query.grade||query.item)
|
||||||
|
return {id:b.id,createdAt:b.createdAt,sourceName:b.sourceName,count:b.receipts.length,totalCents:b.receipts.reduce((s,r)=>s+r.amountCents,0),firstNumber:b.receipts[0].number,lastNumber:b.receipts.at(-1)!.number,status:b.status,outputDirectory:b.outputDirectory,message:conciseError(b.message),groups:summarizeReceiptGroups(b.receipts),...(filtering?{match:{count:matches.length,number:matches[0].number,customer:matches[0].customer}}:{})}
|
||||||
|
}
|
||||||
|
summaries(query:BatchQuery={}):BatchSummary[]{
|
||||||
|
const result:BatchSummary[]=[]
|
||||||
|
for(const {batch,matches} of this.matchingBatches(query)){
|
||||||
|
result.push(this.summarizeBatch(batch,matches,query))
|
||||||
|
if(result.length===200)break
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
batchPage(query:BatchPageQuery={}):BatchPage{
|
||||||
|
const pageSize=query.pageSize&&historyPageSizes.includes(query.pageSize)?query.pageSize:5
|
||||||
|
const requested=Math.max(1,Math.trunc(query.page||1))
|
||||||
|
const filtering=!!(query.search?.trim()||query.school||query.grade||query.item)
|
||||||
|
if(!filtering){
|
||||||
|
const total=this.countBatches()
|
||||||
|
const page=Math.min(requested,Math.max(1,Math.ceil(total/pageSize)))
|
||||||
|
const items=this.values('SELECT data FROM batches ORDER BY created_at DESC,rowid DESC LIMIT ? OFFSET ?',[pageSize,(page-1)*pageSize]).map(row=>{
|
||||||
|
const batch=batchSchema.parse(JSON.parse(String(row[0])))
|
||||||
|
return this.summarizeBatch(batch,batch.receipts,query)
|
||||||
|
})
|
||||||
|
return {items,total,page,pageSize}
|
||||||
|
}
|
||||||
|
const selected:{batch:Batch;matches:Batch['receipts']}[]=[],offset=(requested-1)*pageSize
|
||||||
|
let total=0,tail:typeof selected=[]
|
||||||
|
for(const entry of this.matchingBatches(query)){
|
||||||
|
if(total%pageSize===0)tail=[]
|
||||||
|
tail.push(entry)
|
||||||
|
if(total>=offset&&selected.length<pageSize)selected.push(entry)
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
const page=Math.min(requested,Math.max(1,Math.ceil(total/pageSize)))
|
||||||
|
return {items:(page===requested?selected:tail).map(({batch,matches})=>this.summarizeBatch(batch,matches,query)),total,page,pageSize}
|
||||||
|
}
|
||||||
|
countBatches():number{return Number(this.values('SELECT COUNT(*) FROM batches')[0][0])}
|
||||||
|
historyFilters():HistoryFilters{
|
||||||
|
const sets=[new Set<string>(),new Set<string>(),new Set<string>()]
|
||||||
|
const statement=this.db.prepare('SELECT data FROM batches')
|
||||||
|
try{while(statement.step()){
|
||||||
|
const b=batchSchema.parse(JSON.parse(String(statement.get()[0])))
|
||||||
|
for(const receipt of b.receipts)receiptGroupLabels(receipt).forEach((label,i)=>sets[i].add(label))
|
||||||
|
}}finally{statement.free()}
|
||||||
|
const [schools,grades,items]=sets.map(values=>[...values].sort((a,b)=>a.localeCompare(b,'zh-CN',{numeric:true})))
|
||||||
|
return {schools,grades,items}
|
||||||
|
}
|
||||||
|
isOutputDirectory(folder:string):boolean{
|
||||||
|
const resolved=path.resolve(folder),statement=this.db.prepare('SELECT data FROM batches')
|
||||||
|
try{while(statement.step()){
|
||||||
|
const batch=batchSchema.parse(JSON.parse(String(statement.get()[0])))
|
||||||
|
if(batch.outputDirectory&&path.resolve(batch.outputDirectory)===resolved)return true
|
||||||
|
}}finally{statement.free()}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
previous(hash:string):string|undefined{return this.values('SELECT id FROM batches WHERE source_hash=? ORDER BY created_at DESC LIMIT 1',[hash])[0]?.[0] as string|undefined}
|
||||||
|
async recoverInterrupted():Promise<void>{
|
||||||
|
for(const row of this.values('SELECT data FROM batches')){const b=batchSchema.parse(JSON.parse(String(row[0])));if(['running','reserved'].includes(b.status)){b.status='partial';b.message='上次生成被中断,可使用原编号重新导出';await this.update(b)}}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { BrowserWindow } from 'electron'
|
||||||
|
import type { AppSettings,Receipt,SealImage } from '../shared/types'
|
||||||
|
import { buildDocument,TemplateRegistry } from '../core/templates'
|
||||||
|
import { conciseError } from '../shared/errors'
|
||||||
|
import { printDocuments } from './print-documents'
|
||||||
|
import { withPngDensity } from './png-density'
|
||||||
|
export interface PdfEngine {
|
||||||
|
pdf(receipts:Receipt[],settings:AppSettings,seal:SealImage|null,layout:'single'|'print',perPage:1|2,cutLine:boolean):Promise<Buffer>;
|
||||||
|
png(pdf:Uint8Array,dpi:number):Promise<Buffer>;
|
||||||
|
close():void;
|
||||||
|
}
|
||||||
|
export class ElectronPdfEngine implements PdfEngine {
|
||||||
|
private printWindow:BrowserWindow|null=null;private rasterWindow:BrowserWindow|null=null
|
||||||
|
constructor(private templates:TemplateRegistry,private rendererBase:string){}
|
||||||
|
async pdf(receipts:Receipt[],settings:AppSettings,seal:SealImage|null,layout:'single'|'print',perPage:1|2,cutLine:boolean):Promise<Buffer>{
|
||||||
|
this.printWindow??=new BrowserWindow({show:false,width:1000,height:1100,webPreferences:{sandbox:true,contextIsolation:true,nodeIntegration:false,backgroundThrottling:false}})
|
||||||
|
const html=buildDocument(this.templates.get(settings.templateId),receipts,{settings,seal,preview:false},layout,perPage,cutLine)
|
||||||
|
const document=printDocuments.publish(html)
|
||||||
|
let stage='加载收据页面'
|
||||||
|
try{
|
||||||
|
await this.printWindow.loadURL(document.url)
|
||||||
|
stage='加载字体或印章'
|
||||||
|
await this.printWindow.webContents.executeJavaScript('Promise.all([document.fonts.ready, ...Array.from(document.images).map(image => image.decode())]).then(()=>true)')
|
||||||
|
stage='生成 PDF'
|
||||||
|
return await this.printWindow.webContents.printToPDF({preferCSSPageSize:true,printBackground:true,displayHeaderFooter:false,margins:{top:0,bottom:0,left:0,right:0}})
|
||||||
|
}catch(error){throw Error(stage+'失败:'+conciseError(error))}
|
||||||
|
finally{document.dispose()}
|
||||||
|
}
|
||||||
|
async png(pdf:Uint8Array,dpi:number):Promise<Buffer>{
|
||||||
|
if(!this.rasterWindow){this.rasterWindow=new BrowserWindow({show:false,webPreferences:{sandbox:true,contextIsolation:true,nodeIntegration:false,backgroundThrottling:false}});await this.rasterWindow.loadURL(this.rendererBase+'/raster.html')}
|
||||||
|
const data=await this.rasterWindow.webContents.executeJavaScript('window.renderPdf('+JSON.stringify(Buffer.from(pdf).toString('base64'))+','+dpi+')') as string
|
||||||
|
return withPngDensity(Buffer.from(data.split(',')[1],'base64'),dpi)
|
||||||
|
}
|
||||||
|
close():void{this.printWindow?.destroy();this.rasterWindow?.destroy();this.printWindow=null;this.rasterWindow=null}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
function crc32(bytes:Uint8Array):number {
|
||||||
|
let crc=0xffffffff
|
||||||
|
for(const byte of bytes){crc^=byte;for(let bit=0;bit<8;bit++)crc=(crc>>>1)^((crc&1)?0xedb88320:0)}
|
||||||
|
return (crc^0xffffffff)>>>0
|
||||||
|
}
|
||||||
|
// Canvas serialisation may tag PNGs as 96 DPI. Store the requested physical resolution explicitly.
|
||||||
|
export function withPngDensity(input:Buffer,dpi:number):Buffer {
|
||||||
|
if(!input.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])))throw Error('PNG 数据无效')
|
||||||
|
const body=Buffer.alloc(13);body.write('pHYs',0,'ascii')
|
||||||
|
const pixelsPerMeter=Math.round(dpi/0.0254);body.writeUInt32BE(pixelsPerMeter,4);body.writeUInt32BE(pixelsPerMeter,8);body[12]=1
|
||||||
|
const chunk=Buffer.alloc(21);chunk.writeUInt32BE(9,0);body.copy(chunk,4);chunk.writeUInt32BE(crc32(body),17)
|
||||||
|
const parts:Buffer[]=[input.subarray(0,8)]
|
||||||
|
for(let offset=8;offset<input.length;){
|
||||||
|
if(offset+12>input.length)throw Error('PNG 文件不完整')
|
||||||
|
const length=input.readUInt32BE(offset),end=offset+length+12,type=input.toString('ascii',offset+4,offset+8)
|
||||||
|
if(end>input.length)throw Error('PNG 文件不完整')
|
||||||
|
if(type!=='pHYs')parts.push(input.subarray(offset,end))
|
||||||
|
if(type==='IHDR')parts.push(chunk)
|
||||||
|
offset=end
|
||||||
|
}
|
||||||
|
return Buffer.concat(parts)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { contextBridge,ipcRenderer } from 'electron'
|
||||||
|
import type { ReceiptAPI,ExportProgress,ConfirmationRequest,ProcessingTask } from '../shared/types'
|
||||||
|
async function call<T>(method:string,...args:unknown[]):Promise<T>{
|
||||||
|
const result=await ipcRenderer.invoke('receipt:'+method,...args)
|
||||||
|
if(!result.ok)throw Error(result.error)
|
||||||
|
return result.value as T
|
||||||
|
}
|
||||||
|
const api:ReceiptAPI={
|
||||||
|
bootstrap:()=>call('bootstrap'),saveSettings:s=>call('saveSettings',s),chooseFolder:k=>call('chooseFolder',k),
|
||||||
|
importExcel:()=>call('importExcel'),saveExcelTemplate:()=>call('saveExcelTemplate'),listSeals:()=>call('listSeals'),
|
||||||
|
preview:r=>call('preview',r),exportReceipts:r=>call('exportReceipts',r),cancelExport:()=>call('cancelExport'),
|
||||||
|
listBatches:query=>call('listBatches',query),listBatchPage:query=>call('listBatchPage',query),deleteBatch:id=>call('deleteBatch',id),openFolder:p=>call('openFolder',p),
|
||||||
|
answerConfirmation:(id,confirmed)=>call('answerConfirmation',{id,confirmed}),
|
||||||
|
onProcessing:callback=>{const listener=(_event:Electron.IpcRendererEvent,task:ProcessingTask)=>callback(task);ipcRenderer.on('receipt:processing',listener);return()=>ipcRenderer.removeListener('receipt:processing',listener)},
|
||||||
|
onConfirmation:callback=>{const listener=(_event:Electron.IpcRendererEvent,data:ConfirmationRequest)=>callback(data);ipcRenderer.on('receipt:confirmation',listener);return()=>ipcRenderer.removeListener('receipt:confirmation',listener)},
|
||||||
|
onProgress:callback=>{const listener=(_event:Electron.IpcRendererEvent,data:ExportProgress)=>callback(data);ipcRenderer.on('receipt:progress',listener);return()=>ipcRenderer.removeListener('receipt:progress',listener)}
|
||||||
|
}
|
||||||
|
contextBridge.exposeInMainWorld('receipts',api)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
|
||||||
|
// Keep print content in memory. The renderer receives a short local URL even when stamps are large.
|
||||||
|
export class PrintDocumentStore {
|
||||||
|
private documents=new Map<string,string>()
|
||||||
|
publish(html:string):{url:string;dispose:()=>void}{
|
||||||
|
const url='receipt://print/'+randomUUID()+'/index.html'
|
||||||
|
this.documents.set(url,html)
|
||||||
|
return {url,dispose:()=>{this.documents.delete(url)}}
|
||||||
|
}
|
||||||
|
read(url:string):string|undefined{return this.documents.get(url)}
|
||||||
|
}
|
||||||
|
export const printDocuments=new PrintDocumentStore()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { nativeImage } from 'electron'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import type { SealImage,SealList } from '../shared/types'
|
||||||
|
const cache=new Map<string,{signature:string;image:SealImage}>()
|
||||||
|
export async function readSeal(folder:string,name:string):Promise<SealImage>{
|
||||||
|
if(!folder||name!==path.basename(name)||!/^.+\.(png|jpe?g|webp)$/i.test(name))throw Error('印章文件名无效')
|
||||||
|
const file=path.join(folder,name),stat=await fs.stat(file)
|
||||||
|
if(!stat.isFile()||stat.size>8*1024*1024)throw Error('印章图片须小于 8 MB')
|
||||||
|
const signature=stat.mtimeMs+':'+stat.size,hit=cache.get(file)
|
||||||
|
if(hit?.signature===signature)return hit.image
|
||||||
|
let img=nativeImage.createFromPath(file);const size=img.getSize()
|
||||||
|
if(img.isEmpty()||Math.max(size.width,size.height)>8192)throw Error('图片无法读取或尺寸过大')
|
||||||
|
if(Math.max(size.width,size.height)>1000)img=img.resize(size.width>=size.height?{width:1000}:{height:1000})
|
||||||
|
const image={id:name,name:path.parse(name).name,dataUrl:img.toDataURL()}
|
||||||
|
if(cache.size>=100)cache.delete(cache.keys().next().value!)
|
||||||
|
cache.set(file,{signature,image});return image
|
||||||
|
}
|
||||||
|
export async function readSeals(folder:string):Promise<SealList>{
|
||||||
|
if(!folder)return {images:[],warnings:[]}
|
||||||
|
let names:string[]
|
||||||
|
try{names=await fs.readdir(folder)}catch{throw Error('印章文件夹无法访问,请检查是否被移动,或在设置中重新绑定')}
|
||||||
|
const result:SealList={images:[],warnings:[]}
|
||||||
|
const files=names.filter(n=>/\.(png|jpe?g|webp)$/i.test(n)).sort((a,b)=>a.localeCompare(b,'zh-CN'))
|
||||||
|
if(files.length>100)result.warnings.push('仅加载前 100 张印章,请整理文件夹后刷新')
|
||||||
|
for(const name of files.slice(0,100)){try{result.images.push(await readSeal(folder,name))}catch{result.warnings.push(name+' 无法加载,请使用有效的 PNG、JPG 或 WebP 图片')}}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { defaultSettings,settingsSchema } from '../shared/settings'
|
||||||
|
import type { AppSettings } from '../shared/types'
|
||||||
|
import { atomicWrite } from './files'
|
||||||
|
function activeSettings(input:unknown):AppSettings{
|
||||||
|
// Retain legacy fields for old files and snapshots; these behaviors are now always enabled.
|
||||||
|
return {...settingsSchema.parse(input),createBatchFolder:true,openAfterExport:true}
|
||||||
|
}
|
||||||
|
export class SettingsStore {
|
||||||
|
private current:AppSettings
|
||||||
|
constructor(private readonly file:string,output:string){this.current=defaultSettings(output)}
|
||||||
|
async load():Promise<AppSettings>{
|
||||||
|
try {this.current=activeSettings({...this.current,...JSON.parse(await fs.readFile(this.file,'utf8'))})}
|
||||||
|
catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw Error('设置文件无法读取,原文件已保留,请联系维护人员处理')}
|
||||||
|
return this.get()
|
||||||
|
}
|
||||||
|
get():AppSettings{return {...this.current}}
|
||||||
|
async save(input:unknown):Promise<AppSettings>{
|
||||||
|
const next=activeSettings(input)
|
||||||
|
for(const folder of [next.outputDirectory,next.sealDirectory]) if(folder&&!path.isAbsolute(folder))throw Error('请选择完整的文件夹路径')
|
||||||
|
await atomicWrite(this.file,JSON.stringify(next,null,2));this.current=next;return this.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import ExcelJS from 'exceljs'
|
||||||
|
import { createHash,randomUUID } from 'node:crypto'
|
||||||
|
import { promises as fs } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { parseMoney } from '../core/money'
|
||||||
|
import { parseReceiptDate } from '../core/date'
|
||||||
|
import type { ImportDraft,ImportRow } from '../shared/types'
|
||||||
|
export const MAX_ROWS=5000
|
||||||
|
const fields=['收款日期','学校','年级','姓名','收费项目','金额','出纳','复核'] as const
|
||||||
|
const requiredFields=fields.slice(0,6)
|
||||||
|
function value(cell:ExcelJS.Cell):unknown {
|
||||||
|
const v=cell.value
|
||||||
|
if(v&&typeof v==='object'&&'formula' in v) return v.result
|
||||||
|
if(v&&typeof v==='object'&&'richText' in v) return v.richText.map(x=>x.text).join('')
|
||||||
|
if(v&&typeof v==='object'&&'text' in v) return v.text
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
function text(v:unknown):string { return v===null||v===undefined?'':String(v).trim() }
|
||||||
|
export interface WorkbookReader { read(file:string):Promise<ImportDraft> }
|
||||||
|
export class XlsxReader implements WorkbookReader {
|
||||||
|
async read(file:string):Promise<ImportDraft> {
|
||||||
|
if(path.extname(file).toLowerCase()!=='.xlsx') throw Error('请使用 .xlsx 文件;旧版 .xls 请先另存为 .xlsx')
|
||||||
|
const stat=await fs.stat(file); if(stat.size>20*1024*1024) throw Error('Excel 文件不能超过 20 MB')
|
||||||
|
const bytes=await fs.readFile(file)
|
||||||
|
const book=new ExcelJS.Workbook()
|
||||||
|
try{await book.xlsx.load(bytes as unknown as ExcelJS.Buffer)}catch{throw Error('无法读取 Excel,请检查文件是否损坏或设置了密码')}
|
||||||
|
const sheet=book.worksheets.find(s=>s.state==='visible'); if(!sheet) throw Error('文件中没有可读取的工作表')
|
||||||
|
if(sheet.rowCount>MAX_ROWS+1) throw Error('单次最多导入 '+MAX_ROWS+' 行,请拆分文件后导入')
|
||||||
|
const columns=new Map<string,number>()
|
||||||
|
sheet.getRow(1).eachCell((cell,col)=>{const name=text(value(cell));if(fields.includes(name as typeof fields[number])){if(columns.has(name))throw Error('表头重复:'+name);columns.set(name,col)}})
|
||||||
|
const missing=requiredFields.filter(f=>!columns.has(f))
|
||||||
|
if(missing.length) throw Error('第一行缺少表头:'+missing.join('、')+'。请使用程序提供的 Excel 模板')
|
||||||
|
const rows:ImportRow[]=[], seen=new Set<string>()
|
||||||
|
for(let n=2;n<=sheet.rowCount;n++){
|
||||||
|
const row=sheet.getRow(n), get=(field:string)=>columns.has(field)?value(row.getCell(columns.get(field)!)):null
|
||||||
|
if(fields.every(f=>!text(get(f)))) continue
|
||||||
|
const errors:string[]=[],warnings:string[]=[]
|
||||||
|
let date='',amountCents=0
|
||||||
|
try{date=parseReceiptDate(get('收款日期'),book.properties.date1904)}catch(e){errors.push((e as Error).message)}
|
||||||
|
try{amountCents=parseMoney(text(get('金额')))}catch(e){errors.push((e as Error).message)}
|
||||||
|
const school=text(get('学校')),grade=text(get('年级')),studentName=text(get('姓名'))
|
||||||
|
const customer=[school,grade,studentName].filter(Boolean).join(' ')
|
||||||
|
const item=text(get('收费项目')),cashier=text(get('出纳')),reviewer=text(get('复核'))
|
||||||
|
if(!school)errors.push('学校不能为空')
|
||||||
|
if(!grade)errors.push('年级不能为空')
|
||||||
|
if(!studentName)errors.push('姓名不能为空')
|
||||||
|
if(customer.length>80)errors.push('学校、年级、姓名拼接后最多 80 个字符')
|
||||||
|
if(!item)errors.push('收费项目不能为空');else if(item.length>80)errors.push('收费项目最多 80 个字符')
|
||||||
|
if(cashier.length>20||reviewer.length>20)errors.push('出纳、复核姓名最多 20 个字符')
|
||||||
|
const receipt={date,school,grade,studentName,customer,item,amountCents,cashier,reviewer}
|
||||||
|
const key=JSON.stringify(receipt);if(seen.has(key))warnings.push('与文件中的另一行内容相同,请核对是否重复');seen.add(key)
|
||||||
|
rows.push({index:rows.length,excelRow:n,receipt,errors,warnings})
|
||||||
|
}
|
||||||
|
if(!rows.length) throw Error('Excel 中没有收据数据,请从第二行开始填写')
|
||||||
|
return {id:randomUUID(),fileName:path.basename(file),hash:createHash('sha256').update(bytes).digest('hex'),rows}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function createWorkbookTemplate(file:string):Promise<void>{
|
||||||
|
const book=new ExcelJS.Workbook(),sheet=book.addWorksheet('收据数据')
|
||||||
|
sheet.columns=fields.map((name,i)=>({header:name,key:name,width:[18,30,20,18,38,18,16,16][i]}))
|
||||||
|
sheet.addRow(['2026-10-16','某某学校','初二年级','张三','秋季学期服务费',3500,'',''])
|
||||||
|
sheet.getRow(1).height=28
|
||||||
|
sheet.getRow(1).eachCell(c=>{c.font={bold:true,color:{argb:'FFFFFFFF'}};c.fill={type:'pattern',pattern:'solid',fgColor:{argb:'FF157F73'}};c.alignment={vertical:'middle'}})
|
||||||
|
for(const column of [1,2,3,4])sheet.getColumn(column).numFmt='@'
|
||||||
|
sheet.getColumn(6).numFmt='0.00'
|
||||||
|
sheet.views=[{state:'frozen',ySplit:1}];sheet.autoFilter='A1:H1'
|
||||||
|
const info=book.addWorksheet('填写说明')
|
||||||
|
for(const line of ['每行生成一张收据,第一行表头请保留。','收款日期、学校、年级、姓名、收费项目、金额为必填。','收据客户名称按“学校 年级 姓名”拼接,各列之间使用一个空格。','每列首尾空白会自动去除;拼接后最多 80 个字符。','金额须大于 0,最多两位小数,最高 9,999,999.99 元。','出纳、复核留空时使用程序设置中的默认值。','编号由系统生成,Excel 中不需要填写。','第一行数据是示例,请替换或删除。','单次最多导入 5000 行,支持 .xlsx。'])info.addRow([line])
|
||||||
|
info.getColumn(1).width=95
|
||||||
|
await book.xlsx.writeFile(file)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed,onMounted,onBeforeUnmount,watch } from 'vue'
|
||||||
|
import { FileSpreadsheet,Stamp,History,Settings2,ShieldCheck,ArrowUpRight,LoaderCircle,TriangleAlert } from '@lucide/vue'
|
||||||
|
import WorkbenchView from './views/WorkbenchView.vue'
|
||||||
|
import SealsView from './views/SealsView.vue'
|
||||||
|
import HistoryView from './views/HistoryView.vue'
|
||||||
|
import SettingsView from './views/SettingsView.vue'
|
||||||
|
import { state,actions } from './state'
|
||||||
|
import ConfirmDialog from './components/ConfirmDialog.vue'
|
||||||
|
import { askConfirmation,answerConfirmation } from './confirmations'
|
||||||
|
const navigation=[{id:'workbench',label:'收据工作台',icon:FileSpreadsheet},{id:'seals',label:'印章管理',icon:Stamp},{id:'history',label:'生成记录',icon:History},{id:'settings',label:'偏好设置',icon:Settings2}] as const
|
||||||
|
const titles={workbench:['收据工作台','从一份表格,生成整齐的每一张收据。'],seals:['印章管理','绑定本地文件夹,常用印章随时可选。'],history:['生成记录','查找已生成的批次,使用原编号重新导出。'],settings:['偏好设置','记住你的习惯,让每次开具更顺手。']}
|
||||||
|
const heading=computed(()=>titles[state.page])
|
||||||
|
async function navigate(page:typeof state.page){
|
||||||
|
if(page===state.page)return
|
||||||
|
if(state.page==='settings'&&state.settingsDirty&&!await askConfirmation({title:'放弃未保存的设置?',description:'离开后,本次尚未保存的更改不会保留。',variant:'warning',confirmLabel:'放弃更改',cancelLabel:'继续编辑'}))return
|
||||||
|
state.settingsDirty=false;state.page=page
|
||||||
|
}
|
||||||
|
let stopConfirmations:(()=>void)|undefined
|
||||||
|
onMounted(()=>{
|
||||||
|
stopConfirmations=window.receipts?.onConfirmation?.(request=>{
|
||||||
|
void askConfirmation(request).then(confirmed=>window.receipts.answerConfirmation(request.id,confirmed)).catch(error=>{actions.reportError(state.page==='workbench'?'export':state.page,error instanceof Error?error.message:'确认操作失败')})
|
||||||
|
})
|
||||||
|
void actions.init()
|
||||||
|
})
|
||||||
|
onBeforeUnmount(()=>{stopConfirmations?.();answerConfirmation(false)})
|
||||||
|
watch(()=>state.page,page=>{window.scrollTo(0,0);if(page==='workbench'&&state.ready)void actions.refreshPreview()})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand"><div class="brand-mark">收</div><div><strong>收据工坊</strong><span>RECEIPT STUDIO</span></div></div>
|
||||||
|
<div class="nav-label">我的工作空间</div>
|
||||||
|
<nav><button v-for="nav in navigation" :key="nav.id" :class="['nav-item',{active:state.page===nav.id}]" @click="navigate(nav.id)"><component :is="nav.icon" :size="19"/><span>{{nav.label}}</span><span v-if="nav.id==='history'&&state.historyTotal" class="nav-count">{{state.historyTotal}}</span></button></nav>
|
||||||
|
<div class="sidebar-bottom"><div class="offline-note"><ShieldCheck :size="19"/><div><strong>安心在本地处理</strong><span>无需登录 · 离线可用</span></div></div><div class="app-version">收据工坊 v{{state.version}}<span>WIN 64</span></div></div>
|
||||||
|
</aside>
|
||||||
|
<main class="main-content">
|
||||||
|
<header class="page-header"><div><div class="eyebrow">YOUR LOCAL WORKSPACE</div><h1>{{heading[0]}}</h1><p>{{heading[1]}}</p></div><div class="header-status"><span class="status-dot"></span>本地工作空间 <ArrowUpRight :size="15"/></div></header>
|
||||||
|
<div v-if="!state.ready" class="loading-state"><TriangleAlert v-if="state.error" :size="25"/><LoaderCircle v-else class="spin" :size="25"/><span>{{state.error||'正在准备本地工作空间…'}}</span></div>
|
||||||
|
<template v-else><WorkbenchView v-if="state.page==='workbench'"/><SealsView v-else-if="state.page==='seals'"/><HistoryView v-else-if="state.page==='history'"/><SettingsView v-else/></template>
|
||||||
|
</main>
|
||||||
|
<ConfirmDialog/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { state } from '../state'
|
||||||
|
const props=withDefaults(defineProps<{task:string;target?:string;loading?:boolean;disabled?:boolean;iconOnly?:boolean;type?:'button'|'submit'|'reset'}>(),{type:'button',loading:undefined})
|
||||||
|
const matches=computed(()=>props.target===undefined||state.taskTargets.get(props.task)===props.target)
|
||||||
|
const pending=computed(()=>props.loading??(matches.value&&state.busy.has(props.task)))
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<button :type="type" :disabled="disabled||pending" :aria-busy="pending||undefined" :data-task="task" :data-task-target="target" :data-phase="pending?'pending':'idle'">
|
||||||
|
<span v-if="$slots.icon" class="action-icon" aria-hidden="true"><slot name="icon"/></span><slot v-if="!iconOnly"/>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { AlertDialogRoot,AlertDialogPortal,AlertDialogOverlay,AlertDialogContent,AlertDialogTitle,AlertDialogDescription,AlertDialogCancel } from 'reka-ui'
|
||||||
|
import { Trash2,TriangleAlert,CircleHelp,ShieldCheck,X } from '@lucide/vue'
|
||||||
|
import { confirmation,answerConfirmation,restoreConfirmationFocus } from '../confirmations'
|
||||||
|
const icon=computed(()=>confirmation.value?.variant==='danger'?Trash2:confirmation.value?.variant==='warning'?TriangleAlert:CircleHelp)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<AlertDialogRoot :open="!!confirmation" @update:open="open=>{if(!open)answerConfirmation(false)}">
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay class="confirm-overlay"/>
|
||||||
|
<AlertDialogContent :class="['confirm-dialog',{'confirm-danger':confirmation?.variant==='danger','confirm-warning':confirmation?.variant==='warning'}]" @close-auto-focus="restoreConfirmationFocus">
|
||||||
|
<button class="icon-button confirm-close" aria-label="关闭确认窗口" @click="answerConfirmation(false)"><X :size="18"/></button>
|
||||||
|
<div class="confirm-heading"><span class="confirm-icon"><component :is="icon" :size="24" :stroke-width="1.6"/></span><div><AlertDialogTitle class="confirm-title">{{confirmation?.title}}</AlertDialogTitle><AlertDialogDescription class="confirm-description">{{confirmation?.description}}</AlertDialogDescription></div></div>
|
||||||
|
<dl v-if="confirmation?.details?.length" class="confirm-details"><div v-for="(detail,index) in confirmation.details" :key="index"><dt>{{detail.label}}</dt><dd>{{detail.value}}</dd></div></dl>
|
||||||
|
<p v-if="confirmation?.note" class="confirm-note"><ShieldCheck :size="16"/><span>{{confirmation.note}}</span></p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<AlertDialogCancel as-child><button class="button" @click="answerConfirmation(false)">{{confirmation?.cancelLabel||'取消'}}</button></AlertDialogCancel>
|
||||||
|
<button :class="['button','confirm-accept',confirmation?.variant==='danger'?'destructive':'primary']" @click="answerConfirmation(true)">{{confirmation?.confirmLabel||'确定'}}</button>
|
||||||
|
</div>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
</AlertDialogRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick,onBeforeUnmount,onMounted,ref,watch } from 'vue'
|
||||||
|
import { LoaderCircle,TriangleAlert,X } from '@lucide/vue'
|
||||||
|
const props=withDefaults(defineProps<{as?:string;loading?:boolean;label?:string;error?:string;progress?:number;delay?:number}>(),{as:'div',loading:false,label:'正在处理…',error:'',delay:0})
|
||||||
|
const emit=defineEmits<{dismissError:[]}>()
|
||||||
|
const container=ref<HTMLElement>(),visible=ref(false),center=ref(80)
|
||||||
|
let timer:ReturnType<typeof setTimeout>|undefined,observer:ResizeObserver|undefined,previousFocus:HTMLElement|null=null
|
||||||
|
function position(){
|
||||||
|
const rect=container.value?.getBoundingClientRect();if(!rect)return
|
||||||
|
const top=Math.max(0,rect.top),bottom=Math.min(innerHeight,rect.bottom)
|
||||||
|
center.value=bottom>top?(top+bottom)/2-rect.top:rect.height/2
|
||||||
|
}
|
||||||
|
watch(()=>props.loading,async loading=>{
|
||||||
|
clearTimeout(timer)
|
||||||
|
if(loading){
|
||||||
|
previousFocus=document.activeElement instanceof HTMLElement&&container.value?.contains(document.activeElement)?document.activeElement:null
|
||||||
|
if(props.delay)timer=setTimeout(()=>{visible.value=true;void nextTick(position)},props.delay)
|
||||||
|
else visible.value=true
|
||||||
|
}else{
|
||||||
|
visible.value=false
|
||||||
|
await nextTick()
|
||||||
|
if(previousFocus?.isConnected&&!previousFocus.matches(':disabled')&&document.activeElement===document.body)previousFocus.focus({preventScroll:true})
|
||||||
|
previousFocus=null
|
||||||
|
}
|
||||||
|
await nextTick();position()
|
||||||
|
},{immediate:true})
|
||||||
|
onMounted(()=>{
|
||||||
|
observer=new ResizeObserver(position);if(container.value)observer.observe(container.value)
|
||||||
|
window.addEventListener('scroll',position,{capture:true,passive:true});window.addEventListener('resize',position);position()
|
||||||
|
})
|
||||||
|
onBeforeUnmount(()=>{clearTimeout(timer);observer?.disconnect();window.removeEventListener('scroll',position,true);window.removeEventListener('resize',position)})
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<component :is="as" ref="container" class="loading-region" :aria-busy="loading||undefined">
|
||||||
|
<div v-if="error" class="region-error" role="alert"><TriangleAlert :size="16"/><span>{{error}}</span><button class="icon-button" type="button" aria-label="关闭本板块错误提示" @click="emit('dismissError')"><X :size="15"/></button></div>
|
||||||
|
<div class="region-content" :inert="loading||undefined"><slot/></div>
|
||||||
|
<Transition name="region-loading">
|
||||||
|
<div v-if="visible" class="region-loading" role="status" aria-live="polite">
|
||||||
|
<div class="region-loading-indicator" :style="{top:center+'px'}"><LoaderCircle class="region-spinner" :size="30" :stroke-width="1.7"/><span>{{label}}</span>
|
||||||
|
<div v-if="progress!==undefined" class="region-loading-progress"><div><i :style="{width:Math.min(100,Math.max(0,progress))+'%'}"/></div><span>{{progress}}%</span></div>
|
||||||
|
<slot name="loading-actions"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {computed,reactive,ref} from 'vue'
|
||||||
|
import {ChevronRight} from '@lucide/vue'
|
||||||
|
import {displayMoney} from '../../core/money'
|
||||||
|
import type {ImportGroupNode} from '../../shared/receipt-groups'
|
||||||
|
const props=withDefaults(defineProps<{nodes:ImportGroupNode[];disabled?:boolean;expanded?:boolean;depth?:number}>(),{disabled:false,expanded:true,depth:0})
|
||||||
|
const emit=defineEmits<{toggle:[indices:number[]]}>()
|
||||||
|
const shown=ref(50),opened=reactive(new Map<string,boolean>())
|
||||||
|
const visible=computed(()=>props.nodes.slice(0,shown.value))
|
||||||
|
function isOpen(node:ImportGroupNode){return opened.get(node.key)??(props.expanded||props.depth===0)}
|
||||||
|
function setOpen(node:ImportGroupNode,event:Event){if(event.target instanceof HTMLDetailsElement)opened.set(node.key,event.target.open)}
|
||||||
|
function label(node:ImportGroupNode){return '选择分组:'+JSON.parse(node.key).join(' / ')}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="receipt-group-tree" :class="{'group-tree-root':depth===0}">
|
||||||
|
<component :is="node.children.length?'details':'div'" v-for="node in visible" :key="node.key" class="receipt-group-node" :open="node.children.length&&isOpen(node)?true:undefined" @toggle="setOpen(node,$event)">
|
||||||
|
<component :is="node.children.length?'summary':'label'" class="receipt-group-choice" :class="{'group-leaf':!node.children.length}">
|
||||||
|
<ChevronRight v-if="node.children.length" class="group-chevron" :size="14" aria-hidden="true"/><span v-else class="group-leaf-mark" aria-hidden="true"/>
|
||||||
|
<input type="checkbox" :aria-label="label(node)" :checked="node.indices.length>0&&node.selectedCount===node.indices.length" :indeterminate="node.selectedCount>0&&node.selectedCount<node.indices.length" :disabled="disabled||!node.indices.length" @click.stop @keydown.stop @change="emit('toggle',node.indices)">
|
||||||
|
<span class="group-name" :title="node.label">{{node.label}}</span>
|
||||||
|
<span v-if="node.invalidCount" class="group-invalid">{{node.invalidCount}} 条待修改</span>
|
||||||
|
<span class="group-count">已选 {{node.selectedCount}} / {{node.indices.length}} 张</span>
|
||||||
|
<span class="group-amount" :title="'本组有效数据合计 '+displayMoney(node.totalCents)+' 元'">¥{{displayMoney(node.selectedCents)}}</span>
|
||||||
|
</component>
|
||||||
|
<ReceiptGroupTree v-if="node.children.length&&isOpen(node)" :nodes="node.children" :disabled="disabled" :expanded="expanded" :depth="depth+1" @toggle="emit('toggle',$event)"/>
|
||||||
|
</component>
|
||||||
|
<button v-if="nodes.length>shown" type="button" class="text-button group-show-more" @click="shown+=50">显示更多分组(还有 {{nodes.length-shown}} 项)</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed,onBeforeUnmount,onMounted,ref,watch } from 'vue'
|
||||||
|
const props=defineProps<{ html:string; layout?:'single'|'print' }>()
|
||||||
|
const container=ref<HTMLDivElement>(),scale=ref(.6)
|
||||||
|
let observer:ResizeObserver|undefined
|
||||||
|
const paperWidth=computed(()=>(props.layout==='print'?210:190)*96/25.4)
|
||||||
|
const paperHeight=computed(()=>(props.layout==='print'?297:140)*96/25.4)
|
||||||
|
function resize(){if(container.value)scale.value=Math.min((container.value.clientWidth-36)/paperWidth.value,1)}
|
||||||
|
onMounted(()=>{observer=new ResizeObserver(resize);if(container.value)observer.observe(container.value);resize()})
|
||||||
|
watch(()=>props.layout,resize)
|
||||||
|
onBeforeUnmount(()=>observer?.disconnect())
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div ref="container" class="preview-canvas">
|
||||||
|
<div v-if="html" class="paper-wrap" :style="{width:paperWidth*scale+'px',height:paperHeight*scale+'px'}">
|
||||||
|
<iframe title="收据打印预览" sandbox="" scrolling="no" :srcdoc="html" :style="{width:paperWidth+'px',height:paperHeight+'px',transform:'scale('+scale+')'}"></iframe>
|
||||||
|
</div>
|
||||||
|
<div v-else class="preview-placeholder">选择一条有效数据,查看收据预览</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { shallowRef } from 'vue'
|
||||||
|
import type { ConfirmationOptions } from '../shared/types'
|
||||||
|
export const confirmation=shallowRef<ConfirmationOptions|null>(null)
|
||||||
|
let resolvePending:((confirmed:boolean)=>void)|null=null
|
||||||
|
let previousFocus:HTMLElement|null=null
|
||||||
|
|
||||||
|
export function askConfirmation(options:ConfirmationOptions):Promise<boolean>{
|
||||||
|
if(resolvePending)return Promise.resolve(false)
|
||||||
|
previousFocus=document.activeElement instanceof HTMLElement?document.activeElement:null
|
||||||
|
confirmation.value=options
|
||||||
|
return new Promise(resolve=>{resolvePending=resolve})
|
||||||
|
}
|
||||||
|
export function answerConfirmation(confirmed:boolean):void{
|
||||||
|
const resolve=resolvePending
|
||||||
|
resolvePending=null;confirmation.value=null
|
||||||
|
resolve?.(confirmed)
|
||||||
|
}
|
||||||
|
export function restoreConfirmationFocus(event:Event):void{
|
||||||
|
event.preventDefault()
|
||||||
|
if(previousFocus?.isConnected&&previousFocus!==document.body&&!previousFocus.matches(':disabled'))previousFocus.focus({preventScroll:true})
|
||||||
|
else document.querySelector<HTMLButtonElement>('.nav-item.active')?.focus({preventScroll:true})
|
||||||
|
previousFocus=null
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import type { ReceiptAPI } from '../shared/types'
|
||||||
|
declare global { interface Window { receipts: ReceiptAPI } }
|
||||||
|
export {}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self' about:; connect-src 'self' ws://127.0.0.1:5173; worker-src 'self' blob:"><title>收据工坊</title></head><body><div id="app"></div><script type="module" src="./main.ts"></script></body></html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import { state } from './state'
|
||||||
|
export type PanelScope='settings'|'seals'|'history'|'data'|'preview'|'export'
|
||||||
|
const labels:Record<string,string>={
|
||||||
|
settings:'正在保存设置…',
|
||||||
|
'refresh-seals':'正在刷新印章列表…',
|
||||||
|
'refresh-records':'正在刷新生成记录…','history-preview':'正在加载收据预览…','delete-batch':'正在删除批次记录…',
|
||||||
|
'history-page':'正在加载生成记录…',
|
||||||
|
import:'正在导入收据数据…',template:'正在保存导入模板…',export:'正在准备生成…',cancel:'正在取消…'
|
||||||
|
}
|
||||||
|
export function usePanelState(scope:PanelScope){
|
||||||
|
return computed(()=>{
|
||||||
|
const error=state.panelErrors.get(scope)||''
|
||||||
|
if(state.exporting&&scope===state.exportPanel){
|
||||||
|
const progress=state.progress?Math.round(state.progress.completed/Math.max(1,state.progress.total)*100):undefined
|
||||||
|
return {loading:true,delay:0,label:state.cancelRequested?'正在取消…':state.progress?.stage||'正在准备生成收据…',progress,error}
|
||||||
|
}
|
||||||
|
const task=[...state.loadingTasks].find(key=>state.taskPanels.get(key)===scope)
|
||||||
|
return {loading:!!task||scope==='preview'&&state.previewLoading,delay:task?0:120,label:task?labels[task]||'正在处理…':'正在更新收据预览…',progress:undefined,error}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css'
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 UnoVue <https://github.com/unovue>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'unsafe-inline'; img-src 'self' data: blob:"></head><body><canvas id="canvas"></canvas><script type="module" src="./raster.ts"></script></body></html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { getDocument,GlobalWorkerOptions } from 'pdfjs-dist'
|
||||||
|
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
||||||
|
GlobalWorkerOptions.workerSrc=workerUrl
|
||||||
|
declare global { interface Window {renderPdf:(base64:string,dpi:number)=>Promise<string>} }
|
||||||
|
window.renderPdf=async(base64,dpi)=>{
|
||||||
|
const data=Uint8Array.from(atob(base64),v=>v.charCodeAt(0))
|
||||||
|
const doc=await getDocument({data}).promise
|
||||||
|
try{
|
||||||
|
const page=await doc.getPage(1),viewport=page.getViewport({scale:dpi/72})
|
||||||
|
const canvas=document.getElementById('canvas') as HTMLCanvasElement
|
||||||
|
canvas.width=Math.ceil(viewport.width);canvas.height=Math.ceil(viewport.height)
|
||||||
|
await page.render({canvas,canvasContext:canvas.getContext('2d')!,viewport,background:'white'}).promise
|
||||||
|
return canvas.toDataURL('image/png')
|
||||||
|
}finally{await doc.destroy()}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { nextTick,reactive } from 'vue'
|
||||||
|
import { defaultSettings } from '../shared/settings'
|
||||||
|
import { conciseError } from '../shared/errors'
|
||||||
|
import type { AppSettings,BatchSummary,ExportProgress,HistoryFilters,ImportDraft,SealImage } from '../shared/types'
|
||||||
|
export const state=reactive({
|
||||||
|
page:'workbench' as 'workbench'|'seals'|'history'|'settings',settings:defaultSettings(),draft:null as ImportDraft|null,
|
||||||
|
selected:new Set<number>(),rowIndex:0,seals:[] as SealImage[],sealWarnings:[] as string[],currentSealId:'',
|
||||||
|
previewHtml:'',previewLayout:'single' as 'single'|'print',previewLoading:false,batches:[] as BatchSummary[],historyTotal:0,historyFilters:{schools:[],grades:[],items:[]} as HistoryFilters,nextNumber:'',
|
||||||
|
dataDirectory:'',version:'1.0.0',templates:[] as {id:string;name:string}[],
|
||||||
|
settingsDirty:false,busy:new Set<string>(),loadingTasks:new Set<string>(),taskTargets:new Map<string,string>(),taskPanels:new Map<string,string>(),panelErrors:new Map<string,string>(),
|
||||||
|
error:'',cancelRequested:false,exporting:false,exportBatchId:'',exportPanel:'export',progress:null as ExportProgress|null,ready:false
|
||||||
|
})
|
||||||
|
interface TaskFeedback {target?:string;feedback?:boolean;scope?:string}
|
||||||
|
const processingTasks=new Set(['import','template','delete-batch'])
|
||||||
|
const quietTasks=new Set(['init','settings-folder','folder','open','seal','cancel'])
|
||||||
|
const loadingMinimums=new Map<string,Promise<void>>()
|
||||||
|
function startLoading(key:string,minimumMs=500){
|
||||||
|
if(state.loadingTasks.has(key))return
|
||||||
|
state.loadingTasks.add(key)
|
||||||
|
loadingMinimums.set(key,(async()=>{
|
||||||
|
await nextTick()
|
||||||
|
// Count visible time after the first painted frame, not from the click or IPC call.
|
||||||
|
await new Promise<void>(resolve=>{
|
||||||
|
let frame=0
|
||||||
|
const finish=()=>{clearTimeout(timer);cancelAnimationFrame(frame);resolve()}
|
||||||
|
const timer=setTimeout(finish,100)
|
||||||
|
frame=requestAnimationFrame(()=>{frame=requestAnimationFrame(finish)})
|
||||||
|
})
|
||||||
|
if(minimumMs)await new Promise(resolve=>setTimeout(resolve,minimumMs))
|
||||||
|
})())
|
||||||
|
}
|
||||||
|
function taskPanel(key:string):string{
|
||||||
|
if(['settings','settings-folder'].includes(key))return 'settings'
|
||||||
|
if(['refresh-records','history-preview','delete-batch'].includes(key))return 'history'
|
||||||
|
if(['import','template'].includes(key))return 'data'
|
||||||
|
if(['export','cancel'].includes(key))return state.page==='history'?'history':'export'
|
||||||
|
if(key==='refresh-seals'||key==='seal')return state.page==='seals'?'seals':state.page==='settings'?'settings':'preview'
|
||||||
|
if(key==='folder')return state.page==='seals'?'seals':state.taskTargets.get(key)==='output'?'export':'preview'
|
||||||
|
return state.page==='workbench'?'export':state.page
|
||||||
|
}
|
||||||
|
let previewVersion=0,recordsVersion=0,sealsVersion=0
|
||||||
|
async function fetchRecords(){
|
||||||
|
const version=++recordsVersion,boot=await window.receipts.bootstrap()
|
||||||
|
if(version===recordsVersion){state.batches=boot.batches;state.historyTotal=boot.historyTotal??boot.batches.length;state.historyFilters=boot.historyFilters||{schools:[],grades:[],items:[]};state.nextNumber=boot.nextNumber}
|
||||||
|
}
|
||||||
|
async function fetchSeals():Promise<boolean>{
|
||||||
|
const version=++sealsVersion
|
||||||
|
try{
|
||||||
|
const list=await window.receipts.listSeals()
|
||||||
|
if(version===sealsVersion){state.seals=list.images;state.sealWarnings=list.warnings}
|
||||||
|
return true
|
||||||
|
}catch(error){
|
||||||
|
if(version===sealsVersion){state.seals=[];state.sealWarnings=[conciseError(error)]}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const actions={
|
||||||
|
reportError(scope:string,message:string){if(scope==='init')state.error=message;else state.panelErrors.set(scope,message)},
|
||||||
|
clearError(scope:string){state.panelErrors.delete(scope)},
|
||||||
|
async task<T>(key:string,work:()=>Promise<T>,options:TaskFeedback={}):Promise<T|null>{
|
||||||
|
if(state.busy.has(key))return null
|
||||||
|
if(options.target!==undefined)state.taskTargets.set(key,options.target);else state.taskTargets.delete(key)
|
||||||
|
const scope=options.scope||taskPanel(key);state.taskPanels.set(key,scope);state.panelErrors.delete(scope)
|
||||||
|
state.busy.add(key);if(key==='init')state.error=''
|
||||||
|
if(!quietTasks.has(key)&&(!processingTasks.has(key)||!window.receipts.onProcessing))startLoading(key,options.feedback===false?0:500)
|
||||||
|
let result:T|null=null,errorMessage=''
|
||||||
|
try{result=await work()}
|
||||||
|
catch(error){errorMessage=conciseError(error)}
|
||||||
|
finally{
|
||||||
|
await loadingMinimums.get(key)
|
||||||
|
loadingMinimums.delete(key);state.loadingTasks.delete(key);state.busy.delete(key)
|
||||||
|
if(errorMessage)actions.reportError(scope,errorMessage)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
async init(){
|
||||||
|
if(!window.receipts){state.error='请使用桌面程序打开收据工坊';return}
|
||||||
|
window.receipts.onProcessing?.(key=>{if(state.busy.has(key))startLoading(key)})
|
||||||
|
window.receipts.onProgress(p=>{
|
||||||
|
state.progress=p;state.exporting=!p.finished;if(p.finished)state.cancelRequested=false
|
||||||
|
if(p.finished){
|
||||||
|
const failed=p.status==='failed'||p.status==='partial'
|
||||||
|
if(failed)actions.reportError(state.exportPanel,conciseError(p.errors.slice(0,3).join(';')||p.stage))
|
||||||
|
void actions.refreshRecords()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await actions.task('init',async()=>{await actions.reload();state.ready=true;await actions.loadSeals();await actions.refreshPreview()},{feedback:false,scope:'init'})
|
||||||
|
},
|
||||||
|
async reload(){
|
||||||
|
const boot=await window.receipts.bootstrap()
|
||||||
|
state.settings=boot.settings;state.batches=boot.batches;state.historyTotal=boot.historyTotal??boot.batches.length;state.historyFilters=boot.historyFilters||{schools:[],grades:[],items:[]};state.nextNumber=boot.nextNumber
|
||||||
|
state.dataDirectory=boot.dataDirectory;state.version=boot.version;state.templates=boot.templates
|
||||||
|
state.currentSealId=boot.settings.defaultSealId
|
||||||
|
},
|
||||||
|
async refreshRecords(feedback=false){
|
||||||
|
if(!feedback)return fetchRecords()
|
||||||
|
await actions.task('refresh-records',async()=>{await fetchRecords();return true})
|
||||||
|
},
|
||||||
|
async loadSeals(feedback=false){
|
||||||
|
if(!feedback)return fetchSeals()
|
||||||
|
return actions.task('refresh-seals',async()=>{
|
||||||
|
if(!await fetchSeals())throw Error(state.sealWarnings[0]||'印章列表刷新失败')
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async importExcel(){
|
||||||
|
await actions.task('import',async()=>{
|
||||||
|
const draft=await window.receipts.importExcel();if(!draft)return false
|
||||||
|
state.draft=draft;state.selected=new Set(draft.rows.filter(r=>!r.errors.length).map(r=>r.index))
|
||||||
|
state.rowIndex=draft.rows.find(r=>!r.errors.length)?.index??0;state.page='workbench';state.progress=null
|
||||||
|
await actions.refreshPreview();return true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async template(){
|
||||||
|
let saved=''
|
||||||
|
await actions.task('template',async()=>{saved=await window.receipts.saveExcelTemplate()||'';return !!saved})
|
||||||
|
},
|
||||||
|
async refreshPreview(showLoading=true){
|
||||||
|
const version=++previewVersion
|
||||||
|
const row=state.draft?.rows.find(r=>r.index===state.rowIndex)
|
||||||
|
if(row?.errors.length){state.previewHtml='';state.previewLoading=false;return}
|
||||||
|
if(state.draft&&state.previewLayout==='print'&&!state.selected.size){state.previewHtml='';state.previewLoading=false;return}
|
||||||
|
state.previewLoading=showLoading
|
||||||
|
try{
|
||||||
|
const html=await window.receipts.preview({draftId:state.draft?.id,rowIndex:state.rowIndex,rowIndices:state.previewLayout==='print'?[...state.selected]:undefined,sealId:state.currentSealId,layout:state.previewLayout})
|
||||||
|
if(version===previewVersion)state.previewHtml=html
|
||||||
|
}catch(e){if(version===previewVersion){state.previewHtml='';actions.reportError(state.page==='settings'?'settings':'preview',conciseError(e))}}
|
||||||
|
finally{if(version===previewVersion)state.previewLoading=false}
|
||||||
|
},
|
||||||
|
async selectSeal(id:string){
|
||||||
|
await actions.task('seal',async()=>{
|
||||||
|
state.settings=await window.receipts.saveSettings({...state.settings,defaultSealId:id})
|
||||||
|
state.currentSealId=id;await actions.refreshPreview();return true
|
||||||
|
},{target:id})
|
||||||
|
},
|
||||||
|
async chooseAndSaveFolder(kind:'output'|'seal'){
|
||||||
|
await actions.task('folder',async()=>{
|
||||||
|
const folder=await window.receipts.chooseFolder(kind);if(!folder)return false
|
||||||
|
state.settings=await window.receipts.saveSettings({...state.settings,[kind==='seal'?'sealDirectory':'outputDirectory']:folder,...(kind==='seal'?{defaultSealId:''}:{})})
|
||||||
|
if(kind==='seal'){state.currentSealId='';await actions.loadSeals();await actions.refreshPreview(false)}
|
||||||
|
return true
|
||||||
|
},{target:kind})
|
||||||
|
},
|
||||||
|
async saveSettings(settings:AppSettings){
|
||||||
|
return actions.task('settings',async()=>{
|
||||||
|
state.settings=await window.receipts.saveSettings(settings);state.currentSealId=state.settings.defaultSealId
|
||||||
|
await actions.loadSeals();await actions.refreshRecords();await actions.refreshPreview();return true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async export(batchId?:string){
|
||||||
|
if(state.exporting||state.busy.has('delete-batch'))return
|
||||||
|
if(!batchId&&(!state.draft||!state.selected.size)){actions.reportError('export','请先导入 Excel 并选择有效数据');return}
|
||||||
|
if(!state.settings.exportPdf&&!state.settings.exportPng&&!state.settings.exportPrint){actions.reportError(state.page==='history'?'history':'export','请至少选择一种导出内容');return}
|
||||||
|
await actions.task('export',async()=>{
|
||||||
|
state.exporting=true;state.cancelRequested=false;state.exportBatchId=batchId||'';state.exportPanel=state.page==='history'?'history':'export';state.progress=null
|
||||||
|
try{
|
||||||
|
state.settings=await window.receipts.saveSettings({...state.settings})
|
||||||
|
await window.receipts.exportReceipts({batchId,draftId:state.draft?.id,rowIndices:[...state.selected],sealId:state.currentSealId,
|
||||||
|
options:{pdf:state.settings.exportPdf,png:state.settings.exportPng,print:state.settings.exportPrint,receiptsPerPage:state.settings.receiptsPerPage,cutLine:state.settings.cutLine}})
|
||||||
|
}catch(e){state.exporting=false;throw e}
|
||||||
|
},{target:batchId||''})
|
||||||
|
},
|
||||||
|
async deleteBatch(id:string){
|
||||||
|
if(state.exporting)return
|
||||||
|
await actions.task('delete-batch',async()=>{
|
||||||
|
if(!window.receipts.deleteBatch)throw Error('请关闭程序并重新启动本地调试,以启用删除记录功能')
|
||||||
|
if(!await window.receipts.deleteBatch(id))return false
|
||||||
|
if(state.draft?.previousBatchId===id)state.draft.previousBatchId=undefined
|
||||||
|
if(state.progress?.batchId===id)state.progress=null
|
||||||
|
await actions.refreshRecords();return true
|
||||||
|
},{target:id})
|
||||||
|
},
|
||||||
|
async open(folder:string){await actions.task('open',async()=>{await window.receipts.openFolder(folder);return true},{target:folder})},
|
||||||
|
async cancel(){await actions.task('cancel',async()=>{await window.receipts.cancelExport();state.cancelRequested=true;return true})}
|
||||||
|
}
|
||||||
|
export function statusText(status:BatchSummary['status']):string{return {reserved:'已分配编号',running:'生成中',completed:'已完成',partial:'部分完成',failed:'失败',cancelled:'已取消'}[status]}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed,onBeforeUnmount,reactive,ref,watch } from 'vue'
|
||||||
|
import { History,RefreshCw,FolderOpen,RotateCcw,Eye,X,Trash2,ChevronLeft,ChevronRight } from '@lucide/vue'
|
||||||
|
import { state,actions,statusText } from '../state'
|
||||||
|
import { displayMoney } from '../../core/money'
|
||||||
|
import ReceiptPreview from '../components/ReceiptPreview.vue'
|
||||||
|
import ActionButton from '../components/ActionButton.vue'
|
||||||
|
import LoadingRegion from '../components/LoadingRegion.vue'
|
||||||
|
import { usePanelState } from '../loading'
|
||||||
|
import {historyPageSizes} from '../../shared/types'
|
||||||
|
import type {BatchPageQuery,BatchSummary,HistoryPageSize} from '../../shared/types'
|
||||||
|
const panel=usePanelState('history')
|
||||||
|
const query=reactive({search:'',school:'',grade:'',item:''}),html=ref(''),title=ref('')
|
||||||
|
const batches=ref<BatchSummary[]>([]),searching=ref(true),total=ref(0),page=ref(1)
|
||||||
|
const pageSize=ref<HistoryPageSize>(state.settings.historyPageSize||5)
|
||||||
|
const previewMatch=ref(false)
|
||||||
|
const hasFilters=computed(()=>Object.values(query).some(value=>value.trim()))
|
||||||
|
const pageCount=computed(()=>Math.max(1,Math.ceil(total.value/pageSize.value)))
|
||||||
|
const rangeStart=computed(()=>total.value?(page.value-1)*pageSize.value+1:0)
|
||||||
|
const rangeEnd=computed(()=>Math.min(page.value*pageSize.value,total.value))
|
||||||
|
const pageNumbers=computed(()=>{
|
||||||
|
const start=Math.max(1,Math.min(page.value-2,pageCount.value-4))
|
||||||
|
return Array.from({length:Math.min(5,pageCount.value)},(_,index)=>start+index)
|
||||||
|
})
|
||||||
|
let request=0,timer:ReturnType<typeof setTimeout>|undefined,queryFailed=false
|
||||||
|
watch(()=>[query.search,query.school,query.grade,query.item],()=>{page.value=1;schedulePage()})
|
||||||
|
watch(()=>state.batches,()=>schedulePage(),{immediate:true})
|
||||||
|
function schedulePage(animated=false,persistSize=false){
|
||||||
|
clearTimeout(timer)
|
||||||
|
const version=++request
|
||||||
|
searching.value=true
|
||||||
|
const filters={...query,page:page.value,pageSize:pageSize.value}
|
||||||
|
if(animated){
|
||||||
|
const settings={...state.settings,historyPageSize:pageSize.value}
|
||||||
|
void actions.task('history-page',async()=>{
|
||||||
|
await Promise.all([findBatches(version,filters),persistSize?window.receipts.saveSettings(settings):Promise.resolve()])
|
||||||
|
return true
|
||||||
|
},{scope:'history'})
|
||||||
|
}else timer=setTimeout(()=>{void findBatches(version,filters)},hasFilters.value?180:0)
|
||||||
|
}
|
||||||
|
async function findBatches(version:number,filters:BatchPageQuery){
|
||||||
|
try{
|
||||||
|
const result=await window.receipts.listBatchPage(filters)
|
||||||
|
if(version===request){batches.value=result.items;total.value=result.total;page.value=result.page;pageSize.value=result.pageSize;if(queryFailed)actions.clearError('history');queryFailed=false}
|
||||||
|
}catch(error){if(version===request){batches.value=[];total.value=0;page.value=1;queryFailed=true;actions.reportError('history',error instanceof Error?error.message:'加载生成记录失败')}}
|
||||||
|
finally{if(version===request)searching.value=false}
|
||||||
|
}
|
||||||
|
function changePage(next:number){
|
||||||
|
if(searching.value||panel.value.loading||next<1||next>pageCount.value||next===page.value)return
|
||||||
|
page.value=next;schedulePage(true)
|
||||||
|
}
|
||||||
|
function changePageSize(){
|
||||||
|
page.value=1;state.settings.historyPageSize=pageSize.value;schedulePage(true,true)
|
||||||
|
}
|
||||||
|
function clearFilters(){query.search='';query.school='';query.grade='';query.item=''}
|
||||||
|
function category(batch:BatchSummary,field:'school'|'grade'|'item',full=false){
|
||||||
|
const labels=[...new Set((batch.groups||[]).map(group=>group[field]))]
|
||||||
|
if(!labels.length)return '未分类'
|
||||||
|
return full?labels.join('、'):labels.slice(0,2).join('、')+(labels.length>2?' 等 '+labels.length+' 项':'')
|
||||||
|
}
|
||||||
|
onBeforeUnmount(()=>{clearTimeout(timer);request++})
|
||||||
|
async function preview(batch:BatchSummary){await actions.task('history-preview',async()=>{html.value=await window.receipts.preview({batchId:batch.id,receiptNumber:batch.match?.number,layout:'single'});title.value=batch.match?batch.match.customer+' · '+batch.match.number:batch.sourceName;previewMatch.value=!!batch.match;return true},{target:batch.id})}
|
||||||
|
function date(v:string){return new Date(v).toLocaleString('zh-CN',{hour12:false})}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<LoadingRegion as="section" class="panel" data-region="history" :loading="panel.loading" :label="panel.label" :progress="panel.progress" :error="panel.error" @dismiss-error="actions.clearError('history')"><div class="panel-heading"><div><h2>最近生成的批次 <span class="badge">{{total}}</span></h2><p>重新导出沿用原编号、原数据和原印章,使用当前导出选项。</p></div><ActionButton class="button subtle" task="refresh-records" @click="actions.refreshRecords(true)"><template #icon><RefreshCw :size="16"/></template>刷新</ActionButton></div>
|
||||||
|
<div v-if="!total&&!hasFilters&&!searching" class="large-empty"><span class="empty-circle"><History :size="34" :stroke-width="1.4"/></span><h3>每一批收据,都有迹可循</h3><p>完成首次生成后,可在这里查看记录和重新导出。</p><button class="button primary" @click="state.page='workbench'">前往工作台</button></div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="history-filters"><label>学校<select class="input" aria-label="按学校查找记录" v-model="query.school"><option value="">全部学校</option><option v-for="school in state.historyFilters.schools" :key="school">{{school}}</option></select></label><label>年级<select class="input" aria-label="按年级查找记录" v-model="query.grade"><option value="">全部年级</option><option v-for="grade in state.historyFilters.grades" :key="grade">{{grade}}</option></select></label><label>收费项目<select class="input" aria-label="按收费项目查找记录" v-model="query.item"><option value="">全部收费项目</option><option v-for="item in state.historyFilters.items" :key="item">{{item}}</option></select></label></div>
|
||||||
|
<div class="history-search"><input v-model="query.search" class="input" maxlength="200" placeholder="输入文件名、任意收据编号或学生姓名" aria-label="搜索生成记录"><button v-if="hasFilters" class="text-button" @click="clearFilters">清空筛选</button><span aria-live="polite">{{searching?'正在加载记录…':'共 '+total+' 个批次'}}</span></div>
|
||||||
|
<p v-if="hasFilters" class="history-filter-note">按收据内容查找,下面显示匹配收据所在的完整批次;重新导出会导出该批全部收据。</p>
|
||||||
|
<div class="history-table-wrap"><table class="data-table history-table"><thead><tr><th>生成批次</th><th>学校 / 年级 / 收费项目</th><th>编号范围</th><th>张数 / 金额</th><th>状态</th><th>操作</th></tr></thead><TransitionGroup tag="tbody" name="history-row"><tr v-for="batch in batches" :key="batch.id"><td class="history-source"><strong>{{batch.sourceName}}</strong><span class="table-secondary">{{date(batch.createdAt)}}</span><span v-if="batch.match" class="history-match">匹配 {{batch.match.count}} 张:{{batch.match.customer}} · {{batch.match.number}}</span></td><td class="history-category"><strong :title="category(batch,'school',true)">{{category(batch,'school')}}</strong><span class="table-secondary" :title="category(batch,'grade',true)">{{category(batch,'grade')}}</span><span class="table-secondary" :title="category(batch,'item',true)">{{category(batch,'item')}}</span></td><td class="numeric">{{batch.firstNumber}}<span class="table-secondary">至 {{batch.lastNumber}}</span></td><td>{{batch.count}} 张<span class="table-secondary">¥{{displayMoney(batch.totalCents)}}</span></td><td><span :class="['state-tag',batch.status==='completed'?'ok':batch.status==='failed'?'danger':'warning']">{{statusText(batch.status)}}</span><span v-if="batch.message&&batch.status!=='completed'" class="table-secondary history-message" :title="batch.message">{{batch.message}}</span></td><td><div class="button-row">
|
||||||
|
<ActionButton class="icon-button" task="history-preview" :target="batch.id" icon-only title="查看原收据" aria-label="查看原收据" :disabled="state.busy.has('history-preview')" @click="preview(batch)"><template #icon><Eye :size="16"/></template></ActionButton>
|
||||||
|
<ActionButton class="icon-button" task="open" :target="batch.outputDirectory" icon-only title="打开输出目录" aria-label="打开输出目录" :disabled="!batch.outputDirectory" @click="actions.open(batch.outputDirectory)"><template #icon><FolderOpen :size="16"/></template></ActionButton>
|
||||||
|
<ActionButton class="button subtle compact" task="export" :target="batch.id" :loading="state.exporting&&state.exportBatchId===batch.id" :disabled="state.exporting||state.busy.has('delete-batch')" @click="actions.export(batch.id)"><template #icon><RotateCcw :size="14"/></template>重新导出</ActionButton>
|
||||||
|
<ActionButton class="button danger compact" task="delete-batch" :target="batch.id" :disabled="state.exporting||state.busy.has('delete-batch')||batch.status==='running'||batch.status==='reserved'" @click="actions.deleteBatch(batch.id)"><template #icon><Trash2 :size="14"/></template>删除</ActionButton>
|
||||||
|
</div></td></tr></TransitionGroup></table><div v-if="!batches.length" class="history-no-results">{{searching?'正在加载记录…':'没有符合条件的批次,可更换条件或清空筛选。'}}</div></div>
|
||||||
|
<div class="history-pagination">
|
||||||
|
<span class="history-range" aria-live="polite">共 {{total}} 条,显示第 {{rangeStart}}–{{rangeEnd}} 条</span>
|
||||||
|
<label class="history-page-size">每页条数<select class="input" aria-label="生成记录每页条数" v-model.number="pageSize" :disabled="searching||panel.loading" @change="changePageSize"><option v-for="size in historyPageSizes" :key="size" :value="size">{{size}} 条/页</option></select></label>
|
||||||
|
<nav class="history-page-controls" aria-label="生成记录分页">
|
||||||
|
<button class="history-page-button" type="button" aria-label="生成记录第一页" :disabled="searching||panel.loading||page<=1" @click="changePage(1)">首页</button>
|
||||||
|
<button class="history-page-button" type="button" aria-label="生成记录上一页" :disabled="searching||panel.loading||page<=1" @click="changePage(page-1)"><ChevronLeft :size="15"/></button>
|
||||||
|
<button v-for="number in pageNumbers" :key="number" class="history-page-button" :class="{active:page===number}" type="button" :aria-label="'生成记录第 '+number+' 页'" :aria-current="page===number?'page':undefined" :disabled="searching||panel.loading" @click="changePage(number)">{{number}}</button>
|
||||||
|
<button class="history-page-button" type="button" aria-label="生成记录下一页" :disabled="searching||panel.loading||page>=pageCount" @click="changePage(page+1)"><ChevronRight :size="15"/></button>
|
||||||
|
<button class="history-page-button" type="button" aria-label="生成记录末页" :disabled="searching||panel.loading||page>=pageCount" @click="changePage(pageCount)">末页</button>
|
||||||
|
<span class="history-page-position">第 {{page}} / {{pageCount}} 页</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #loading-actions><ActionButton v-if="state.exporting" class="button" task="cancel" :loading="state.cancelRequested||state.busy.has('cancel')" @click="actions.cancel()">{{state.cancelRequested?'正在取消…':'取消生成'}}</ActionButton></template>
|
||||||
|
</LoadingRegion>
|
||||||
|
<div v-if="html" class="modal-backdrop" @click.self="html=''"><section class="modal-card"><div class="panel-heading"><div><h2>原收据预览</h2><p>{{title}}</p></div><button class="icon-button" aria-label="关闭原收据预览" @click="html=''"><X :size="19"/></button></div><ReceiptPreview :html="html"/><div class="modal-footer">{{previewMatch?'显示符合查找条件的第一张原收据':'显示本批第一张收据'}},沿用正式编号。</div></section></div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { FolderOpen,RefreshCw,Check,Stamp,Image } from '@lucide/vue'
|
||||||
|
import { state,actions } from '../state'
|
||||||
|
import ActionButton from '../components/ActionButton.vue'
|
||||||
|
import LoadingRegion from '../components/LoadingRegion.vue'
|
||||||
|
import { usePanelState } from '../loading'
|
||||||
|
const panel=usePanelState('seals')
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<LoadingRegion as="section" class="panel" data-region="seals" :loading="panel.loading" :label="panel.label" :error="panel.error" @dismiss-error="actions.clearError('seals')">
|
||||||
|
<div class="panel-heading"><div><h2>我的印章 <span class="badge">{{state.seals.length}}</span></h2><p>支持 PNG、JPG 和 WebP,透明背景 PNG 的盖章效果更好。</p></div><div class="button-row">
|
||||||
|
<ActionButton class="button subtle" task="refresh-seals" @click="actions.loadSeals(true)"><template #icon><RefreshCw :size="16"/></template>刷新列表</ActionButton>
|
||||||
|
<ActionButton class="button primary" task="folder" target="seal" @click="actions.chooseAndSaveFolder('seal')" :disabled="state.exporting"><template #icon><FolderOpen :size="16"/></template>{{state.settings.sealDirectory?'更换文件夹':'绑定印章文件夹'}}</ActionButton>
|
||||||
|
</div></div>
|
||||||
|
<div v-if="state.settings.sealDirectory" class="folder-banner"><FolderOpen :size="18"/><span :title="state.settings.sealDirectory">{{state.settings.sealDirectory}}</span><ActionButton class="text-button" task="open" :target="state.settings.sealDirectory" @click="actions.open(state.settings.sealDirectory)">打开文件夹</ActionButton></div>
|
||||||
|
<div v-for="warning in state.sealWarnings" :key="warning" class="inline-note warning">{{warning}}</div>
|
||||||
|
<div v-if="!state.seals.length" class="large-empty"><span class="empty-circle"><Stamp :size="35" :stroke-width="1.4"/></span><h3>{{state.settings.sealDirectory?'文件夹里还没有可用印章':'让常用印章随手可选'}}</h3><p>{{state.settings.sealDirectory?'将印章图片放入已绑定的文件夹,然后刷新列表。':'选择保存印章图片的本地文件夹,程序会记住它。'}}</p><ActionButton class="button primary" :task="state.settings.sealDirectory?'open':'folder'" :target="state.settings.sealDirectory||'seal'" :disabled="state.exporting" @click="state.settings.sealDirectory?actions.open(state.settings.sealDirectory):actions.chooseAndSaveFolder('seal')"><template #icon><FolderOpen :size="16"/></template>{{state.settings.sealDirectory?'打开印章文件夹':'选择印章文件夹'}}</ActionButton></div>
|
||||||
|
<div v-else class="seal-grid"><button v-for="seal in state.seals" :key="seal.id" :class="['seal-card',{selected:state.currentSealId===seal.id}]" :disabled="state.exporting||state.busy.has('seal')" :aria-busy="state.busy.has('seal')&&state.taskTargets.get('seal')===seal.id||undefined" @click="actions.selectSeal(seal.id)"><div class="seal-image-stage"><img :src="seal.dataUrl" :alt="seal.name"><span v-if="state.currentSealId===seal.id" class="seal-check"><Check :size="15"/></span></div><div class="seal-card-label"><Image :size="15"/><strong :title="seal.name">{{seal.name}}</strong></div><small>{{state.busy.has('seal')&&state.taskTargets.get('seal')===seal.id?'正在切换印章…':state.currentSealId===seal.id?'当前使用 · 下次自动记住':'点击设为当前印章'}}</small></button></div>
|
||||||
|
</LoadingRegion>
|
||||||
|
<div class="helper-note"><Stamp :size="17"/><span>印章位置和大小可在偏好设置中调整。生成后,程序会保留当时的印章快照,方便按原样补打。</span><button class="text-button" @click="state.page='settings'">调整盖章位置</button></div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed,reactive,watch } from 'vue'
|
||||||
|
import { FolderOpen,Save,FolderHeart,Printer,Stamp,Hash } from '@lucide/vue'
|
||||||
|
import { state,actions } from '../state'
|
||||||
|
import ActionButton from '../components/ActionButton.vue'
|
||||||
|
import LoadingRegion from '../components/LoadingRegion.vue'
|
||||||
|
import { usePanelState } from '../loading'
|
||||||
|
import {receiptFileStem} from '../../shared/file-names'
|
||||||
|
const panel=usePanelState('settings')
|
||||||
|
const form=reactive({...state.settings})
|
||||||
|
watch(()=>state.settings,value=>Object.assign(form,value))
|
||||||
|
const dirty=computed(()=>JSON.stringify(form)!==JSON.stringify(state.settings))
|
||||||
|
const filenameExample=computed(()=>{
|
||||||
|
const row=state.draft?.rows.find(row=>row.index===state.rowIndex&&!row.errors.length)||state.draft?.rows.find(row=>!row.errors.length)
|
||||||
|
const number=form.numberPrefix===state.settings.numberPrefix?state.nextNumber:form.numberPrefix+'00000001'
|
||||||
|
return receiptFileStem(number||'SJ00000001',row?.receipt.customer||'巴南中学 高2028届 张三',form.fileNamePattern)+(form.exportPng&&!form.exportPdf?'.png':'.pdf')
|
||||||
|
})
|
||||||
|
watch(dirty,value=>{state.settingsDirty=value},{immediate:true})
|
||||||
|
async function choose(kind:'output'|'seal'){
|
||||||
|
await actions.task('settings-folder',async()=>{const folder=await window.receipts.chooseFolder(kind);if(!folder)return false;if(kind==='output')form.outputDirectory=folder;else{form.sealDirectory=folder;form.defaultSealId=''};return true},{target:kind})
|
||||||
|
}
|
||||||
|
async function save(){await actions.saveSettings({...form})}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<LoadingRegion as="form" class="settings-layout" data-region="settings" :loading="panel.loading" :label="panel.label" :error="panel.error" @dismiss-error="actions.clearError('settings')" @submit.prevent="save">
|
||||||
|
<section class="panel settings-panel"><div class="settings-section-heading"><span class="setting-icon"><FolderHeart :size="21"/></span><div><h2>文件与常用选择</h2><p>设置一次,以后自动记住。</p></div></div>
|
||||||
|
<div class="form-field"><label for="output-directory">默认导出文件夹</label><div class="path-input"><input id="output-directory" :value="form.outputDirectory" readonly placeholder="选择生成文件的存放位置"><ActionButton class="button subtle compact" task="settings-folder" target="output" :disabled="state.busy.has('settings-folder')" @click="choose('output')"><template #icon><FolderOpen :size="15"/></template>选择</ActionButton></div><small>批次内按“学校 → 年级 → 收费项目”分层保存收据和打印版。</small></div>
|
||||||
|
<div class="form-field"><label for="seal-directory">印章文件夹</label><div class="path-input"><input id="seal-directory" :value="form.sealDirectory" readonly placeholder="选择本地印章图片文件夹"><ActionButton class="button subtle compact" task="settings-folder" target="seal" :disabled="state.busy.has('settings-folder')" @click="choose('seal')"><template #icon><FolderOpen :size="15"/></template>选择</ActionButton></div><small>启动时读取 PNG、JPG 和 WebP 图片,列表显示文件名与缩略图。</small></div>
|
||||||
|
<div class="form-field"><label for="filename-pattern">导出的文件叫什么名字</label><select id="filename-pattern" class="input" v-model="form.fileNamePattern"><option value="number_customer">编号在前(编号_学校 年级 姓名)</option><option value="customer_number">姓名信息在前(学校 年级 姓名_编号)</option><option value="number">只用收据编号</option></select><div class="filename-example" aria-live="polite"><span>文件名示例</span><strong>{{filenameExample}}</strong></div><small>只影响单张 PDF、图片的文件名。每次生成新建批次文件夹,成功后自动打开。</small></div>
|
||||||
|
<div class="form-two-columns"><div class="form-field"><label for="cashier">默认出纳</label><input id="cashier" class="input" v-model="form.defaultCashier" maxlength="20" placeholder="可留空"></div><div class="form-field"><label for="reviewer">默认复核人</label><input id="reviewer" class="input" v-model="form.defaultReviewer" maxlength="20" placeholder="可留空"></div></div><p class="field-note">Excel 中填写的姓名优先;留空时使用这里的默认值。</p>
|
||||||
|
</section>
|
||||||
|
<section class="panel settings-panel"><div class="settings-section-heading"><span class="setting-icon"><Printer :size="21"/></span><div><h2>收据与打印</h2><p>单张分享、批量打印,各有合适的格式。</p></div></div>
|
||||||
|
<div class="form-field"><label for="receipt-template">收据版式</label><select id="receipt-template" class="input" v-model="form.templateId"><option v-for="template in state.templates" :key="template.id" :value="template.id">{{template.name}}</option></select></div>
|
||||||
|
<div class="form-field"><label>默认导出内容</label><div class="checkbox-row"><label><input type="checkbox" v-model="form.exportPdf">单张 PDF</label><label><input type="checkbox" v-model="form.exportPng">单张 PNG</label><label><input type="checkbox" v-model="form.exportPrint">A4 打印版</label></div></div>
|
||||||
|
<div class="form-two-columns"><div class="form-field"><label for="receipts-per-page">A4 每页收据数</label><select id="receipts-per-page" class="input" v-model="form.receiptsPerPage"><option :value="2">上下 2 张</option><option :value="1">居中 1 张</option></select></div><div class="form-field"><label for="png-dpi">图片清晰度</label><select id="png-dpi" class="input" v-model="form.pngDpi"><option :value="300">300 DPI · 适合打印</option><option :value="150">150 DPI · 文件更小</option></select></div></div>
|
||||||
|
<label class="toggle-line"><span>两张收据之间显示裁切线</span><input type="checkbox" v-model="form.cutLine" role="switch"></label>
|
||||||
|
</section>
|
||||||
|
<section class="panel settings-panel"><div class="settings-section-heading"><span class="setting-icon"><Stamp :size="21"/></span><div><h2>印章位置与大小</h2><p>相对标准位置微调,保存后在工作台预览。</p></div></div>
|
||||||
|
<div class="form-field"><label for="seal-width">印章宽度 <span>{{form.sealWidthMm}} mm</span></label><input id="seal-width" type="range" min="15" max="55" step="1" v-model.number="form.sealWidthMm"><small>图片按原比例缩放,默认宽度 35 毫米。</small></div>
|
||||||
|
<div class="form-two-columns"><div class="form-field"><label for="seal-x">水平偏移 / mm</label><input id="seal-x" type="number" class="input" min="-20" max="20" v-model.number="form.sealOffsetX"><small>正数向右,负数向左。</small></div><div class="form-field"><label for="seal-y">垂直偏移 / mm</label><input id="seal-y" type="number" class="input" min="-12" max="12" v-model.number="form.sealOffsetY"><small>正数向下,负数向上。</small></div></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel settings-panel"><div class="settings-section-heading"><span class="setting-icon"><Hash :size="21"/></span><div><h2>自动编号</h2><p>跨批次连续递增,退出后保留进度。</p></div></div>
|
||||||
|
<div class="form-field"><label for="number-prefix">编号前缀</label><input id="number-prefix" class="input" v-model="form.numberPrefix" maxlength="16" pattern="[A-Za-z0-9_-]{1,16}" required><small>支持字母、数字、短横线和下划线,例如 SJ-A。</small></div>
|
||||||
|
<div class="number-preview"><span>当前前缀的下一个编号</span><strong>{{form.numberPrefix===state.settings.numberPrefix?state.nextNumber:'保存后显示新前缀的流水号'}}</strong></div>
|
||||||
|
<div class="inline-note">预览不占号;重新导出原批次沿用原编号。流水号由系统管理,保留生成记录即可安全补打。</div><p class="field-note">同一单位使用多台电脑时,请分别设置不同前缀。</p>
|
||||||
|
</section>
|
||||||
|
<div class="settings-save-bar"><span>{{dirty?'有尚未保存的更改':'设置已与本地同步'}}</span><ActionButton type="submit" class="button primary" task="settings" :disabled="state.exporting"><template #icon><Save :size="17"/></template>保存偏好设置</ActionButton></div>
|
||||||
|
</LoadingRegion>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed,ref } from 'vue'
|
||||||
|
import { Upload,Download,FileSpreadsheet,ArrowRight,Printer,FolderOpen,RefreshCw,Check,ChevronLeft,ChevronRight,LoaderCircle,Settings2,Stamp,AlertCircle,Search } from '@lucide/vue'
|
||||||
|
import { state,actions } from '../state'
|
||||||
|
import { displayMoney } from '../../core/money'
|
||||||
|
import ReceiptPreview from '../components/ReceiptPreview.vue'
|
||||||
|
import ActionButton from '../components/ActionButton.vue'
|
||||||
|
import LoadingRegion from '../components/LoadingRegion.vue'
|
||||||
|
import ReceiptGroupTree from '../components/ReceiptGroupTree.vue'
|
||||||
|
import {importGroupTree,summarizeReceiptGroups} from '../../shared/receipt-groups'
|
||||||
|
import { usePanelState } from '../loading'
|
||||||
|
const dataPanel=usePanelState('data'),previewPanel=usePanelState('preview'),exportPanel=usePanelState('export')
|
||||||
|
const search=ref(''),filter=ref('all'),page=ref(0)
|
||||||
|
const rows=computed(()=>state.draft?.rows??[])
|
||||||
|
const invalid=computed(()=>rows.value.filter(r=>r.errors.length).length)
|
||||||
|
const warnings=computed(()=>rows.value.filter(r=>r.warnings.length).length)
|
||||||
|
const selectedRows=computed(()=>rows.value.filter(r=>state.selected.has(r.index)))
|
||||||
|
const total=computed(()=>selectedRows.value.reduce((s,r)=>s+r.receipt.amountCents,0))
|
||||||
|
const filtered=computed(()=>rows.value.filter(r=>(filter.value!=='errors'||r.errors.length||r.warnings.length)&&(!search.value||[r.receipt.customer,r.receipt.item,r.receipt.date].some(v=>v.includes(search.value)))))
|
||||||
|
const pages=computed(()=>Math.max(1,Math.ceil(filtered.value.length/50)))
|
||||||
|
const visible=computed(()=>filtered.value.slice(Math.min(page.value,pages.value-1)*50,(Math.min(page.value,pages.value-1)+1)*50))
|
||||||
|
const allSelected=computed(()=>rows.value.some(r=>!r.errors.length)&&rows.value.filter(r=>!r.errors.length).every(r=>state.selected.has(r.index)))
|
||||||
|
const groupTree=computed(()=>importGroupTree(rows.value,state.selected))
|
||||||
|
const groupCount=computed(()=>summarizeReceiptGroups(rows.value.filter(row=>!row.errors.length).map(row=>row.receipt)).length)
|
||||||
|
const selectedSeal=computed(()=>state.seals.find(s=>s.id===state.currentSealId))
|
||||||
|
function toggle(index:number){if(state.exporting)return;if(state.selected.has(index))state.selected.delete(index);else state.selected.add(index);if(state.previewLayout==='print')void actions.refreshPreview()}
|
||||||
|
function toggleAll(){if(state.exporting)return;if(allSelected.value)state.selected.clear();else state.selected=new Set(rows.value.filter(r=>!r.errors.length).map(r=>r.index));if(state.previewLayout==='print')void actions.refreshPreview()}
|
||||||
|
function toggleGroup(indices:number[]){
|
||||||
|
if(state.exporting||!indices.length)return
|
||||||
|
const deselect=indices.every(index=>state.selected.has(index))
|
||||||
|
for(const index of indices){if(deselect)state.selected.delete(index);else state.selected.add(index)}
|
||||||
|
const index=indices.find(index=>state.selected.has(index))??rows.value.find(row=>state.selected.has(row.index))?.index
|
||||||
|
if(index!==undefined)focus(index)
|
||||||
|
else if(state.previewLayout==='print')void actions.refreshPreview()
|
||||||
|
}
|
||||||
|
function focus(index:number){state.rowIndex=index;void actions.refreshPreview()}
|
||||||
|
function layout(value:'single'|'print'){state.previewLayout=value;void actions.refreshPreview()}
|
||||||
|
const percent=computed(()=>state.progress?Math.round(state.progress.completed/Math.max(1,state.progress.total)*100):0)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card"><span class="stat-icon green"><FileSpreadsheet :size="21"/></span><div><span class="stat-label">待生成收据</span><strong>{{state.selected.size}}<small> 张</small></strong></div><span class="stat-sub">{{rows.length?'共导入 '+rows.length+' 条':'从 Excel 开始'}}</span></div>
|
||||||
|
<div class="stat-card"><span class="stat-icon amber">¥</span><div><span class="stat-label">本批合计金额</span><strong class="money-stat">{{displayMoney(total)}}</strong></div><span class="stat-sub">人民币 / 元</span></div>
|
||||||
|
<div class="stat-card"><span class="stat-icon blue"><Stamp :size="21"/></span><div class="stat-seal"><span class="stat-label">本批使用印章</span><strong class="seal-name">{{selectedSeal?.name||'暂未选择'}}</strong></div><button class="text-button" @click="state.page='seals'">管理 <ArrowRight :size="13"/></button></div>
|
||||||
|
</div>
|
||||||
|
<details v-if="state.draft" :key="state.draft.id" class="panel group-review" open>
|
||||||
|
<summary class="group-review-heading"><div><h2>按分组核对收据 <span class="badge">{{groupCount}} 组</span></h2><p>学校 → 年级 → 收费项目,也是导出文件夹的层级;可整组勾选。</p></div><span>{{state.selected.size}} 张已选 · ¥{{displayMoney(total)}}</span></summary>
|
||||||
|
<div class="group-review-body"><ReceiptGroupTree :nodes="groupTree" :disabled="state.exporting" :expanded="groupCount<=20" @toggle="toggleGroup"/></div>
|
||||||
|
<div class="group-review-footer"><span>金额随勾选更新,待修改的数据不会生成。</span><button class="text-button" :disabled="state.exporting||!rows.some(row=>!row.errors.length)" @click="toggleAll">{{allSelected?'取消全选':'选择全部有效数据'}}</button></div>
|
||||||
|
</details>
|
||||||
|
<div class="workspace-grid">
|
||||||
|
<LoadingRegion as="section" class="panel data-panel" data-region="data" :loading="dataPanel.loading" :label="dataPanel.label" :error="dataPanel.error" @dismiss-error="actions.clearError('data')">
|
||||||
|
<div class="panel-heading"><div><h2>收据数据 <span v-if="rows.length" class="badge">{{rows.length}}</span></h2><p>每行数据,对应一张收据</p></div><div class="button-row"><ActionButton class="button subtle compact" task="template" @click="actions.template()"><template #icon><Download :size="15"/></template>下载模板</ActionButton><ActionButton class="button primary compact" task="import" @click="actions.importExcel()" :disabled="state.exporting"><template #icon><Upload :size="15"/></template>导入 Excel</ActionButton></div></div>
|
||||||
|
<div v-if="!state.draft" class="import-empty"><div class="empty-file-icon"><FileSpreadsheet :size="36" :stroke-width="1.4"/><span class="file-tag">XLSX</span></div><h3>把表格交给收据工坊</h3><p>导入填写好的 Excel,自动校验并生成收据。<br>支持 .xlsx,每次最多 5,000 条。</p><ActionButton class="button primary" task="import" @click="actions.importExcel()"><template #icon><Upload :size="16"/></template>选择 Excel 文件</ActionButton><ActionButton class="text-button" task="template" @click="actions.template()">第一次使用?先下载填写模板 <ArrowRight :size="13"/></ActionButton><div class="import-steps"><span><i>1</i>填写表格</span><span class="step-line"></span><span><i>2</i>核对预览</span><span class="step-line"></span><span><i>3</i>一键生成</span></div></div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="file-bar"><FileSpreadsheet :size="16"/><span :title="state.draft.fileName">{{state.draft.fileName}}</span><small>已选择 {{state.selected.size}} 条</small></div>
|
||||||
|
<div v-if="state.draft.previousBatchId" class="inline-note warning"><AlertCircle :size="15"/><span>此文件已有生成记录。补打请使用原编号。</span><button class="text-button" @click="state.page='history'">查看记录</button></div>
|
||||||
|
<div class="table-toolbar"><div class="segmented"><button :class="{active:filter==='all'}" @click="filter='all';page=0">全部</button><button :class="{active:filter==='errors'}" @click="filter='errors';page=0">需核对 {{invalid+warnings}}</button></div><label class="search-input"><Search :size="14"/><input v-model="search" placeholder="搜索学校、姓名或项目" @input="page=0"></label></div>
|
||||||
|
<div class="data-table-scroll"><table class="data-table"><thead><tr><th class="check-cell"><input type="checkbox" aria-label="全选有效数据" :disabled="state.exporting||!rows.some(row=>!row.errors.length)" :checked="allSelected" @change="toggleAll"></th><th>Excel 行</th><th>客户 / 收费项目</th><th class="align-right">金额(元)</th><th>状态</th></tr></thead><tbody><tr v-for="row in visible" :key="row.index" :class="{focused:state.rowIndex===row.index,invalid:row.errors.length}" @click="focus(row.index)"><td class="check-cell"><input type="checkbox" :aria-label="'选择第 '+row.excelRow+' 行'" :checked="state.selected.has(row.index)" :disabled="!!row.errors.length||state.exporting" @click.stop @change="toggle(row.index)"></td><td class="row-number">{{row.excelRow}}</td><td><strong>{{row.receipt.customer||'未填写客户'}}</strong><span class="table-secondary">{{row.receipt.item||'未填写项目'}} · {{row.receipt.date||'日期无效'}}</span><span v-for="error in row.errors" :key="error" class="row-error">{{error}}</span><span v-for="warning in row.warnings" :key="warning" class="row-warning">{{warning}}</span></td><td class="align-right numeric">{{row.receipt.amountCents?displayMoney(row.receipt.amountCents):'—'}}</td><td><span v-if="row.errors.length" class="state-tag danger">待修改</span><span v-else-if="row.warnings.length" class="state-tag warning">请核对</span><span v-else class="valid-label"><Check :size="13"/>有效</span></td></tr><tr v-if="!visible.length"><td colspan="5" class="empty-table">没有符合条件的数据</td></tr></tbody></table></div>
|
||||||
|
<div class="table-footer"><span>{{invalid?'有 '+invalid+' 条错误,请修改 Excel 后重新导入':'数据校验通过,点击任意行预览'}}</span><div class="pagination"><button aria-label="上一页" :disabled="page===0" @click="page--"><ChevronLeft :size="16"/></button><span>{{Math.min(page+1,pages)}} / {{pages}}</span><button aria-label="下一页" :disabled="page>=pages-1" @click="page++"><ChevronRight :size="16"/></button></div></div>
|
||||||
|
</template>
|
||||||
|
</LoadingRegion>
|
||||||
|
<LoadingRegion as="section" class="panel preview-panel" data-region="preview" :loading="previewPanel.loading" :label="previewPanel.label" :delay="previewPanel.delay" :error="previewPanel.error" @dismiss-error="actions.clearError('preview')"><div class="panel-heading"><div><h2>收据预览</h2><p>{{state.draft?'点击左侧数据,核对收据内容':'示例样张 · 导入后替换为实际数据'}}</p></div><span class="preview-live"><span class="status-dot"></span>实时预览</span></div>
|
||||||
|
<div class="preview-toolbar"><div class="segmented"><button :class="{active:state.previewLayout==='single'}" @click="layout('single')">单张收据</button><button :class="{active:state.previewLayout==='print'}" @click="layout('print')">A4 打印排版</button></div><span>{{state.previewLayout==='single'?'190 × 140 mm':'A4 · 每页 '+state.settings.receiptsPerPage+' 张'}}</span></div>
|
||||||
|
<ReceiptPreview :html="state.previewHtml" :layout="state.previewLayout"/>
|
||||||
|
<div class="seal-select-row"><Stamp :size="17"/><label for="workbench-seal">本批印章</label><select id="workbench-seal" :value="state.currentSealId" :disabled="state.exporting||state.busy.has('seal')" @change="actions.selectSeal(($event.target as HTMLSelectElement).value)"><option value="">不使用印章</option><option v-if="state.currentSealId&&!selectedSeal" :value="state.currentSealId">原印章文件已丢失,请重新选择</option><option v-for="seal in state.seals" :key="seal.id" :value="seal.id">{{seal.name}}</option></select><ActionButton class="icon-button" task="refresh-seals" icon-only title="刷新印章列表" aria-label="刷新印章列表" @click="actions.loadSeals(true)"><template #icon><RefreshCw :size="15"/></template></ActionButton></div>
|
||||||
|
<ActionButton v-if="!state.settings.sealDirectory" class="bind-seal-link" task="folder" target="seal" @click="actions.chooseAndSaveFolder('seal')"><template #icon><FolderOpen :size="14"/></template>绑定印章文件夹,下次自动加载</ActionButton>
|
||||||
|
<p class="preview-footnote">预览不占用编号,正式生成后分配并保留。</p>
|
||||||
|
</LoadingRegion>
|
||||||
|
</div>
|
||||||
|
<LoadingRegion as="section" class="panel export-panel" data-region="export" :loading="exportPanel.loading" :label="exportPanel.label" :progress="exportPanel.progress" :error="exportPanel.error" @dismiss-error="actions.clearError('export')">
|
||||||
|
<div class="export-options">
|
||||||
|
<div class="export-title"><Printer :size="19"/><h2>生成与导出</h2></div>
|
||||||
|
<div class="export-format-groups">
|
||||||
|
<fieldset class="export-format-group">
|
||||||
|
<legend>单张文件</legend>
|
||||||
|
<div class="export-format-controls"><label><input type="checkbox" v-model="state.settings.exportPdf" :disabled="state.exporting">单张 PDF</label><label><input type="checkbox" v-model="state.settings.exportPng" :disabled="state.exporting">单张 PNG</label></div>
|
||||||
|
<p>每张收据独立保存,按学校、年级、收费项目分目录</p>
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class="export-format-group export-print-group">
|
||||||
|
<legend>A4 批量打印</legend>
|
||||||
|
<div class="export-format-controls">
|
||||||
|
<label><input type="checkbox" v-model="state.settings.exportPrint" :disabled="state.exporting">A4 打印版</label>
|
||||||
|
<label class="print-layout-control"><span>每页张数</span><select aria-label="打印每页张数" v-model="state.settings.receiptsPerPage" :disabled="state.exporting||!state.settings.exportPrint" @change="actions.refreshPreview()"><option :value="2">每页 2 张</option><option :value="1">每页 1 张</option></select></label>
|
||||||
|
</div>
|
||||||
|
<p>每个学校、年级、收费项目单独合并为打印 PDF</p>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="state.progress" class="progress-area"><div><span class="progress-stage">{{state.progress.stage}}</span><strong>{{percent}}%</strong></div><div class="progress-track"><div :style="{width:percent+'%'}"></div></div><div v-if="state.progress.errors.length" class="row-error">{{state.progress.errors.length}} 项未完成,可在生成记录中重试。</div></div>
|
||||||
|
<div class="export-bottom"><div class="output-location"><FolderOpen :size="17"/><div><span>保存到</span><ActionButton class="output-path-action" task="folder" target="output" :title="state.settings.outputDirectory" @click="actions.chooseAndSaveFolder('output')" :disabled="state.exporting">{{state.settings.outputDirectory||'选择导出文件夹'}}</ActionButton></div><button class="icon-button" aria-label="打开导出设置" @click="state.page='settings'"><Settings2 :size="16"/></button></div><ActionButton v-if="state.exporting" class="button" task="cancel" :loading="state.cancelRequested||state.busy.has('cancel')" @click="actions.cancel()">取消生成</ActionButton><ActionButton v-else class="button primary export-button" task="export" :disabled="!state.selected.size" @click="actions.export()"><template #icon><Printer :size="17"/></template>生成 {{state.selected.size||''}} 张收据<ArrowRight :size="16"/></ActionButton></div>
|
||||||
|
<template #loading-actions><ActionButton v-if="state.exporting" class="button" task="cancel" :loading="state.cancelRequested||state.busy.has('cancel')" @click="actions.cancel()">{{state.cancelRequested?'正在取消…':'取消生成'}}</ActionButton></template>
|
||||||
|
</LoadingRegion>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Electron loading errors can contain the entire receipt HTML and its embedded stamp.
|
||||||
|
export function conciseError(error:unknown):string{
|
||||||
|
const raw=error instanceof Error?error.message:typeof error==='string'?error:'操作失败'
|
||||||
|
const safe=raw.replace(/\bdata:[^\s"'<>]+/gi,'[页面内容已省略]')
|
||||||
|
return safe.length>400?safe.slice(0,399)+'…':safe
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type {AppSettings} from './types'
|
||||||
|
|
||||||
|
export function safeFileName(name:string):string{
|
||||||
|
const clean=name.replace(/[<>:"/\\|?*\u0000-\u001f]/g,'_').replace(/[. ]+$/,'').trim().slice(0,100)
|
||||||
|
return !clean?'收据':/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(clean)?'_'+clean:clean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function receiptFileStem(number:string,customer:string,pattern:AppSettings['fileNamePattern']):string{
|
||||||
|
return safeFileName(pattern==='number'?number:pattern==='customer_number'?customer+'_'+number:number+'_'+customer)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type {ImportRow,ReceiptInput,ReceiptGroupSummary} from './types'
|
||||||
|
|
||||||
|
export const receiptGroupLevels=[
|
||||||
|
{field:'school',fallback:'未分类学校'},
|
||||||
|
{field:'grade',fallback:'未分类年级'},
|
||||||
|
{field:'item',fallback:'未分类收费项目'}
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function receiptGroupLabels(receipt:ReceiptInput):string[]{
|
||||||
|
return receiptGroupLevels.map(level=>receipt[level.field]?.trim()||level.fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeReceiptGroups(receipts:ReceiptInput[]):ReceiptGroupSummary[]{
|
||||||
|
const groups=new Map<string,ReceiptGroupSummary>()
|
||||||
|
for(const receipt of receipts){
|
||||||
|
const [school,grade,item]=receiptGroupLabels(receipt),key=JSON.stringify([school,grade,item])
|
||||||
|
let group=groups.get(key)
|
||||||
|
if(!group){group={school,grade,item,count:0,totalCents:0};groups.set(key,group)}
|
||||||
|
group.count++;group.totalCents+=receipt.amountCents
|
||||||
|
}
|
||||||
|
return [...groups.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportGroupNode{
|
||||||
|
key:string;label:string;indices:number[];selectedCount:number;totalCents:number;selectedCents:number;invalidCount:number;children:ImportGroupNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importGroupTree(rows:ImportRow[],selected:ReadonlySet<number>):ImportGroupNode[]{
|
||||||
|
const roots:ImportGroupNode[]=[],nodes=new Map<string,ImportGroupNode>()
|
||||||
|
for(const row of rows){
|
||||||
|
const labels=receiptGroupLabels(row.receipt)
|
||||||
|
let siblings=roots
|
||||||
|
for(let depth=0;depth<labels.length;depth++){
|
||||||
|
const key=JSON.stringify(labels.slice(0,depth+1))
|
||||||
|
let node=nodes.get(key)
|
||||||
|
if(!node){node={key,label:labels[depth],indices:[],selectedCount:0,totalCents:0,selectedCents:0,invalidCount:0,children:[]};nodes.set(key,node);siblings.push(node)}
|
||||||
|
if(row.errors.length)node.invalidCount++
|
||||||
|
else{
|
||||||
|
node.indices.push(row.index);node.totalCents+=row.receipt.amountCents
|
||||||
|
if(selected.has(row.index)){node.selectedCount++;node.selectedCents+=row.receipt.amountCents}
|
||||||
|
}
|
||||||
|
siblings=node.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printPreviewRows(rows:ImportRow[],selected:ReadonlySet<number>,rowIndex:number,perPage:1|2):ImportRow[]{
|
||||||
|
const valid=rows.filter(row=>!row.errors.length&&selected.has(row.index))
|
||||||
|
const focused=valid.find(row=>row.index===rowIndex)||valid[0]
|
||||||
|
if(!focused)return []
|
||||||
|
const key=JSON.stringify(receiptGroupLabels(focused.receipt))
|
||||||
|
const group=valid.filter(row=>JSON.stringify(receiptGroupLabels(row.receipt))===key)
|
||||||
|
const start=Math.floor(group.indexOf(focused)/perPage)*perPage
|
||||||
|
return group.slice(start,start+perPage)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import type { AppSettings } from './types'
|
||||||
|
export const historyPageSizeSchema=z.union([z.literal(5),z.literal(10),z.literal(20),z.literal(50)])
|
||||||
|
export const settingsSchema = z.object({
|
||||||
|
schemaVersion: z.literal(1), outputDirectory: z.string().max(1024), sealDirectory: z.string().max(1024),
|
||||||
|
defaultSealId: z.string().max(260), exportPdf: z.boolean(), exportPng: z.boolean(), exportPrint: z.boolean(),
|
||||||
|
receiptsPerPage: z.union([z.literal(1), z.literal(2)]), cutLine: z.boolean(),
|
||||||
|
createBatchFolder: z.boolean(), openAfterExport: z.boolean(), numberPrefix: z.string().regex(/^[A-Za-z0-9_-]{1,16}$/, '编号前缀须为 1–16 位字母、数字、短横线或下划线'),
|
||||||
|
defaultCashier: z.string().max(20), defaultReviewer: z.string().max(20),
|
||||||
|
sealWidthMm: z.number().min(15).max(55), sealOffsetX: z.number().min(-20).max(20),
|
||||||
|
sealOffsetY: z.number().min(-12).max(12), pngDpi: z.union([z.literal(150), z.literal(300)]),
|
||||||
|
lastImportDirectory: z.string().max(1024), templateId: z.string().min(1).max(80), fileNamePattern:z.enum(['number_customer','customer_number','number']).default('number_customer'),historyPageSize:historyPageSizeSchema.default(5)
|
||||||
|
})
|
||||||
|
export function defaultSettings(outputDirectory = ''): AppSettings {
|
||||||
|
return { schemaVersion: 1, outputDirectory, sealDirectory: '', defaultSealId: '', exportPdf: true,
|
||||||
|
exportPng: false, exportPrint: true, receiptsPerPage: 2, cutLine: true, createBatchFolder: true,
|
||||||
|
openAfterExport: true, numberPrefix: 'SJ', defaultCashier: '', defaultReviewer: '',
|
||||||
|
sealWidthMm: 35, sealOffsetX: 0, sealOffsetY: 0, pngDpi: 300, lastImportDirectory: '', templateId: 'classic-v1', fileNamePattern:'number_customer',historyPageSize:5 }
|
||||||
|
}
|
||||||
|
export const exportOptionsSchema = z.object({ pdf:z.boolean(), png:z.boolean(), print:z.boolean(), receiptsPerPage:z.union([z.literal(1),z.literal(2)]), cutLine:z.boolean() }).refine(v=>v.pdf||v.png||v.print, '请至少选择一种导出内容')
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export interface AppSettings {
|
||||||
|
schemaVersion: 1; outputDirectory: string; sealDirectory: string; defaultSealId: string;
|
||||||
|
exportPdf: boolean; exportPng: boolean; exportPrint: boolean; receiptsPerPage: 1 | 2;
|
||||||
|
cutLine: boolean; createBatchFolder: boolean; openAfterExport: boolean;
|
||||||
|
numberPrefix: string; defaultCashier: string; defaultReviewer: string;
|
||||||
|
sealWidthMm: number; sealOffsetX: number; sealOffsetY: number; pngDpi: 150 | 300;
|
||||||
|
lastImportDirectory: string; templateId: string; fileNamePattern: 'number_customer' | 'customer_number' | 'number';
|
||||||
|
historyPageSize:HistoryPageSize;
|
||||||
|
}
|
||||||
|
export interface ReceiptInput { date: string; school?: string; grade?: string; studentName?: string; customer: string; item: string; amountCents: number; cashier: string; reviewer: string }
|
||||||
|
export interface ImportRow { index: number; excelRow: number; receipt: ReceiptInput; errors: string[]; warnings: string[] }
|
||||||
|
export interface ImportDraft { id: string; fileName: string; hash: string; rows: ImportRow[]; previousBatchId?: string }
|
||||||
|
export interface SealImage { id: string; name: string; dataUrl: string }
|
||||||
|
export interface SealList { images: SealImage[]; warnings: string[] }
|
||||||
|
export interface Receipt extends ReceiptInput { number: string }
|
||||||
|
export interface Batch {
|
||||||
|
id: string; createdAt: string; sourceName: string; sourceHash: string; receipts: Receipt[];
|
||||||
|
settings: AppSettings; seal: SealImage | null; status: 'reserved' | 'running' | 'completed' | 'partial' | 'failed' | 'cancelled';
|
||||||
|
outputDirectory: string; successCount: number; failureCount: number; message: string;
|
||||||
|
}
|
||||||
|
export interface ReceiptGroupSummary {school:string;grade:string;item:string;count:number;totalCents:number}
|
||||||
|
export interface BatchQuery {search?:string;school?:string;grade?:string;item?:string}
|
||||||
|
export const historyPageSizes=[5,10,20,50] as const
|
||||||
|
export type HistoryPageSize=typeof historyPageSizes[number]
|
||||||
|
export interface BatchPageQuery extends BatchQuery {page?:number;pageSize?:HistoryPageSize}
|
||||||
|
export interface BatchPage {items:BatchSummary[];total:number;page:number;pageSize:HistoryPageSize}
|
||||||
|
export interface HistoryFilters {schools:string[];grades:string[];items:string[]}
|
||||||
|
export interface BatchSummary { id: string; createdAt: string; sourceName: string; count: number; totalCents: number; firstNumber: string; lastNumber: string; status: Batch['status']; outputDirectory: string; message: string; groups:ReceiptGroupSummary[];match?:{count:number;number:string;customer:string} }
|
||||||
|
export interface ExportOptions { pdf: boolean; png: boolean; print: boolean; receiptsPerPage: 1 | 2; cutLine: boolean }
|
||||||
|
export interface ExportRequest { draftId?: string; rowIndices?: number[]; batchId?: string; sealId?: string; options: ExportOptions }
|
||||||
|
export interface ExportProgress { batchId: string; completed: number; total: number; stage: string; finished: boolean; status: Batch['status']; outputDirectory: string; errors: string[] }
|
||||||
|
export interface Bootstrap { settings: AppSettings; batches: BatchSummary[]; historyTotal:number; historyFilters:HistoryFilters; nextNumber: string; dataDirectory: string; version: string; templates: { id: string; name: string }[] }
|
||||||
|
export interface ConfirmationOptions {
|
||||||
|
title:string;description:string;variant?:'default'|'warning'|'danger';
|
||||||
|
details?:{label:string;value:string}[];note?:string;confirmLabel?:string;cancelLabel?:string;
|
||||||
|
}
|
||||||
|
export interface ConfirmationRequest extends ConfirmationOptions { id:string }
|
||||||
|
export type ProcessingTask = 'import' | 'template' | 'delete-batch'
|
||||||
|
export interface ReceiptAPI {
|
||||||
|
bootstrap(): Promise<Bootstrap>;
|
||||||
|
saveSettings(settings: AppSettings): Promise<AppSettings>;
|
||||||
|
chooseFolder(kind: 'output' | 'seal'): Promise<string | null>;
|
||||||
|
importExcel(): Promise<ImportDraft | null>;
|
||||||
|
saveExcelTemplate(): Promise<string | null>;
|
||||||
|
listSeals(): Promise<SealList>;
|
||||||
|
preview(request: { draftId?: string; rowIndex?: number; rowIndices?:number[]; batchId?: string; receiptNumber?:string; sealId?: string; layout: 'single' | 'print' }): Promise<string>;
|
||||||
|
exportReceipts(request: ExportRequest): Promise<string>;
|
||||||
|
cancelExport(): Promise<void>;
|
||||||
|
listBatches(query?:BatchQuery): Promise<BatchSummary[]>;
|
||||||
|
listBatchPage(query?:BatchPageQuery): Promise<BatchPage>;
|
||||||
|
deleteBatch(id: string): Promise<boolean>;
|
||||||
|
openFolder(path: string): Promise<void>;
|
||||||
|
onProcessing(callback:(task:ProcessingTask)=>void):()=>void;
|
||||||
|
onConfirmation(callback:(request:ConfirmationRequest)=>void):()=>void;
|
||||||
|
answerConfirmation(id:string,confirmed:boolean):Promise<void>;
|
||||||
|
onProgress(callback: (progress: ExportProgress) => void): () => void;
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
declare module 'sql.js/dist/sql-wasm-inline.js' { import init from 'sql.js'; export default init }
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {it,expect} from 'vitest'
|
||||||
|
import {ConfirmationService} from '../src/main/confirmations'
|
||||||
|
import type {ConfirmationRequest} from '../src/shared/types'
|
||||||
|
const options={title:'删除记录?',description:'删除本批次',variant:'danger' as const,confirmLabel:'删除记录'}
|
||||||
|
it('操作等待匹配确认,其他请求的回复不能批准当前操作',async()=>{
|
||||||
|
let request:ConfirmationRequest|undefined
|
||||||
|
const service=new ConfirmationService(value=>{request=value})
|
||||||
|
const answer=service.ask(options)
|
||||||
|
expect(service.hasPending).toBe(true);expect(request).toMatchObject(options)
|
||||||
|
expect(()=>service.answer('wrong-request',true)).toThrow('已失效')
|
||||||
|
expect(service.hasPending).toBe(true)
|
||||||
|
service.answer(request!.id,true)
|
||||||
|
expect(await answer).toBe(true);expect(service.hasPending).toBe(false)
|
||||||
|
expect(()=>service.answer(request!.id,true)).toThrow('已失效')
|
||||||
|
})
|
||||||
|
it('取消与界面失联均不批准操作',async()=>{
|
||||||
|
let request:ConfirmationRequest|undefined
|
||||||
|
const service=new ConfirmationService(value=>{request=value})
|
||||||
|
const cancelled=service.ask(options);service.answer(request!.id,false);expect(await cancelled).toBe(false)
|
||||||
|
const interrupted=service.ask(options);service.cancel();expect(await interrupted).toBe(false)
|
||||||
|
service.cancel();expect(service.hasPending).toBe(false)
|
||||||
|
})
|
||||||
|
it('重复请求不替换等待中的确认窗口',async()=>{
|
||||||
|
let request:ConfirmationRequest|undefined
|
||||||
|
const service=new ConfirmationService(value=>{request=value})
|
||||||
|
const first=service.ask(options),id=request!.id
|
||||||
|
await expect(service.ask({...options,title:'另一个请求'})).rejects.toThrow('当前确认')
|
||||||
|
expect(request!.id).toBe(id)
|
||||||
|
service.answer(id,true);expect(await first).toBe(true)
|
||||||
|
})
|
||||||
|
it('发送失败后可重新请求确认',async()=>{
|
||||||
|
let failed=true,request:ConfirmationRequest|undefined
|
||||||
|
const service=new ConfirmationService(value=>{if(failed)throw Error('window unavailable');request=value})
|
||||||
|
await expect(service.ask(options)).rejects.toThrow('window unavailable');expect(service.hasPending).toBe(false)
|
||||||
|
failed=false
|
||||||
|
const answer=service.ask(options);service.answer(request!.id,false);expect(await answer).toBe(false)
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe,it,expect,beforeEach,afterEach } from 'vitest'
|
||||||
|
import { mkdtemp,rm,writeFile } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { parseMoney,chineseMoney,moneyCells,MAX_CENTS } from '../src/core/money'
|
||||||
|
import { parseReceiptDate } from '../src/core/date'
|
||||||
|
import { buildDocument,TemplateRegistry } from '../src/core/templates'
|
||||||
|
import { defaultSettings } from '../src/shared/settings'
|
||||||
|
import { Ledger } from '../src/main/ledger'
|
||||||
|
import { XlsxReader,createWorkbookTemplate } from '../src/main/workbooks'
|
||||||
|
import ExcelJS from 'exceljs'
|
||||||
|
describe('金额与日期',()=>{
|
||||||
|
it('以整数分保存,金额格不截断',()=>{expect(parseMoney('3,500.01')).toBe(350001);expect(parseMoney('0.01')).toBe(1);expect(parseMoney('9999999.99')).toBe(MAX_CENTS);expect(moneyCells(MAX_CENTS).join('')).toBe('999999999')})
|
||||||
|
it.each(['-1','0','1.001','1e3','12,34','10000000','abc'])('拒绝无效金额 %s',v=>{expect(()=>parseMoney(v)).toThrow()})
|
||||||
|
it.each([[350000,'叁仟伍佰元整'],[100101,'壹仟零壹元零壹分'],[100000001,'壹佰万元零壹分'],[1010000,'壹万零壹佰元整'],[1234,'壹拾贰元叁角肆分']])('正确转换金额大写', (n,text)=>expect(chineseMoney(n as number)).toBe(text))
|
||||||
|
it('验证真实日期与 Excel 的 1900/1904 日期制',()=>{expect(parseReceiptDate('2024-2-29')).toBe('2024-02-29');expect(()=>parseReceiptDate('2026-2-29')).toThrow();expect(parseReceiptDate(61)).toBe('1900-03-01');expect(parseReceiptDate(0,true)).toBe('1904-01-01');expect(()=>parseReceiptDate(60)).toThrow()})
|
||||||
|
})
|
||||||
|
let dir:string
|
||||||
|
beforeEach(async()=>{dir=await mkdtemp(path.join(tmpdir(),'receipt-studio-test-'))})
|
||||||
|
afterEach(async()=>{await rm(dir,{recursive:true,force:true})})
|
||||||
|
const input={date:'2026-10-16',customer:'测试客户',item:'服务费',amountCents:350000,cashier:'',reviewer:''}
|
||||||
|
describe('持久编号',()=>{
|
||||||
|
it('预览不推进,保留批次快照并在重启后继续编号',async()=>{const store=new Ledger(path.join(dir,'data.sqlite'));await store.open();expect(store.nextNumber('SJ')).toBe('SJ00000001');expect(store.nextNumber('SJ')).toBe('SJ00000001');const settings={...defaultSettings(),defaultCashier:'张三'};const batch=await store.reserve({name:'a.xlsx',hash:'hash'},[input,input],settings,null);expect(batch.receipts.map(r=>r.number)).toEqual(['SJ00000001','SJ00000002']);expect(batch.receipts[0].cashier).toBe('张三');const again=new Ledger(path.join(dir,'data.sqlite'));await again.open();expect(again.nextNumber('SJ')).toBe('SJ00000003');expect(again.get(batch.id).receipts).toEqual(batch.receipts)})
|
||||||
|
it('数据损坏时停止分配编号,保留原文件',async()=>{const file=path.join(dir,'bad.sqlite');await writeFile(file,'broken');await expect(new Ledger(file).open()).rejects.toThrow('停止分配编号')})
|
||||||
|
})
|
||||||
|
describe('模板和工作簿',()=>{
|
||||||
|
it('模板转义用户文字,奇数张 A4 保留空白半页',()=>{const registry=new TemplateRegistry(),r={...input,number:'SJ00000001',customer:'<script>alert(1)</script>'};const html=buildDocument(registry.get('classic-v1'),[r,r,r],{settings:defaultSettings(),seal:null,preview:false},'print',2,true);expect(html).not.toContain('<script>');expect(html).toContain('<script>');expect(html.match(/<section class="page">/g)).toHaveLength(2);expect(html.match(/class="slot"/g)).toHaveLength(4);expect(html).not.toContain('预览样张')})
|
||||||
|
it('导入标准模板并定位错误行,支持千分位与重复行提醒',async()=>{const file=path.join(dir,'test.xlsx');await createWorkbookTemplate(file);const book=new ExcelJS.Workbook();await book.xlsx.readFile(file);book.worksheets[0].addRow(['2026-10-16','学校','年级','客户','项目','12.345']);book.worksheets[0].addRow(['2026-10-16','学校','年级','客户','项目','1,234.50']);book.worksheets[0].addRow(['2026-10-16','学校','年级','客户','项目','1,234.50']);await book.xlsx.writeFile(file);const draft=await new XlsxReader().read(file);expect(draft.rows).toHaveLength(4);expect(draft.rows[1].excelRow).toBe(3);expect(draft.rows[1].errors[0]).toContain('两位小数');expect(draft.rows[2].receipt.amountCents).toBe(123450);expect(draft.rows[3].warnings).toHaveLength(1)})
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {expect,it} from 'vitest'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {groupReceipts} from '../src/main/export-layout'
|
||||||
|
import type {Receipt} from '../src/shared/types'
|
||||||
|
|
||||||
|
function receipt(school:string|undefined,grade='高一',item='服务费'):Receipt {
|
||||||
|
return {number:'SJ00000001',date:'2026-10-19',school,grade,customer:'学生',item,amountCents:100,cashier:'',reviewer:''}
|
||||||
|
}
|
||||||
|
|
||||||
|
it('旧记录没有学校年级时保留原客户名称,并落入未分类目录',()=>{
|
||||||
|
const original={...receipt(undefined),grade:undefined,customer:'以前的客户名称'}
|
||||||
|
expect(groupReceipts([original])[0].directory).toBe(path.join('未分类学校','未分类年级','服务费'))
|
||||||
|
expect(original.customer).toBe('以前的客户名称')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Windows 非法字符、保留名、路径跳转及超长名称生成安全且不混组的目录',()=>{
|
||||||
|
const schools=['学校/A','学校?A','学校_A','CON','..','ABC','abc','甲'.repeat(80)+'一','甲'.repeat(80)+'二']
|
||||||
|
const rows=schools.map((school,i)=>({...receipt(school,'../高一','资料:费'),number:String(i)}))
|
||||||
|
const groups=groupReceipts(rows),root=path.resolve('export-root')
|
||||||
|
expect(groups).toHaveLength(rows.length)
|
||||||
|
expect(new Set(groups.map(g=>g.directory.toLowerCase())).size).toBe(rows.length)
|
||||||
|
for(const group of groups){
|
||||||
|
expect(path.resolve(root,group.directory).startsWith(root+path.sep)).toBe(true)
|
||||||
|
const parts=group.directory.split(path.sep)
|
||||||
|
expect(parts).toHaveLength(3)
|
||||||
|
for(const part of parts){
|
||||||
|
expect(part).not.toMatch(/[<>:"/\\|?*\u0000-\u001f]/)
|
||||||
|
expect(part).not.toMatch(/[. ]$/)
|
||||||
|
expect(part).not.toMatch(/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i)
|
||||||
|
expect(part.length).toBeLessThanOrEqual(80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(groupReceipts(rows).map(g=>g.directory)).toEqual(groups.map(g=>g.directory))
|
||||||
|
})
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import {it,expect} from 'vitest'
|
||||||
|
import {mkdtemp,rm,readdir,readFile,access} from 'node:fs/promises'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {tmpdir} from 'node:os'
|
||||||
|
import {PDFDocument} from 'pdf-lib'
|
||||||
|
import {Ledger} from '../src/main/ledger'
|
||||||
|
import {ExportCoordinator} from '../src/main/exporter'
|
||||||
|
import {defaultSettings} from '../src/shared/settings'
|
||||||
|
import type {PdfEngine} from '../src/main/pdf-engine'
|
||||||
|
import type {ExportProgress} from '../src/shared/types'
|
||||||
|
it('大批量按块排版,奇数张只保留一个空白半页',async()=>{
|
||||||
|
const dir=await mkdtemp(path.join(tmpdir(),'receipt-export-'))
|
||||||
|
try{
|
||||||
|
const ledger=new Ledger(path.join(dir,'db.sqlite'));await ledger.open()
|
||||||
|
const settings={...defaultSettings(dir),fileNamePattern:'number' as const}
|
||||||
|
const inputs=Array.from({length:81},()=>({date:'2026-10-16',customer:'客户',item:'项目',amountCents:100,cashier:'',reviewer:''}))
|
||||||
|
const batch=await ledger.reserve({name:'test',hash:'h'},inputs,settings,null)
|
||||||
|
const sizes:number[]=[]
|
||||||
|
const engine:PdfEngine={async pdf(rows,_s,_seal,_layout,perPage){sizes.push(rows.length);const p=await PDFDocument.create();for(let i=0;i<Math.ceil(rows.length/perPage);i++)p.addPage([595.28,841.89]);return Buffer.from(await p.save())},async png(){return Buffer.alloc(0)},close(){}}
|
||||||
|
let latest:ExportProgress|undefined
|
||||||
|
const exporter=new ExportCoordinator(engine,ledger,p=>{latest=p})
|
||||||
|
await exporter.run(batch,{pdf:false,png:false,print:true,receiptsPerPage:2,cutLine:true},dir)
|
||||||
|
expect(sizes).toEqual([40,40,1]);expect(latest!.status).toBe('completed')
|
||||||
|
const pdf=await PDFDocument.load(await readFile(path.join(batch.outputDirectory,'未分类学校','未分类年级','项目','A4打印版_每页2张.pdf')))
|
||||||
|
expect(pdf.getPageCount()).toBe(41);expect(ledger.nextNumber('SJ')).toBe('SJ00000082')
|
||||||
|
}finally{await rm(dir,{recursive:true,force:true})}
|
||||||
|
})
|
||||||
|
it('取消保留编号,重新导出始终建立独立批次目录,兼容旧的关闭设置',async()=>{
|
||||||
|
const dir=await mkdtemp(path.join(tmpdir(),'receipt-cancel-'))
|
||||||
|
try{
|
||||||
|
const ledger=new Ledger(path.join(dir,'db.sqlite'));await ledger.open()
|
||||||
|
const settings={...defaultSettings(dir),createBatchFolder:false,fileNamePattern:'number' as const}
|
||||||
|
const inputs=Array.from({length:3},()=>({date:'2026-10-16',customer:'客户',item:'项目',amountCents:100,cashier:'',reviewer:''}))
|
||||||
|
const batch=await ledger.reserve({name:'test',hash:'h'},inputs,settings,null)
|
||||||
|
const engine:PdfEngine={async pdf(){const p=await PDFDocument.create();p.addPage();return Buffer.from(await p.save())},async png(){return Buffer.alloc(0)},close(){}}
|
||||||
|
const exporter=new ExportCoordinator(engine,ledger,p=>{if(p.completed===1&&!p.finished)exporter.cancel()})
|
||||||
|
const options={pdf:true,png:false,print:false,receiptsPerPage:2 as const,cutLine:true}
|
||||||
|
await exporter.run(batch,options,dir);expect(ledger.get(batch.id).status).toBe('cancelled')
|
||||||
|
const firstDirectory=batch.outputDirectory
|
||||||
|
expect(path.dirname(firstDirectory)).toBe(dir)
|
||||||
|
expect(ledger.nextNumber('SJ')).toBe('SJ00000004')
|
||||||
|
const retry=new ExportCoordinator(engine,ledger,()=>{});await retry.run(ledger.get(batch.id),options,dir)
|
||||||
|
const retryDirectory=ledger.get(batch.id).outputDirectory
|
||||||
|
expect(path.dirname(retryDirectory)).toBe(dir);expect(retryDirectory).not.toBe(firstDirectory)
|
||||||
|
expect(await readdir(path.join(firstDirectory,'未分类学校','未分类年级','项目'))).toContain('SJ00000001.pdf')
|
||||||
|
expect(await readdir(path.join(retryDirectory,'未分类学校','未分类年级','项目'))).toContain('SJ00000001.pdf')
|
||||||
|
expect(ledger.get(batch.id).status).toBe('completed');expect(ledger.nextNumber('SJ')).toBe('SJ00000004')
|
||||||
|
}finally{await rm(dir,{recursive:true,force:true})}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PDF、图片、打印版按学校、年级、项目分层,非连续行在组内按原顺序排版',async()=>{
|
||||||
|
const dir=await mkdtemp(path.join(tmpdir(),'receipt-groups-'))
|
||||||
|
try{
|
||||||
|
const ledger=new Ledger(path.join(dir,'db.sqlite'));await ledger.open()
|
||||||
|
const settings={...defaultSettings(dir),fileNamePattern:'number' as const}
|
||||||
|
const categories=[['甲学校','高一','服务费'],['乙学校','高一','服务费'],['甲学校','高一','服务费'],['甲学校','高二','服务费'],['甲学校','高一','资料费'],['甲学校','高一','服务费']]
|
||||||
|
const inputs=categories.map(([school,grade,item],i)=>({date:'2026-10-19',school,grade,studentName:'学生'+i,customer:school+' '+grade+' 学生'+i,item,amountCents:100,cashier:'',reviewer:''}))
|
||||||
|
const batch=await ledger.reserve({name:'分组.xlsx',hash:'groups'},inputs,settings,null)
|
||||||
|
const printed:string[][]=[]
|
||||||
|
const engine:PdfEngine={async pdf(rows,_settings,_seal,layout,perPage){
|
||||||
|
if(layout==='print')printed.push(rows.map(r=>r.number))
|
||||||
|
const pdf=await PDFDocument.create();for(let i=0;i<Math.ceil(rows.length/perPage);i++)pdf.addPage()
|
||||||
|
return Buffer.from(await pdf.save())
|
||||||
|
},async png(){return Buffer.from('test image')},close(){}}
|
||||||
|
let latest:ExportProgress|undefined
|
||||||
|
await new ExportCoordinator(engine,ledger,p=>{latest=p}).run(batch,{pdf:true,png:true,print:true,receiptsPerPage:2,cutLine:true},dir)
|
||||||
|
expect(path.dirname(batch.outputDirectory)).toBe(dir)
|
||||||
|
expect(path.basename(batch.outputDirectory)).toMatch(/^\d{8}_\d{6}_/)
|
||||||
|
for(const [i,receipt] of batch.receipts.entries()){
|
||||||
|
const folder=path.join(batch.outputDirectory,...categories[i])
|
||||||
|
await access(path.join(folder,receipt.number+'.pdf'));await access(path.join(folder,receipt.number+'.png'))
|
||||||
|
}
|
||||||
|
expect(printed).toEqual([['SJ00000001','SJ00000003','SJ00000006'],['SJ00000002'],['SJ00000004'],['SJ00000005']])
|
||||||
|
for(const [school,grade,item] of categories){
|
||||||
|
const file=path.join(batch.outputDirectory,school,grade,item,'A4打印版_每页2张.pdf')
|
||||||
|
const pdf=await PDFDocument.load(await readFile(file))
|
||||||
|
expect(pdf.getPageCount()).toBe(school==='甲学校'&&grade==='高一'&&item==='服务费'?2:1)
|
||||||
|
}
|
||||||
|
const manifest=JSON.parse(await readFile(path.join(batch.outputDirectory,'生成清单.json'),'utf8'))
|
||||||
|
expect(manifest.printFiles).toHaveLength(4)
|
||||||
|
expect(manifest.receipts[0].directory).toBe('甲学校/高一/服务费')
|
||||||
|
expect(manifest.receipts[0].files).toEqual(['甲学校/高一/服务费/SJ00000001.pdf','甲学校/高一/服务费/SJ00000001.png'])
|
||||||
|
for(const file of [...manifest.printFiles,...manifest.receipts.flatMap((r:{files:string[]})=>r.files)])await access(path.join(batch.outputDirectory,file))
|
||||||
|
expect(latest).toMatchObject({status:'completed',completed:10,total:10})
|
||||||
|
expect(ledger.nextNumber('SJ')).toBe('SJ00000007')
|
||||||
|
}finally{await rm(dir,{recursive:true,force:true})}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('一个打印分组失败后继续生成其他分组,记录部分完成和准确的成功失败张数',async()=>{
|
||||||
|
const dir=await mkdtemp(path.join(tmpdir(),'receipt-group-failure-'))
|
||||||
|
try{
|
||||||
|
const ledger=new Ledger(path.join(dir,'db.sqlite'));await ledger.open()
|
||||||
|
const inputs=['甲学校','甲学校','乙学校'].map(school=>({date:'2026-10-19',school,grade:'高一',customer:school+' 学生',item:'服务费',amountCents:100,cashier:'',reviewer:''}))
|
||||||
|
const batch=await ledger.reserve({name:'分组.xlsx',hash:'failure'},inputs,defaultSettings(dir),null)
|
||||||
|
const engine:PdfEngine={async pdf(rows){
|
||||||
|
if(rows[0].school==='甲学校')throw Error('排版失败')
|
||||||
|
const pdf=await PDFDocument.create();pdf.addPage();return Buffer.from(await pdf.save())
|
||||||
|
},async png(){return Buffer.alloc(0)},close(){}}
|
||||||
|
let latest:ExportProgress|undefined
|
||||||
|
await new ExportCoordinator(engine,ledger,p=>{latest=p}).run(batch,{pdf:false,png:false,print:true,receiptsPerPage:2,cutLine:true},dir)
|
||||||
|
expect(ledger.get(batch.id)).toMatchObject({status:'partial',successCount:1,failureCount:2})
|
||||||
|
await access(path.join(batch.outputDirectory,'乙学校','高一','服务费','A4打印版_每页2张.pdf'))
|
||||||
|
expect(latest).toMatchObject({completed:5,total:5})
|
||||||
|
expect(latest!.errors[0]).toContain('甲学校 / 高一 / 服务费')
|
||||||
|
}finally{await rm(dir,{recursive:true,force:true})}
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {it,expect} from 'vitest'
|
||||||
|
import {mkdtemp,rm} from 'node:fs/promises'
|
||||||
|
import {tmpdir} from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {Ledger} from '../src/main/ledger'
|
||||||
|
import {defaultSettings} from '../src/shared/settings'
|
||||||
|
function input(school:string|undefined,grade:string|undefined,studentName:string,item:string){return {date:'2026-10-19',school,grade,studentName,customer:[school,grade,studentName].filter(Boolean).join(' '),item,amountCents:100,cashier:'',reviewer:''}}
|
||||||
|
it('按同一张收据组合筛选,支持姓名和中间编号,并返回可直接预览的匹配信息',async()=>{
|
||||||
|
const dir=await mkdtemp(path.join(tmpdir(),'receipt-search-'))
|
||||||
|
try{
|
||||||
|
const ledger=new Ledger(path.join(dir,'db.sqlite'));await ledger.open()
|
||||||
|
const batch=await ledger.reserve({name:'收据模板.xlsx',hash:'a'},[input('甲学校','高一','张三','服务费'),input('乙学校','高二','李四','资料费'),input('甲学校','高一','王五','服务费')],defaultSettings(dir),null)
|
||||||
|
expect(ledger.summaries({school:'甲学校',grade:'高二',item:'资料费'})).toEqual([])
|
||||||
|
expect(ledger.summaries({search:'李四',school:'甲学校'})).toEqual([])
|
||||||
|
expect(ledger.summaries({search:' sj00000002 ',school:'乙学校',grade:'高二',item:'资料费'})[0]).toMatchObject({id:batch.id,count:3,match:{count:1,number:'SJ00000002',customer:'乙学校 高二 李四'}})
|
||||||
|
expect(ledger.summaries({search:'王五'})[0].match?.number).toBe('SJ00000003')
|
||||||
|
expect(ledger.summaries({school:'甲学校'})[0].match?.count).toBe(2)
|
||||||
|
expect(ledger.summaries({search:'收据模板.xlsx'})).toHaveLength(1)
|
||||||
|
expect(ledger.summaries()[0].groups).toEqual([{school:'甲学校',grade:'高一',item:'服务费',count:2,totalCents:200},{school:'乙学校',grade:'高二',item:'资料费',count:1,totalCents:100}])
|
||||||
|
expect(ledger.nextNumber('SJ')).toBe('SJ00000004')
|
||||||
|
}finally{await rm(dir,{recursive:true,force:true})}
|
||||||
|
})
|
||||||
|
it('查找和下拉选项覆盖超过最近 200 批的旧记录,旧数据缺失学校年级也能查找',async()=>{
|
||||||
|
const dir=await mkdtemp(path.join(tmpdir(),'receipt-old-search-'))
|
||||||
|
try{
|
||||||
|
const ledger=new Ledger(path.join(dir,'db.sqlite'));await ledger.open()
|
||||||
|
const oldest=await ledger.reserve({name:'旧记录.xlsx',hash:'old'},[input(undefined,undefined,'旧姓名','旧项目')],defaultSettings(dir),null)
|
||||||
|
oldest.outputDirectory=path.join(dir,'old-output');oldest.status='completed';await ledger.update(oldest)
|
||||||
|
for(let i=0;i<200;i++)await ledger.reserve({name:'新记录.xlsx',hash:String(i)},[input('新学校','高一','新姓名','新项目')],defaultSettings(dir),null)
|
||||||
|
expect(ledger.summaries()).toHaveLength(200)
|
||||||
|
expect(ledger.summaries().some(batch=>batch.id===oldest.id)).toBe(false)
|
||||||
|
expect(ledger.batchPage({page:41})).toMatchObject({total:201,page:41,pageSize:5,items:[{id:oldest.id}]})
|
||||||
|
expect(ledger.summaries({search:'旧姓名',school:'未分类学校',grade:'未分类年级',item:'旧项目'})[0].id).toBe(oldest.id)
|
||||||
|
expect(ledger.isOutputDirectory(oldest.outputDirectory)).toBe(true)
|
||||||
|
expect(ledger.isOutputDirectory(path.join(dir,'other'))).toBe(false)
|
||||||
|
expect(ledger.historyFilters()).toMatchObject({schools:expect.arrayContaining(['新学校','未分类学校']),grades:expect.arrayContaining(['高一','未分类年级']),items:expect.arrayContaining(['旧项目','新项目'])})
|
||||||
|
expect(ledger.nextNumber('SJ')).toBe('SJ00000202')
|
||||||
|
}finally{await rm(dir,{recursive:true,force:true})}
|
||||||
|
},20000)
|
||||||
|
it('默认每页五条,分页互不重复,按筛选结果计数,并在删除末页记录后回到有效页',async()=>{
|
||||||
|
const dir=await mkdtemp(path.join(tmpdir(),'receipt-pages-'))
|
||||||
|
try{
|
||||||
|
const ledger=new Ledger(path.join(dir,'db.sqlite'));await ledger.open()
|
||||||
|
const batches=[]
|
||||||
|
for(let i=1;i<=12;i++){
|
||||||
|
const batch=await ledger.reserve({name:'批次'+i,hash:String(i)},[input(i%2?'甲学校':'乙学校','高一','学生'+i,'服务费')],defaultSettings(dir),null)
|
||||||
|
batch.status='completed';await ledger.update(batch);batches.push(batch)
|
||||||
|
}
|
||||||
|
const first=ledger.batchPage(),second=ledger.batchPage({page:2}),last=ledger.batchPage({page:3})
|
||||||
|
expect(first).toMatchObject({total:12,page:1,pageSize:5})
|
||||||
|
expect(first.items.map(batch=>batch.sourceName)).toEqual(['批次12','批次11','批次10','批次9','批次8'])
|
||||||
|
expect(second.items).toHaveLength(5);expect(last.items).toHaveLength(2)
|
||||||
|
expect(new Set([...first.items,...second.items,...last.items].map(batch=>batch.id)).size).toBe(12)
|
||||||
|
expect(ledger.batchPage({page:2,pageSize:10})).toMatchObject({total:12,page:2,pageSize:10,items:expect.arrayContaining([{...last.items[0]},{...last.items[1]}])})
|
||||||
|
expect(ledger.batchPage({pageSize:50}).items).toHaveLength(12)
|
||||||
|
expect(ledger.batchPage({school:'甲学校',page:2})).toMatchObject({total:6,page:2,items:[{id:batches[0].id}]})
|
||||||
|
expect(ledger.batchPage({school:'甲学校',page:999})).toMatchObject({total:6,page:2,items:[{id:batches[0].id}]})
|
||||||
|
expect(ledger.batchPage({search:'不存在',page:999})).toEqual({items:[],total:0,page:1,pageSize:5})
|
||||||
|
await ledger.deleteBatch(batches[0].id);await ledger.deleteBatch(batches[1].id)
|
||||||
|
expect(ledger.batchPage({page:3})).toMatchObject({total:10,page:2,items:expect.any(Array)})
|
||||||
|
expect(ledger.batchPage({page:3}).items).toHaveLength(5)
|
||||||
|
expect(ledger.nextNumber('SJ')).toBe('SJ00000013')
|
||||||
|
}finally{await rm(dir,{recursive:true,force:true})}
|
||||||
|
})
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {it,expect,beforeEach,afterEach} from 'vitest'
|
||||||
|
import {mkdtemp,rm,writeFile,readFile} from 'node:fs/promises'
|
||||||
|
import {tmpdir} from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {Ledger} from '../src/main/ledger'
|
||||||
|
import {defaultSettings} from '../src/shared/settings'
|
||||||
|
import type {Batch} from '../src/shared/types'
|
||||||
|
let dir:string
|
||||||
|
const input={date:'2026-10-16',customer:'测试学校 高2028届 张三',item:'服务费',amountCents:350000,cashier:'',reviewer:''}
|
||||||
|
beforeEach(async()=>{dir=await mkdtemp(path.join(tmpdir(),'receipt-delete-'))})
|
||||||
|
afterEach(async()=>{await rm(dir,{recursive:true,force:true})})
|
||||||
|
async function open(name='db.sqlite'){const store=new Ledger(path.join(dir,name));await store.open();return store}
|
||||||
|
async function complete(store:Ledger,batch:Batch){batch.status='completed';batch.outputDirectory=dir;await store.update(batch)}
|
||||||
|
it('删除批次持久生效,导出文件和已分配流水保留,重新导入不引用已删除记录',async()=>{
|
||||||
|
const store=await open(),settings=defaultSettings(dir)
|
||||||
|
const first=await store.reserve({name:'first.xlsx',hash:'same'},[input],settings,null);await complete(store,first)
|
||||||
|
const second=await store.reserve({name:'second.xlsx',hash:'same'},[input,input],settings,null);await complete(store,second)
|
||||||
|
const file=path.join(dir,'already-exported.pdf');await writeFile(file,'existing file')
|
||||||
|
await store.deleteBatch(second.id)
|
||||||
|
expect(store.summaries().map(b=>b.id)).toEqual([first.id])
|
||||||
|
expect(()=>store.get(second.id)).toThrow('不存在')
|
||||||
|
expect(store.previous('same')).toBe(first.id)
|
||||||
|
expect(store.nextNumber('SJ')).toBe('SJ00000004')
|
||||||
|
const restarted=await open()
|
||||||
|
expect(restarted.summaries()).toHaveLength(1);expect(restarted.nextNumber('SJ')).toBe('SJ00000004')
|
||||||
|
await restarted.deleteBatch(first.id)
|
||||||
|
expect(restarted.previous('same')).toBeUndefined();expect(restarted.summaries()).toHaveLength(0)
|
||||||
|
const next=await restarted.reserve({name:'new.xlsx',hash:'new'},[input],settings,null)
|
||||||
|
expect(next.receipts[0].number).toBe('SJ00000004')
|
||||||
|
expect(await readFile(file,'utf8')).toBe('existing file')
|
||||||
|
})
|
||||||
|
it('正在生成和已分配编号的批次不能删除,失败后仍保留完整记录',async()=>{
|
||||||
|
const store=await open(),batch=await store.reserve({name:'test',hash:'h'},[input],defaultSettings(dir),null)
|
||||||
|
await expect(store.deleteBatch(batch.id)).rejects.toThrow('生成中的批次不能删除')
|
||||||
|
batch.status='running';await store.update(batch)
|
||||||
|
await expect(store.deleteBatch(batch.id)).rejects.toThrow('生成中的批次不能删除')
|
||||||
|
await expect(store.deleteBatch('missing')).rejects.toThrow('不存在')
|
||||||
|
expect(store.get(batch.id).status).toBe('running')
|
||||||
|
expect(store.nextNumber('SJ')).toBe('SJ00000002')
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import {it,expect} from 'vitest'
|
||||||
|
import {withPngDensity} from '../src/main/png-density'
|
||||||
|
// A small valid PNG; injecting resolution must not change its image payload.
|
||||||
|
const source=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jvF8AAAAASUVORK5CYII=','base64')
|
||||||
|
it('PNG 带有实际 300 DPI 分辨率且可重复更新',()=>{
|
||||||
|
const png=withPngDensity(source,300),index=png.indexOf('pHYs')
|
||||||
|
expect(png.readUInt32BE(index+4)).toBe(11811);expect(png.readUInt32BE(index+8)).toBe(11811)
|
||||||
|
const second=withPngDensity(png,150)
|
||||||
|
expect(second.toString('latin1').split('pHYs')).toHaveLength(2)
|
||||||
|
expect(second.readUInt32BE(second.indexOf('pHYs')+4)).toBe(5906)
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import {it,expect} from 'vitest'
|
||||||
|
import {PrintDocumentStore} from '../src/main/print-documents'
|
||||||
|
import {conciseError} from '../src/shared/errors'
|
||||||
|
|
||||||
|
it('large print documents use short isolated local addresses and can be released',()=>{
|
||||||
|
const store=new PrintDocumentStore(),html='<html>'+ 'x'.repeat(3*1024*1024)+'</html>'
|
||||||
|
const first=store.publish(html),second=store.publish('<html>second</html>')
|
||||||
|
expect(first.url.length).toBeLessThan(100)
|
||||||
|
expect(first.url).not.toBe(second.url)
|
||||||
|
expect(store.read(first.url)).toBe(html)
|
||||||
|
expect(store.read(first.url+'?extra')).toBeUndefined()
|
||||||
|
first.dispose();first.dispose()
|
||||||
|
expect(store.read(first.url)).toBeUndefined()
|
||||||
|
expect(store.read(second.url)).toBe('<html>second</html>')
|
||||||
|
second.dispose();expect(store.read(second.url)).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('loading errors retain the error code without exposing embedded document content',()=>{
|
||||||
|
const message=conciseError(new Error("ERR_INVALID_URL (-300) loading 'data:text/html;charset=utf-8,"+'sensitive'.repeat(100000)+"'"))
|
||||||
|
expect(message).toContain('ERR_INVALID_URL (-300)')
|
||||||
|
expect(message).not.toContain('sensitive')
|
||||||
|
expect(message.length).toBeLessThan(100)
|
||||||
|
expect(conciseError('x'.repeat(1000)).length).toBe(400)
|
||||||
|
expect(conciseError('文件夹无法访问')).toBe('文件夹无法访问')
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {it,expect} from 'vitest'
|
||||||
|
import {importGroupTree,printPreviewRows,summarizeReceiptGroups} from '../src/shared/receipt-groups'
|
||||||
|
import type {ImportRow} from '../src/shared/types'
|
||||||
|
function row(index:number,school='甲学校',errors:string[]=[]):ImportRow{
|
||||||
|
return {index,excelRow:index+2,errors,warnings:[],receipt:{date:'2026-10-19',school,grade:'高一',studentName:'学生'+index,customer:school+' 高一 学生'+index,item:'服务费',amountCents:(index+1)*100,cashier:'',reviewer:''}}
|
||||||
|
}
|
||||||
|
it('非连续行合并为同组,分组金额与勾选同步,错误行始终不计入可生成数据',()=>{
|
||||||
|
const rows=[row(0),row(1,'乙学校'),row(2),row(3,'甲学校',['日期无效'])],before=JSON.stringify(rows)
|
||||||
|
const [schoolA,schoolB]=importGroupTree(rows,new Set([2,3]))
|
||||||
|
expect(schoolA).toMatchObject({label:'甲学校',indices:[0,2],selectedCount:1,totalCents:400,selectedCents:300,invalidCount:1})
|
||||||
|
expect(schoolA.children[0].children[0]).toMatchObject({label:'服务费',indices:[0,2],selectedCount:1,invalidCount:1})
|
||||||
|
expect(schoolB).toMatchObject({label:'乙学校',indices:[1],selectedCount:0,totalCents:200,selectedCents:0})
|
||||||
|
expect(JSON.stringify(rows)).toBe(before)
|
||||||
|
expect(summarizeReceiptGroups(rows.filter(row=>!row.errors.length).map(row=>row.receipt))).toEqual([
|
||||||
|
{school:'甲学校',grade:'高一',item:'服务费',count:2,totalCents:400},
|
||||||
|
{school:'乙学校',grade:'高一',item:'服务费',count:1,totalCents:200}
|
||||||
|
])
|
||||||
|
})
|
||||||
|
it('A4 预览只显示所选同组收据,定位到所在页,最后一页不会混入其他学校',()=>{
|
||||||
|
const rows=[row(0),row(1,'乙学校'),row(2),row(3,'甲学校',['金额无效']),row(4)]
|
||||||
|
expect(printPreviewRows(rows,new Set([0,1,2,3,4]),2,2).map(row=>row.index)).toEqual([0,2])
|
||||||
|
expect(printPreviewRows(rows,new Set([0,1,2,4]),4,2).map(row=>row.index)).toEqual([4])
|
||||||
|
expect(printPreviewRows(rows,new Set([1,2,4]),2,2).map(row=>row.index)).toEqual([2,4])
|
||||||
|
expect(printPreviewRows(rows,new Set([1,2,4]),4,1).map(row=>row.index)).toEqual([4])
|
||||||
|
expect(printPreviewRows(rows,new Set(),0,2)).toEqual([])
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {it,expect} from 'vitest'
|
||||||
|
import {mkdtemp,readFile,writeFile,rm} from 'node:fs/promises'
|
||||||
|
import {tmpdir} from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {SettingsStore} from '../src/main/settings-store'
|
||||||
|
import {defaultSettings} from '../src/shared/settings'
|
||||||
|
|
||||||
|
it('升级旧配置启用固定导出行为,保留其他偏好,并在下次保存后持久化',async()=>{
|
||||||
|
const dir=await mkdtemp(path.join(tmpdir(),'receipt-settings-')),file=path.join(dir,'settings.json')
|
||||||
|
try{
|
||||||
|
const old:Partial<ReturnType<typeof defaultSettings>>={...defaultSettings(dir),createBatchFolder:false,openAfterExport:false,numberPrefix:'OLD',defaultCashier:'原出纳',fileNamePattern:'customer_number'}
|
||||||
|
delete old.historyPageSize
|
||||||
|
await writeFile(file,JSON.stringify(old))
|
||||||
|
const store=new SettingsStore(file,dir),loaded=await store.load()
|
||||||
|
expect(loaded).toMatchObject({createBatchFolder:true,openAfterExport:true,numberPrefix:'OLD',defaultCashier:'原出纳',fileNamePattern:'customer_number',outputDirectory:dir,historyPageSize:5})
|
||||||
|
// Loading does not overwrite the user's existing file; the next explicit save persists the policy.
|
||||||
|
expect(JSON.parse(await readFile(file,'utf8'))).toEqual(old)
|
||||||
|
const saved=await store.save({...loaded,createBatchFolder:false,openAfterExport:false,defaultReviewer:'复核人',historyPageSize:20})
|
||||||
|
expect(saved).toMatchObject({createBatchFolder:true,openAfterExport:true,defaultReviewer:'复核人',numberPrefix:'OLD',historyPageSize:20})
|
||||||
|
expect(await new SettingsStore(file,dir).load()).toEqual(saved)
|
||||||
|
}finally{await rm(dir,{recursive:true,force:true})}
|
||||||
|
})
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import {it,expect,beforeEach,afterEach} from 'vitest'
|
||||||
|
import ExcelJS from 'exceljs'
|
||||||
|
import {mkdtemp,rm} from 'node:fs/promises'
|
||||||
|
import {tmpdir} from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import {XlsxReader,createWorkbookTemplate} from '../src/main/workbooks'
|
||||||
|
import {Ledger} from '../src/main/ledger'
|
||||||
|
import {defaultSettings} from '../src/shared/settings'
|
||||||
|
let dir:string
|
||||||
|
const headers=['收款日期','学校','年级','姓名','收费项目','金额','出纳','复核']
|
||||||
|
beforeEach(async()=>{dir=await mkdtemp(path.join(tmpdir(),'receipt-fields-'))})
|
||||||
|
afterEach(async()=>{await rm(dir,{recursive:true,force:true})})
|
||||||
|
async function workbook(row:unknown[],columns=headers){
|
||||||
|
const file=path.join(dir,'data.xlsx'),book=new ExcelJS.Workbook(),sheet=book.addWorksheet('收据数据')
|
||||||
|
sheet.addRow(columns);sheet.addRow(row);await book.xlsx.writeFile(file);return file
|
||||||
|
}
|
||||||
|
it('下载模板包含学校、年级、姓名,金额列和筛选范围随之调整',async()=>{
|
||||||
|
const file=path.join(dir,'template.xlsx');await createWorkbookTemplate(file)
|
||||||
|
const book=new ExcelJS.Workbook();await book.xlsx.readFile(file);const sheet=book.worksheets[0]
|
||||||
|
expect(sheet.getRow(1).values).toEqual([undefined,...headers])
|
||||||
|
expect(sheet.getColumn(6).numFmt).toBe('0.00');expect(sheet.autoFilter).toBe('A1:H1')
|
||||||
|
const draft=await new XlsxReader().read(file)
|
||||||
|
expect(draft.rows[0].errors).toEqual([])
|
||||||
|
expect(draft.rows[0].receipt.customer).toBe('某某学校 初二年级 张三')
|
||||||
|
})
|
||||||
|
it('三列去除首尾空白后,用单个空格拼接,并保留原字段用于扩展',async()=>{
|
||||||
|
const file=await workbook(['2026-10-16',' 巴南中学 ',' 高2028届 ',' 张三 ','服务费',3500])
|
||||||
|
const draft=await new XlsxReader().read(file),receipt=draft.rows[0].receipt
|
||||||
|
expect(receipt.customer).toBe('巴南中学 高2028届 张三')
|
||||||
|
expect(receipt).toMatchObject({school:'巴南中学',grade:'高2028届',studentName:'张三'})
|
||||||
|
expect(draft.rows[0].errors).toEqual([])
|
||||||
|
const store=new Ledger(path.join(dir,'db.sqlite'));await store.open()
|
||||||
|
const batch=await store.reserve({name:'data.xlsx',hash:'hash'},[receipt],defaultSettings(),null)
|
||||||
|
expect(store.get(batch.id).receipts[0]).toMatchObject({school:'巴南中学',grade:'高2028届',studentName:'张三',customer:'巴南中学 高2028届 张三'})
|
||||||
|
})
|
||||||
|
it.each([[1,'学校'],[2,'年级'],[3,'姓名']] as const)('%s 列缺失时,定位具体必填字段',async(index,label)=>{
|
||||||
|
const row:unknown[]=['2026-10-16','学校','年级','张三','服务费',3500];row[index]=' '
|
||||||
|
const draft=await new XlsxReader().read(await workbook(row))
|
||||||
|
expect(draft.rows[0].errors).toContain(label+'不能为空')
|
||||||
|
})
|
||||||
|
it('缺少拆分后的表头时提示重新下载模板',async()=>{
|
||||||
|
const file=await workbook(['2026-10-16','学校 年级 张三','服务费',3500],['收款日期','客户名称','收费项目','金额'])
|
||||||
|
await expect(new XlsxReader().read(file)).rejects.toThrow('第一行缺少表头:学校、年级、姓名')
|
||||||
|
})
|
||||||
|
it('按表头识别三列,而非依赖固定列序',async()=>{
|
||||||
|
const file=await workbook(['张三',3500,'高2028届','2026-10-16','服务费','学校'],['姓名','金额','年级','收款日期','收费项目','学校'])
|
||||||
|
const draft=await new XlsxReader().read(file)
|
||||||
|
expect(draft.rows[0].receipt.customer).toBe('学校 高2028届 张三')
|
||||||
|
expect(draft.rows[0].errors).toEqual([])
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"types": [
|
||||||
|
"vite/client"
|
||||||
|
],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/renderer/**/*.ts",
|
||||||
|
"src/renderer/**/*.vue",
|
||||||
|
"src/shared/**/*.ts",
|
||||||
|
"src/core/**/*.ts",
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"lib": [
|
||||||
|
"ES2022",
|
||||||
|
"DOM"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/main/**/*.ts",
|
||||||
|
"src/core/**/*.ts",
|
||||||
|
"src/shared/**/*.ts",
|
||||||
|
"src/types/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
export default defineConfig({
|
||||||
|
root: 'src/renderer', base: './', plugins: [vue()],
|
||||||
|
build: { outDir: '../../dist/renderer', emptyOutDir: true, rollupOptions: { input: { main: resolve('src/renderer/index.html'), raster: resolve('src/renderer/raster.html') } } },
|
||||||
|
server: { host: '127.0.0.1', port: 5173, strictPort: true }
|
||||||
|
})
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
export default defineConfig({test:{include:['tests/**/*.test.ts'],environment:'node',testTimeout:20000}})
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
chcp 65001 >nul
|
||||||
|
cd /d "%~dp0"
|
||||||
|
set "RECEIPT_DEV_NODE=%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\node\bin\node.exe"
|
||||||
|
if not exist "%RECEIPT_DEV_NODE%" set "RECEIPT_DEV_NODE=node"
|
||||||
|
"%RECEIPT_DEV_NODE%" -e "const [major,minor]=process.versions.node.split('.').map(Number);process.exit(major>22||(major===22&&minor>=13)?0:1)" >nul 2>nul
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Node.js 24 LTS is required. Please install it and try again.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
if not exist "node_modules\vite\package.json" (
|
||||||
|
echo Project dependencies are missing. Please run npm install first.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
"%RECEIPT_DEV_NODE%" scripts\dev.mjs %*
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Startup failed. See the error message above.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user