diff --git a/README.md b/README.md index 7e62a4e..6c47b12 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 57b0a36..4ec0c86 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 在设置校验器中为旧配置补默认值,修改后持久保存。筛选或每页条数变化时回到第一页,刷新保留当前页;使用请求版本号保持最新结果。显式翻页沿用整块加载反馈,自动筛选合并短时间内的输入变化。过滤结果仍对应完整批次,重新导出维持原有批次语义。原收据预览可指定匹配编号,旧批次输出目录经过全库归属检查。 diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 47c012b..74cbcdf 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -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 数据,用户数据未改动;未重新生成安装包。关闭程序后重新启动本地调试生效。 diff --git a/docs/用户使用说明.txt b/docs/用户使用说明.txt new file mode 100644 index 0000000..f58a8b3 --- /dev/null +++ b/docs/用户使用说明.txt @@ -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 用户的数据各自独立。 diff --git a/package-lock.json b/package-lock.json index a605a96..7958517 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4d9637e..1f72254 100644 --- a/package.json +++ b/package.json @@ -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/**/*" ], diff --git a/scripts/desktop-test.mjs b/scripts/desktop-test.mjs index b301621..f7786ec 100644 --- a/scripts/desktop-test.mjs +++ b/scripts/desktop-test.mjs @@ -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) diff --git a/scripts/package-test.mjs b/scripts/package-test.mjs index 115e191..6260502 100644 --- a/scripts/package-test.mjs +++ b/scripts/package-test.mjs @@ -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(()=>{})} diff --git a/src/core/preview-samples.ts b/src/core/preview-samples.ts new file mode 100644 index 0000000..752c072 --- /dev/null +++ b/src/core/preview-samples.ts @@ -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') + })) +} diff --git a/src/main/index.ts b/src/main/index.ts index 11c98bf..a6f04a5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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)) diff --git a/src/renderer/state.ts b/src/renderer/state.ts index 712582d..148139b 100644 --- a/src/renderer/state.ts +++ b/src/renderer/state.ts @@ -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} diff --git a/src/renderer/style.css b/src/renderer/style.css index cdbffed..525b920 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -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} diff --git a/src/renderer/views/WorkbenchView.vue b/src/renderer/views/WorkbenchView.vue index fc63587..35280b2 100644 --- a/src/renderer/views/WorkbenchView.vue +++ b/src/renderer/views/WorkbenchView.vue @@ -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
¥
本批合计金额{{displayMoney(total)}}
人民币 / 元
本批使用印章{{selectedSeal?.name||'暂未选择'}}
-
+

按分组核对收据 {{groupCount}} 组

学校 → 年级 → 收费项目,也是导出文件夹的层级;可整组勾选。

{{state.selected.size}} 张已选 · ¥{{displayMoney(total)}}
@@ -62,6 +75,7 @@ const percent=computed(()=>state.progress?Math.round(state.progress.completed/Ma

收据预览

{{state.draft?'点击左侧数据,核对收据内容':'示例样张 · 导入后替换为实际数据'}}

实时预览
{{state.previewLayout==='single'?'190 × 140 mm':'A4 · 每页 '+state.settings.receiptsPerPage+' 张'}}
+
绑定印章文件夹,下次自动加载 diff --git a/src/shared/types.ts b/src/shared/types.ts index 7c7239f..0938bc1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -43,7 +43,7 @@ export interface ReceiptAPI { importExcel(): Promise; saveExcelTemplate(): Promise; listSeals(): Promise; - preview(request: { draftId?: string; rowIndex?: number; rowIndices?:number[]; batchId?: string; receiptNumber?:string; sealId?: string; layout: 'single' | 'print' }): Promise; + preview(request: { draftId?: string; rowIndex?: number; rowIndices?:number[]; batchId?: string; receiptNumber?:string; sealId?: string; layout: 'single' | 'print'; receiptsPerPage?:1|2 }): Promise; exportReceipts(request: ExportRequest): Promise; cancelExport(): Promise; listBatches(query?:BatchQuery): Promise;