111
This commit is contained in:
@@ -2,11 +2,17 @@
|
||||
|
||||
面向 Windows 10 x64 的本地收据工具。安装后无需登录或联网,无需安装 Excel。
|
||||
|
||||
## 发给用户使用
|
||||
|
||||
当前交付版本为 1.0.2。将 `release/收据工坊-1.0.2-Windows64位.zip` 发给用户,完整解压后双击其中的 `ReceiptStudio-1.0.2-win-x64-setup.exe` 安装,再从桌面快捷方式打开。压缩包附有中文使用说明;安装包不包含开发机的收据、印章、设置或测试数据。首次配置导出目录、印章文件夹及常用姓名,多台电脑使用不同编号前缀。
|
||||
|
||||
用户操作说明见 [用户使用说明](docs/用户使用说明.txt)。交付前运行 `npm run package` 生成安装包,`npm run test:package` 用独立数据验证打包后的程序;安装包不需要与源码或 node_modules 一起发送。当前未配置代码签名证书,部分电脑可能提示未知发布者。交付后请在实际打印机上试打一页。
|
||||
|
||||
## 使用
|
||||
|
||||
1. 打开“偏好设置”,选择导出目录和印章文件夹,填写默认出纳、复核人。
|
||||
2. 在“印章管理”选择本批印章;也可以明确选择不使用印章。
|
||||
3. 在工作台下载 Excel 模板,每行填写一条收费项目,再导入;在“按分组核对收据”中按学校、年级、收费项目整组勾选,核对张数、金额和待修改条数。
|
||||
3. 在工作台下载 Excel 模板,每行填写一条收费项目,再导入;“按分组核对收据”默认收起,点击标题展开后,可按学校、年级、收费项目整组勾选,核对张数、金额和待修改条数。
|
||||
4. 核对错误行、选中数据与右侧预览,选择 PDF、PNG、A4 打印版后生成。A4 预览按已选分组显示对应页,不跨组混排。
|
||||
5. 补打请到“生成记录”,按学校、年级、收费项目,或输入姓名、任意收据编号查找,预览匹配的原收据。重新导出沿用原编号、数据和印章,导出该批全部收据。
|
||||
6. 生成记录支持逐批删除,确认后移除批次数据;已导出的文件保留,流水号继续递增。删除后不能在列表中补打该批次。
|
||||
@@ -15,6 +21,8 @@
|
||||
|
||||
A4 打印版默认上下两张,奇数张时最后半页留空,也可选每页一张。单张 PDF/PNG 页面为 190 × 140 mm;收据主体为 180 × 120 mm。打印 A4 文件时使用 A4、实际大小或 100% 比例。首批请在实际打印机上试打一页。
|
||||
|
||||
未导入 Excel 时,A4 示例按当前“每页张数”显示一张或两张不同的样张;切换张数立即更新预览。导入后只预览已勾选的同组收据,预览上方显示分组、组内已选张数和本页张数。当前分组仅选一张或最后一页剩一张时,会说明下半页留白的原因。
|
||||
|
||||
每次导出自动建立独立批次文件夹,生成成功后自动打开输出目录,这两项行为无需设置。批次保留原有命名,内部按“学校 → 年级 → 收费项目”分层。单张 PDF、PNG 以及该组的 A4 打印版放在同一个项目文件夹;每组独立排版,组内保持 Excel 顺序,每页两张时奇数张的最后半页留空。生成清单保留在批次根目录,记录文件的相对路径。历史记录缺少学校或年级时,使用“未分类学校”“未分类年级”目录。文件名在“偏好设置 → 文件与常用选择 → 导出的文件叫什么名字”中调整,修改后立即显示文件名示例。
|
||||
|
||||
```text
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
| src/core/money.ts | 整数分、金额上限、人民币大写、金额格 |
|
||||
| src/core/date.ts | 日期与 Excel 日期制 |
|
||||
| src/core/templates.ts | 模板接口、注册器、打印页面组合;与桌面环境解耦 |
|
||||
| src/core/preview-samples.ts | 未导入数据时的示例收据,按当前页面容量生成,不分配流水号 |
|
||||
| src/main/workbooks.ts | WorkbookReader 接口及 .xlsx 适配器 |
|
||||
| src/main/ledger.ts | 持久编号、批次快照及删除记录 |
|
||||
| src/main/settings-store.ts | 版本化设置及原子写入 |
|
||||
@@ -76,6 +77,8 @@ importGroupTree 按三级分组统计有效行、待修改行、选中张数和
|
||||
|
||||
A4 预览使用 printPreviewRows 在已选同组收据中定位当前行所在页,并按全局已选顺序展示待分配编号。预览查询不修改流水号。设置页面的实时文件名示例和正式导出使用相同的 receiptFileStem,保持前缀、命名方式和字符替换一致。
|
||||
|
||||
工作台预览请求显式传递当前 receiptsPerPage,主进程校验为 1 或 2,并只覆盖本次预览上下文;省略时沿用已保存设置,历史预览继续使用原批次快照。切换每页张数无需先保存设置。未导入时由 createPreviewSamples 提供对应张数的不同示例;导入后始终使用真实勾选数据,不补充示例。界面共用 printPreviewRows 计算本页张数,并展示当前分组、组内已选数量及空白半页原因。
|
||||
|
||||
记录列表通过 listBatchPage 传递经校验的学校、年级、项目、文本、页码和每页条数,返回当前页、真实总条数及有效页码。无筛选时使用数据库 COUNT/LIMIT/OFFSET;有筛选时逐批匹配同一张收据,统计所有匹配批次并仅构建当前页摘要,末页删除后自动钳制到有效页。学校、姓名和任意编号查找仍覆盖全库。原 listBatches 继续为已有调用提供最多 200 条摘要,bootstrap 提供全库分类选项和真实总数。
|
||||
|
||||
界面默认每页 5 条,可选 5/10/20/50;historyPageSize 在设置校验器中为旧配置补默认值,修改后持久保存。筛选或每页条数变化时回到第一页,刷新保留当前页;使用请求版本号保持最新结果。显式翻页沿用整块加载反馈,自动筛选合并短时间内的输入变化。过滤结果仍对应完整批次,重新导出维持原有批次语义。原收据预览可指定匹配编号,旧批次输出目录经过全库归属检查。
|
||||
|
||||
@@ -110,3 +110,11 @@
|
||||
- 类型检查、构建及 46 项测试通过,覆盖默认条数、分页不重复、每页 10/50 条、筛选总数、空结果、页码越界、删除末页、201 条记录的最后一页以及旧配置默认值与条数保存。
|
||||
- 新增隔离桌面验证 test:history,使用 13 个测试批次实际验证五条分页、范围和边界按钮、翻页、条数切换、筛选回首页、清空筛选、跨板块/重启记忆、逐条删除末页自动回退,流水号保持不变;截图已检查,无界面脚本异常。完整桌面导入/分组/PDF/PNG/A4/原编号补打/重启验证通过。
|
||||
- 验证记录:.qa/history-pagination/result.json、.qa/result.json;截图:.qa/history-pagination/pagination.png。测试使用隔离配置,用户记录未改动;未重新生成安装包。重启本地调试后生效。
|
||||
|
||||
## 本地调试版:A4 示例张数及实时排版预览(2026-10-19)
|
||||
|
||||
- 未导入数据时,示例按当前每页张数提供一张或两张不同收据,单张预览仍只显示一张;示例不会分配编号或写入记录。
|
||||
- 修复工作台切换每页张数后,主进程仍沿用已保存张数的问题。预览请求显式传递当前值,主进程校验后仅用于本次预览;历史预览继续使用原批次设置。
|
||||
- A4 预览新增当前分组、已选张数和本页张数。分组只有一张或奇数张末页时,明确说明下半页留白原因,继续按学校、年级、收费项目分别排版。
|
||||
- 类型检查、构建及 46 项原有测试通过。隔离 Electron 验证已保存每页一张时切换为两张立即显示两张示例、单张/A4 切换、同组非连续行配对、学校与年级隔离、奇数末页说明、取消勾选后重新配对、清空选择及编号与历史总数保持不变;无界面脚本异常,截图已检查。
|
||||
- 验证记录:.qa/a4-preview-1792381053561/result.json;截图:同目录 sample-two.png、group-two.png。测试使用隔离配置和 Excel 数据,用户数据未改动;未重新生成安装包。关闭程序后重新启动本地调试生效。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
收据工坊 使用说明
|
||||
适用系统:Windows 10,64 位
|
||||
|
||||
一、安装与打开
|
||||
1. 先将收到的压缩包完整解压到一个文件夹。
|
||||
2. 双击 ReceiptStudio-1.0.2-win-x64-setup.exe,按照提示选择安装位置并完成安装。
|
||||
3. 之后双击桌面上的“收据工坊”打开软件。
|
||||
4. 软件无需登录,安装后可离线使用,无需安装开发环境。
|
||||
|
||||
二、首次设置
|
||||
1. 打开“偏好设置”,选择生成文件的保存位置。
|
||||
2. 将印章图片放在电脑上的固定文件夹,然后绑定印章文件夹;推荐使用透明背景的 PNG 图片。
|
||||
3. 填写常用的出纳、复核姓名,保存设置。
|
||||
4. 多台电脑分别开收据时,请给每台设置不同的编号前缀,例如 CW01、CW02。
|
||||
5. 到“印章管理”选择需要使用的印章,也可以选择不使用印章。
|
||||
|
||||
三、导入与生成收据
|
||||
1. 在“收据工作台”点击“下载模板”,使用 Excel 或 WPS 填写数据,保存为 .xlsx 文件。
|
||||
2. 保留第一行表头,将示例行替换成真实数据。每行对应一张收据、一条收费项目。
|
||||
3. 必填列:收款日期、学校、年级、姓名、收费项目、金额。出纳、复核可以留空,留空时使用偏好设置中的姓名。
|
||||
4. 点击“导入 Excel”,核对金额、姓名、收费项目和勾选张数;需要修改的数据请在 Excel 中改好后重新导入。
|
||||
5. “按分组核对收据”默认收起,点击标题可展开,按学校、年级、收费项目整组选择。
|
||||
6. 选择单张 PDF、单张 PNG 或 A4 打印版,点击“生成收据”。成功后会自动打开保存文件夹。
|
||||
|
||||
四、文件与打印
|
||||
1. 每次生成会建立一个独立批次文件夹,内部按“学校 → 年级 → 收费项目”保存文件。
|
||||
2. 单张 PDF、PNG 每张收据单独一个文件;A4 打印版会将同一分组的收据合并成打印 PDF。
|
||||
3. A4 默认每页上下两张,也可选择每页一张。不同分组单独排版,组内最后一页只有一张时,下半页留白。
|
||||
4. 点击左侧不同数据,可查看它所在分组的打印页。预览不占用正式编号。
|
||||
5. 打印时打开生成的 A4 打印版 PDF,选择 A4 纸和“实际大小”或 100% 比例。首次使用请先试打一页,核对印章、边距和裁切位置。
|
||||
|
||||
五、查找与补打
|
||||
1. 在“生成记录”中可按学校、年级、收费项目、姓名或收据编号查找。
|
||||
2. 点击“重新导出”会使用原编号、原数据和原印章,导出该批全部收据。
|
||||
3. 删除生成记录只删除软件中的批次记录,已导出文件仍保留;删除后不能在列表中补打该批次,收据编号继续递增。
|
||||
|
||||
六、更新与提示
|
||||
1. 后续收到新版安装包时,先关闭软件,再运行新版安装包更新;同一 Windows 用户下的原设置和生成记录会保留。
|
||||
2. 本安装包未配置数字签名,部分电脑可能提示“未知发布者”。请核对安装包来源;单位电脑有安装限制时,请由管理员按单位规定安装。
|
||||
3. 设置和生成记录保存在当前 Windows 用户的数据目录中。不同电脑、不同 Windows 用户的数据各自独立。
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "receipt-studio",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "receipt-studio",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.58.0",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "receipt-studio",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"private": true,
|
||||
"description": "离线收据生成与批量打印工具",
|
||||
"author": "Receipt Studio",
|
||||
@@ -52,6 +52,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"!dist/**/*.map",
|
||||
"package.json",
|
||||
"!node_modules/**/*"
|
||||
],
|
||||
|
||||
@@ -76,6 +76,7 @@ try{
|
||||
await page.getByRole('button',{name:'导入 Excel',exact:true}).click()
|
||||
await expect(page.getByText('已选择 3 条',{exact:true})).toBeVisible()
|
||||
await expect(page.getByText('待修改',{exact:true})).toBeVisible()
|
||||
await page.locator('.group-review-heading').click()
|
||||
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)
|
||||
@@ -149,6 +150,7 @@ try{
|
||||
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 page.locator('.group-review-heading').click()
|
||||
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)
|
||||
|
||||
+69
-21
@@ -1,39 +1,87 @@
|
||||
import { _electron as electron,expect } from '@playwright/test'
|
||||
import { listPackage } from '@electron/asar'
|
||||
import ExcelJS from 'exceljs'
|
||||
import { PNG } from 'pngjs'
|
||||
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
|
||||
const root=process.cwd(),version=JSON.parse(await fs.readFile('package.json','utf8')).version
|
||||
await fs.mkdir(path.join(root,'.qa'),{recursive:true})
|
||||
const qa=await fs.mkdtemp(path.join(root,'.qa','packaged-'+version+'-'))
|
||||
const profile=path.join(qa,'profile'),sealFolder=path.join(qa,'seals'),output=path.join(qa,'output')
|
||||
for(const folder of [profile,sealFolder,output])await fs.mkdir(folder,{recursive:true})
|
||||
const image=new PNG({width:120,height:120})
|
||||
for(let y=0;y<120;y++)for(let x=0;x<120;x++){
|
||||
const i=(y*120+x)*4,d=Math.hypot(x-60,y-60)
|
||||
image.data[i]=195;image.data[i+1]=30;image.data[i+2]=35;image.data[i+3]=d>45&&d<50?255:0
|
||||
}
|
||||
await fs.writeFile(path.join(sealFolder,'测试印章.png'),PNG.sync.write(image))
|
||||
const book=new ExcelJS.Workbook(),sheet=book.addWorksheet('收据数据')
|
||||
sheet.addRow(['收款日期','学校','年级','姓名','收费项目','金额'])
|
||||
for(const [school,name,amount] of [['甲学校','张三',3500],['乙学校','赵六',2500],['甲学校','李四',3000],['甲学校','王五',2000]])sheet.addRow(['2026-10-19',school,'高一',name,'秋季学期服务费',amount])
|
||||
const excelFile=path.join(qa,'收据测试数据.xlsx');await book.xlsx.writeFile(excelFile)
|
||||
const archive=listPackage(path.join(root,'release','win-unpacked','resources','app.asar'))
|
||||
expect(archive.some(file=>/[\\/](?:\.local-data|\.qa|\.vs|node_modules)(?:[\\/]|$)/.test(file))).toBe(false)
|
||||
expect(archive.some(file=>/\.(?:sqlite|map)$/i.test(file))).toBe(false)
|
||||
expect(archive.some(file=>/[\\/]brand-[^\\/]+\.png$/.test(file))).toBe(true)
|
||||
const env={...process.env};delete env.ELECTRON_RUN_AS_NODE;delete env.ELECTRON_RENDERER_URL
|
||||
let app
|
||||
const errors=[]
|
||||
try{
|
||||
app=await electron.launch({executablePath:path.join(process.cwd(),'release','win-unpacked','收据工坊.exe'),args:['--smoke-test','--user-data-dir='+profile],env,timeout:60000})
|
||||
app=await electron.launch({executablePath:path.join(root,'release','win-unpacked','收据工坊.exe'),args:['--smoke-test','--user-data-dir='+profile],env,timeout:60000})
|
||||
const page=await app.firstWindow();page.setDefaultTimeout(20000)
|
||||
page.on('pageerror',error=>errors.push(error.message))
|
||||
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.version).toBe(version)
|
||||
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()=>''})
|
||||
expect(boot.historyTotal).toBe(0);expect(boot.settings.historyPageSize).toBe(5)
|
||||
await expect.poll(()=>page.locator('.brand-mark').evaluate(img=>img.complete&&img.naturalWidth===200)).toBe(true)
|
||||
await page.getByRole('button',{name:'A4 打印排版',exact:true}).click()
|
||||
await expect(page.frameLocator('iframe').locator('.receipt')).toHaveCount(2)
|
||||
await page.getByLabel('打印每页张数').selectOption('1')
|
||||
await expect(page.frameLocator('iframe').locator('.receipt')).toHaveCount(1)
|
||||
await page.getByLabel('打印每页张数').selectOption('2')
|
||||
await expect(page.frameLocator('iframe').locator('.receipt')).toHaveCount(2)
|
||||
expect((await page.evaluate(()=>window.receipts.bootstrap())).nextNumber).toBe(boot.nextNumber)
|
||||
await app.evaluate(({shell,dialog},file)=>{shell.openPath=async()=>'';dialog.showOpenDialog=async()=>({canceled:false,filePaths:[file]})},excelFile)
|
||||
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 seals=await page.evaluate(()=>window.receipts.listSeals());expect(seals.images).toHaveLength(1)
|
||||
await page.evaluate(async sealId=>{const b=await window.receipts.bootstrap();await window.receipts.saveSettings({...b.settings,defaultSealId:sealId})},seals.images[0].id)
|
||||
await page.reload()
|
||||
await expect(page.getByRole('heading',{name:'收据工作台',exact:true})).toBeVisible()
|
||||
await page.getByRole('button',{name:'导入 Excel',exact:true}).click()
|
||||
await expect(page.getByText('已选择 4 条',{exact:true})).toBeVisible()
|
||||
await expect(page.locator('.group-review')).toHaveJSProperty('open',false)
|
||||
await page.getByRole('button',{name:'A4 打印排版',exact:true}).click()
|
||||
await expect(page.frameLocator('iframe').locator('.receipt')).toHaveCount(2)
|
||||
await expect(page.frameLocator('iframe').getByText('甲学校 高一 李四',{exact:true})).toBeVisible()
|
||||
await page.getByLabel('单张 PNG',{exact:true}).check()
|
||||
await page.getByRole('button',{name:/生成 4 张收据/}).click()
|
||||
await expect.poll(async()=>page.evaluate(async()=> (await window.receipts.listBatches())[0]?.status),{timeout:120000,intervals:[500,1000]}).toBe('completed')
|
||||
const batch=(await page.evaluate(()=>window.receipts.listBatches()))[0]
|
||||
expect(batch.count).toBe(4);expect(batch.firstNumber).toBe('PACK00000001');expect(batch.lastNumber).toBe('PACK00000004')
|
||||
const files=await fs.readdir(batch.outputDirectory,{recursive:true})
|
||||
expect(files.filter(n=>n.endsWith('.png'))).toHaveLength(1)
|
||||
expect(files.filter(n=>n.endsWith('.png'))).toHaveLength(4)
|
||||
expect(files.filter(n=>n.endsWith('.pdf'))).toHaveLength(6)
|
||||
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.')
|
||||
const pages={}
|
||||
for(const [school,count] of [['甲学校',2],['乙学校',1]]){
|
||||
const file=path.join(school,'高一','秋季学期服务费','A4打印版_每页2张.pdf')
|
||||
expect(files).toContain(file)
|
||||
const pdf=await PDFDocument.load(await fs.readFile(path.join(batch.outputDirectory,file)))
|
||||
expect(pdf.getPageCount()).toBe(count);pages[school]=count
|
||||
expect(Math.abs(pdf.getPage(0).getWidth()-595.28)).toBeLessThan(1)
|
||||
}
|
||||
await page.getByRole('button',{name:/生成记录/}).click()
|
||||
await expect(page.locator('.history-table tbody tr')).toHaveCount(1)
|
||||
expect((await page.evaluate(()=>window.receipts.bootstrap())).nextNumber).toBe('PACK00000005')
|
||||
expect(errors).toEqual([])
|
||||
const result={passed:true,version,packaged:true,cleanArchive:true,defaultGroupCollapsed:true,liveA4Preview:true,receipts:4,renderedPdf:true,renderedPng:true,printPages:pages,dpi:300,profile,errors}
|
||||
await fs.writeFile(path.join(qa,'result.json'),JSON.stringify(result,null,2))
|
||||
await fs.writeFile(path.join(root,'.qa','package-result.json'),JSON.stringify(result,null,2))
|
||||
console.log('Packaged application verified: '+version+'. Artifacts: '+qa)
|
||||
}finally{if(app)await app.close().catch(()=>{})}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AppSettings,Receipt } from '../shared/types'
|
||||
|
||||
export function createPreviewSamples(settings:AppSettings,firstNumber:string,count:1|2,date=new Date().toLocaleDateString('sv-SE')):Receipt[]{
|
||||
const school='某某学校',grade='初二年级'
|
||||
const sequence=Number(firstNumber.slice(settings.numberPrefix.length))
|
||||
return ['张三','李四'].slice(0,count).map((studentName,index)=>({
|
||||
date,school,grade,studentName,customer:[school,grade,studentName].join(' '),
|
||||
item:'秋季学期服务费',amountCents:index===0?350000:300000,
|
||||
cashier:settings.defaultCashier,reviewer:settings.defaultReviewer,
|
||||
number:settings.numberPrefix+String(sequence+index).padStart(8,'0')
|
||||
}))
|
||||
}
|
||||
+5
-3
@@ -12,6 +12,7 @@ import { ElectronPdfEngine } from './pdf-engine'
|
||||
import { printDocuments } from './print-documents'
|
||||
import { ExportCoordinator } from './exporter'
|
||||
import { TemplateRegistry,buildDocument } from '../core/templates'
|
||||
import { createPreviewSamples } from '../core/preview-samples'
|
||||
import { settingsSchema,exportOptionsSchema,historyPageSizeSchema } from '../shared/settings'
|
||||
import {printPreviewRows} from '../shared/receipt-groups'
|
||||
import type { Batch,ExportRequest,ImportDraft,Receipt,SealImage,ProcessingTask } from '../shared/types'
|
||||
@@ -113,15 +114,16 @@ async function start(){
|
||||
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)
|
||||
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']),receiptsPerPage:z.union([z.literal(1),z.literal(2)]).optional()}).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 {
|
||||
current={...current,receiptsPerPage:request.receiptsPerPage??current.receiptsPerPage}
|
||||
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)}]
|
||||
const input=row?.receipt,firstNumber=ledger.nextNumber(current.numberPrefix)
|
||||
receipts=input?[{...input,cashier:input.cashier||current.defaultCashier,reviewer:input.reviewer||current.defaultReviewer,number:firstNumber}]:createPreviewSamples(current,firstNumber,request.layout==='print'?current.receiptsPerPage:1)
|
||||
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))
|
||||
|
||||
@@ -122,7 +122,7 @@ export const actions={
|
||||
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})
|
||||
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,receiptsPerPage:state.settings.receiptsPerPage})
|
||||
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}
|
||||
|
||||
@@ -21,3 +21,4 @@
|
||||
.history-match{display:block;margin-top:7px;font-size:11px;line-height:1.7;color:#338267;overflow-wrap:anywhere}
|
||||
.group-show-more{margin:10px 0 12px 30px;padding:5px}
|
||||
.history-pagination{padding:15px 20px;border-top:1px solid var(--line);display:flex;flex-wrap:wrap;align-items:center;gap:14px;color:#73877b;font-size:12px}.history-range{margin-right:auto;font-variant-numeric:tabular-nums}.history-page-size{display:flex;align-items:center;gap:9px;white-space:nowrap}.history-page-size select{width:98px;min-height:34px;padding:6px 8px;font-size:12px}.history-page-controls{display:flex;flex-wrap:wrap;align-items:center;gap:5px}.history-page-button{display:inline-flex;align-items:center;justify-content:center;min-width:32px;height:33px;padding:0 9px;border:1px solid #e2eae6;border-radius:6px;background:#fff;color:#647f6e;font-size:12px}.history-page-button:hover:not(:disabled){background:#f0f8f3;border-color:#a6ceba}.history-page-button.active{background:var(--green);border-color:var(--green);color:#fff}.history-page-button:disabled{opacity:.45;cursor:not-allowed}.history-page-position{margin-left:7px;white-space:nowrap;font-size:12px;font-variant-numeric:tabular-nums}@media(max-width:1250px){.history-pagination{padding:14px;gap:12px}.history-page-controls{margin-left:auto}.history-range{min-width:175px}}
|
||||
.print-preview-summary{display:flex;align-items:flex-start;gap:8px;padding:10px 17px;background:#f8fbfa;border-top:1px solid #edf2ef;color:#6d8c7c;font-size:11px;line-height:1.7;overflow-wrap:anywhere}.print-preview-summary>svg{flex-shrink:0;margin-top:2px}.print-preview-summary>div{min-width:0}.print-preview-summary strong{display:block;font-weight:500;color:#5d7b6b}.print-preview-summary p{margin-top:3px;color:#94a49b;font-size:10px}
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 {importGroupTree,printPreviewRows,receiptGroupLabels,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)
|
||||
@@ -23,6 +23,19 @@ const allSelected=computed(()=>rows.value.some(r=>!r.errors.length)&&rows.value.
|
||||
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))
|
||||
const printRows=computed(()=>printPreviewRows(rows.value,state.selected,state.rowIndex,state.settings.receiptsPerPage))
|
||||
const printPreviewInfo=computed(()=>{
|
||||
if(!state.draft)return {title:'示例排版 · 本页 '+state.settings.receiptsPerPage+' 张',detail:'导入 Excel 后,按已勾选的学校、年级、收费项目分组显示。'}
|
||||
if(rows.value.find(row=>row.index===state.rowIndex)?.errors.length)return {title:'当前行需要修改',detail:'请在左侧选择一条有效数据,查看打印排版。'}
|
||||
const first=printRows.value[0]
|
||||
if(!first)return {title:'尚未选择可打印的收据',detail:'请先在左侧勾选需要生成的有效数据。'}
|
||||
const labels=receiptGroupLabels(first.receipt),key=JSON.stringify(labels)
|
||||
const count=selectedRows.value.filter(row=>!row.errors.length&&JSON.stringify(receiptGroupLabels(row.receipt))===key).length
|
||||
const detail=state.settings.receiptsPerPage===2&&printRows.value.length===1
|
||||
?(count===1?'当前分组仅选中 1 张,下半页留白。':'本组最后一页仅剩 1 张,下半页留白。')+'每个分组单独排版。'
|
||||
:'每个分组单独排版,点击左侧数据可查看它所在的打印页。'
|
||||
return {title:labels.join(' / ')+' · 已选 '+count+' 张 · 本页 '+printRows.value.length+' 张',detail}
|
||||
})
|
||||
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[]){
|
||||
@@ -43,7 +56,7 @@ const percent=computed(()=>state.progress?Math.round(state.progress.completed/Ma
|
||||
<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>
|
||||
<details v-if="state.draft" :key="state.draft.id" class="panel group-review">
|
||||
<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>
|
||||
@@ -62,6 +75,7 @@ const percent=computed(()=>state.progress?Math.round(state.progress.completed/Ma
|
||||
</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>
|
||||
<div v-if="state.previewLayout==='print'" class="print-preview-summary" role="status"><Printer :size="15"/><div><strong>{{printPreviewInfo.title}}</strong><p>{{printPreviewInfo.detail}}</p></div></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>
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ export interface ReceiptAPI {
|
||||
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>;
|
||||
preview(request: { draftId?: string; rowIndex?: number; rowIndices?:number[]; batchId?: string; receiptNumber?:string; sealId?: string; layout: 'single' | 'print'; receiptsPerPage?:1|2 }): Promise<string>;
|
||||
exportReceipts(request: ExportRequest): Promise<string>;
|
||||
cancelExport(): Promise<void>;
|
||||
listBatches(query?:BatchQuery): Promise<BatchSummary[]>;
|
||||
|
||||
Reference in New Issue
Block a user