Initial commit: Receipt Studio desktop application

This commit is contained in:
fuenmao
2026-10-19 11:09:09 +08:00
commit 2caac3c31f
77 changed files with 9846 additions and 0 deletions
+44
View 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>
+12
View File
@@ -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>
+24
View File
@@ -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>
+46
View File
@@ -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>
+23
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
import type { ReceiptAPI } from '../shared/types'
declare global { interface Window { receipts: ReceiptAPI } }
export {}
+1
View File
@@ -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>
+21
View File
@@ -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}
})
}
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+21
View File
@@ -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.
+1
View File
@@ -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>
+15
View File
@@ -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()}
}
+176
View File
@@ -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
+94
View File
@@ -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>
+21
View File
@@ -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>
+49
View File
@@ -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>
+94
View File
@@ -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>