Initial commit: Receipt Studio desktop application
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed,onMounted,onBeforeUnmount,watch } from 'vue'
|
||||
import { FileSpreadsheet,Stamp,History,Settings2,ShieldCheck,ArrowUpRight,LoaderCircle,TriangleAlert } from '@lucide/vue'
|
||||
import WorkbenchView from './views/WorkbenchView.vue'
|
||||
import SealsView from './views/SealsView.vue'
|
||||
import HistoryView from './views/HistoryView.vue'
|
||||
import SettingsView from './views/SettingsView.vue'
|
||||
import { state,actions } from './state'
|
||||
import ConfirmDialog from './components/ConfirmDialog.vue'
|
||||
import { askConfirmation,answerConfirmation } from './confirmations'
|
||||
const navigation=[{id:'workbench',label:'收据工作台',icon:FileSpreadsheet},{id:'seals',label:'印章管理',icon:Stamp},{id:'history',label:'生成记录',icon:History},{id:'settings',label:'偏好设置',icon:Settings2}] as const
|
||||
const titles={workbench:['收据工作台','从一份表格,生成整齐的每一张收据。'],seals:['印章管理','绑定本地文件夹,常用印章随时可选。'],history:['生成记录','查找已生成的批次,使用原编号重新导出。'],settings:['偏好设置','记住你的习惯,让每次开具更顺手。']}
|
||||
const heading=computed(()=>titles[state.page])
|
||||
async function navigate(page:typeof state.page){
|
||||
if(page===state.page)return
|
||||
if(state.page==='settings'&&state.settingsDirty&&!await askConfirmation({title:'放弃未保存的设置?',description:'离开后,本次尚未保存的更改不会保留。',variant:'warning',confirmLabel:'放弃更改',cancelLabel:'继续编辑'}))return
|
||||
state.settingsDirty=false;state.page=page
|
||||
}
|
||||
let stopConfirmations:(()=>void)|undefined
|
||||
onMounted(()=>{
|
||||
stopConfirmations=window.receipts?.onConfirmation?.(request=>{
|
||||
void askConfirmation(request).then(confirmed=>window.receipts.answerConfirmation(request.id,confirmed)).catch(error=>{actions.reportError(state.page==='workbench'?'export':state.page,error instanceof Error?error.message:'确认操作失败')})
|
||||
})
|
||||
void actions.init()
|
||||
})
|
||||
onBeforeUnmount(()=>{stopConfirmations?.();answerConfirmation(false)})
|
||||
watch(()=>state.page,page=>{window.scrollTo(0,0);if(page==='workbench'&&state.ready)void actions.refreshPreview()})
|
||||
</script>
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><div class="brand-mark">收</div><div><strong>收据工坊</strong><span>RECEIPT STUDIO</span></div></div>
|
||||
<div class="nav-label">我的工作空间</div>
|
||||
<nav><button v-for="nav in navigation" :key="nav.id" :class="['nav-item',{active:state.page===nav.id}]" @click="navigate(nav.id)"><component :is="nav.icon" :size="19"/><span>{{nav.label}}</span><span v-if="nav.id==='history'&&state.historyTotal" class="nav-count">{{state.historyTotal}}</span></button></nav>
|
||||
<div class="sidebar-bottom"><div class="offline-note"><ShieldCheck :size="19"/><div><strong>安心在本地处理</strong><span>无需登录 · 离线可用</span></div></div><div class="app-version">收据工坊 v{{state.version}}<span>WIN 64</span></div></div>
|
||||
</aside>
|
||||
<main class="main-content">
|
||||
<header class="page-header"><div><div class="eyebrow">YOUR LOCAL WORKSPACE</div><h1>{{heading[0]}}</h1><p>{{heading[1]}}</p></div><div class="header-status"><span class="status-dot"></span>本地工作空间 <ArrowUpRight :size="15"/></div></header>
|
||||
<div v-if="!state.ready" class="loading-state"><TriangleAlert v-if="state.error" :size="25"/><LoaderCircle v-else class="spin" :size="25"/><span>{{state.error||'正在准备本地工作空间…'}}</span></div>
|
||||
<template v-else><WorkbenchView v-if="state.page==='workbench'"/><SealsView v-else-if="state.page==='seals'"/><HistoryView v-else-if="state.page==='history'"/><SettingsView v-else/></template>
|
||||
</main>
|
||||
<ConfirmDialog/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { state } from '../state'
|
||||
const props=withDefaults(defineProps<{task:string;target?:string;loading?:boolean;disabled?:boolean;iconOnly?:boolean;type?:'button'|'submit'|'reset'}>(),{type:'button',loading:undefined})
|
||||
const matches=computed(()=>props.target===undefined||state.taskTargets.get(props.task)===props.target)
|
||||
const pending=computed(()=>props.loading??(matches.value&&state.busy.has(props.task)))
|
||||
</script>
|
||||
<template>
|
||||
<button :type="type" :disabled="disabled||pending" :aria-busy="pending||undefined" :data-task="task" :data-task-target="target" :data-phase="pending?'pending':'idle'">
|
||||
<span v-if="$slots.icon" class="action-icon" aria-hidden="true"><slot name="icon"/></span><slot v-if="!iconOnly"/>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { AlertDialogRoot,AlertDialogPortal,AlertDialogOverlay,AlertDialogContent,AlertDialogTitle,AlertDialogDescription,AlertDialogCancel } from 'reka-ui'
|
||||
import { Trash2,TriangleAlert,CircleHelp,ShieldCheck,X } from '@lucide/vue'
|
||||
import { confirmation,answerConfirmation,restoreConfirmationFocus } from '../confirmations'
|
||||
const icon=computed(()=>confirmation.value?.variant==='danger'?Trash2:confirmation.value?.variant==='warning'?TriangleAlert:CircleHelp)
|
||||
</script>
|
||||
<template>
|
||||
<AlertDialogRoot :open="!!confirmation" @update:open="open=>{if(!open)answerConfirmation(false)}">
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay class="confirm-overlay"/>
|
||||
<AlertDialogContent :class="['confirm-dialog',{'confirm-danger':confirmation?.variant==='danger','confirm-warning':confirmation?.variant==='warning'}]" @close-auto-focus="restoreConfirmationFocus">
|
||||
<button class="icon-button confirm-close" aria-label="关闭确认窗口" @click="answerConfirmation(false)"><X :size="18"/></button>
|
||||
<div class="confirm-heading"><span class="confirm-icon"><component :is="icon" :size="24" :stroke-width="1.6"/></span><div><AlertDialogTitle class="confirm-title">{{confirmation?.title}}</AlertDialogTitle><AlertDialogDescription class="confirm-description">{{confirmation?.description}}</AlertDialogDescription></div></div>
|
||||
<dl v-if="confirmation?.details?.length" class="confirm-details"><div v-for="(detail,index) in confirmation.details" :key="index"><dt>{{detail.label}}</dt><dd>{{detail.value}}</dd></div></dl>
|
||||
<p v-if="confirmation?.note" class="confirm-note"><ShieldCheck :size="16"/><span>{{confirmation.note}}</span></p>
|
||||
<div class="confirm-actions">
|
||||
<AlertDialogCancel as-child><button class="button" @click="answerConfirmation(false)">{{confirmation?.cancelLabel||'取消'}}</button></AlertDialogCancel>
|
||||
<button :class="['button','confirm-accept',confirmation?.variant==='danger'?'destructive':'primary']" @click="answerConfirmation(true)">{{confirmation?.confirmLabel||'确定'}}</button>
|
||||
</div>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick,onBeforeUnmount,onMounted,ref,watch } from 'vue'
|
||||
import { LoaderCircle,TriangleAlert,X } from '@lucide/vue'
|
||||
const props=withDefaults(defineProps<{as?:string;loading?:boolean;label?:string;error?:string;progress?:number;delay?:number}>(),{as:'div',loading:false,label:'正在处理…',error:'',delay:0})
|
||||
const emit=defineEmits<{dismissError:[]}>()
|
||||
const container=ref<HTMLElement>(),visible=ref(false),center=ref(80)
|
||||
let timer:ReturnType<typeof setTimeout>|undefined,observer:ResizeObserver|undefined,previousFocus:HTMLElement|null=null
|
||||
function position(){
|
||||
const rect=container.value?.getBoundingClientRect();if(!rect)return
|
||||
const top=Math.max(0,rect.top),bottom=Math.min(innerHeight,rect.bottom)
|
||||
center.value=bottom>top?(top+bottom)/2-rect.top:rect.height/2
|
||||
}
|
||||
watch(()=>props.loading,async loading=>{
|
||||
clearTimeout(timer)
|
||||
if(loading){
|
||||
previousFocus=document.activeElement instanceof HTMLElement&&container.value?.contains(document.activeElement)?document.activeElement:null
|
||||
if(props.delay)timer=setTimeout(()=>{visible.value=true;void nextTick(position)},props.delay)
|
||||
else visible.value=true
|
||||
}else{
|
||||
visible.value=false
|
||||
await nextTick()
|
||||
if(previousFocus?.isConnected&&!previousFocus.matches(':disabled')&&document.activeElement===document.body)previousFocus.focus({preventScroll:true})
|
||||
previousFocus=null
|
||||
}
|
||||
await nextTick();position()
|
||||
},{immediate:true})
|
||||
onMounted(()=>{
|
||||
observer=new ResizeObserver(position);if(container.value)observer.observe(container.value)
|
||||
window.addEventListener('scroll',position,{capture:true,passive:true});window.addEventListener('resize',position);position()
|
||||
})
|
||||
onBeforeUnmount(()=>{clearTimeout(timer);observer?.disconnect();window.removeEventListener('scroll',position,true);window.removeEventListener('resize',position)})
|
||||
</script>
|
||||
<template>
|
||||
<component :is="as" ref="container" class="loading-region" :aria-busy="loading||undefined">
|
||||
<div v-if="error" class="region-error" role="alert"><TriangleAlert :size="16"/><span>{{error}}</span><button class="icon-button" type="button" aria-label="关闭本板块错误提示" @click="emit('dismissError')"><X :size="15"/></button></div>
|
||||
<div class="region-content" :inert="loading||undefined"><slot/></div>
|
||||
<Transition name="region-loading">
|
||||
<div v-if="visible" class="region-loading" role="status" aria-live="polite">
|
||||
<div class="region-loading-indicator" :style="{top:center+'px'}"><LoaderCircle class="region-spinner" :size="30" :stroke-width="1.7"/><span>{{label}}</span>
|
||||
<div v-if="progress!==undefined" class="region-loading-progress"><div><i :style="{width:Math.min(100,Math.max(0,progress))+'%'}"/></div><span>{{progress}}%</span></div>
|
||||
<slot name="loading-actions"/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</component>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import {computed,reactive,ref} from 'vue'
|
||||
import {ChevronRight} from '@lucide/vue'
|
||||
import {displayMoney} from '../../core/money'
|
||||
import type {ImportGroupNode} from '../../shared/receipt-groups'
|
||||
const props=withDefaults(defineProps<{nodes:ImportGroupNode[];disabled?:boolean;expanded?:boolean;depth?:number}>(),{disabled:false,expanded:true,depth:0})
|
||||
const emit=defineEmits<{toggle:[indices:number[]]}>()
|
||||
const shown=ref(50),opened=reactive(new Map<string,boolean>())
|
||||
const visible=computed(()=>props.nodes.slice(0,shown.value))
|
||||
function isOpen(node:ImportGroupNode){return opened.get(node.key)??(props.expanded||props.depth===0)}
|
||||
function setOpen(node:ImportGroupNode,event:Event){if(event.target instanceof HTMLDetailsElement)opened.set(node.key,event.target.open)}
|
||||
function label(node:ImportGroupNode){return '选择分组:'+JSON.parse(node.key).join(' / ')}
|
||||
</script>
|
||||
<template>
|
||||
<div class="receipt-group-tree" :class="{'group-tree-root':depth===0}">
|
||||
<component :is="node.children.length?'details':'div'" v-for="node in visible" :key="node.key" class="receipt-group-node" :open="node.children.length&&isOpen(node)?true:undefined" @toggle="setOpen(node,$event)">
|
||||
<component :is="node.children.length?'summary':'label'" class="receipt-group-choice" :class="{'group-leaf':!node.children.length}">
|
||||
<ChevronRight v-if="node.children.length" class="group-chevron" :size="14" aria-hidden="true"/><span v-else class="group-leaf-mark" aria-hidden="true"/>
|
||||
<input type="checkbox" :aria-label="label(node)" :checked="node.indices.length>0&&node.selectedCount===node.indices.length" :indeterminate="node.selectedCount>0&&node.selectedCount<node.indices.length" :disabled="disabled||!node.indices.length" @click.stop @keydown.stop @change="emit('toggle',node.indices)">
|
||||
<span class="group-name" :title="node.label">{{node.label}}</span>
|
||||
<span v-if="node.invalidCount" class="group-invalid">{{node.invalidCount}} 条待修改</span>
|
||||
<span class="group-count">已选 {{node.selectedCount}} / {{node.indices.length}} 张</span>
|
||||
<span class="group-amount" :title="'本组有效数据合计 '+displayMoney(node.totalCents)+' 元'">¥{{displayMoney(node.selectedCents)}}</span>
|
||||
</component>
|
||||
<ReceiptGroupTree v-if="node.children.length&&isOpen(node)" :nodes="node.children" :disabled="disabled" :expanded="expanded" :depth="depth+1" @toggle="emit('toggle',$event)"/>
|
||||
</component>
|
||||
<button v-if="nodes.length>shown" type="button" class="text-button group-show-more" @click="shown+=50">显示更多分组(还有 {{nodes.length-shown}} 项)</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed,onBeforeUnmount,onMounted,ref,watch } from 'vue'
|
||||
const props=defineProps<{ html:string; layout?:'single'|'print' }>()
|
||||
const container=ref<HTMLDivElement>(),scale=ref(.6)
|
||||
let observer:ResizeObserver|undefined
|
||||
const paperWidth=computed(()=>(props.layout==='print'?210:190)*96/25.4)
|
||||
const paperHeight=computed(()=>(props.layout==='print'?297:140)*96/25.4)
|
||||
function resize(){if(container.value)scale.value=Math.min((container.value.clientWidth-36)/paperWidth.value,1)}
|
||||
onMounted(()=>{observer=new ResizeObserver(resize);if(container.value)observer.observe(container.value);resize()})
|
||||
watch(()=>props.layout,resize)
|
||||
onBeforeUnmount(()=>observer?.disconnect())
|
||||
</script>
|
||||
<template>
|
||||
<div ref="container" class="preview-canvas">
|
||||
<div v-if="html" class="paper-wrap" :style="{width:paperWidth*scale+'px',height:paperHeight*scale+'px'}">
|
||||
<iframe title="收据打印预览" sandbox="" scrolling="no" :srcdoc="html" :style="{width:paperWidth+'px',height:paperHeight+'px',transform:'scale('+scale+')'}"></iframe>
|
||||
</div>
|
||||
<div v-else class="preview-placeholder">选择一条有效数据,查看收据预览</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { shallowRef } from 'vue'
|
||||
import type { ConfirmationOptions } from '../shared/types'
|
||||
export const confirmation=shallowRef<ConfirmationOptions|null>(null)
|
||||
let resolvePending:((confirmed:boolean)=>void)|null=null
|
||||
let previousFocus:HTMLElement|null=null
|
||||
|
||||
export function askConfirmation(options:ConfirmationOptions):Promise<boolean>{
|
||||
if(resolvePending)return Promise.resolve(false)
|
||||
previousFocus=document.activeElement instanceof HTMLElement?document.activeElement:null
|
||||
confirmation.value=options
|
||||
return new Promise(resolve=>{resolvePending=resolve})
|
||||
}
|
||||
export function answerConfirmation(confirmed:boolean):void{
|
||||
const resolve=resolvePending
|
||||
resolvePending=null;confirmation.value=null
|
||||
resolve?.(confirmed)
|
||||
}
|
||||
export function restoreConfirmationFocus(event:Event):void{
|
||||
event.preventDefault()
|
||||
if(previousFocus?.isConnected&&previousFocus!==document.body&&!previousFocus.matches(':disabled'))previousFocus.focus({preventScroll:true})
|
||||
else document.querySelector<HTMLButtonElement>('.nav-item.active')?.focus({preventScroll:true})
|
||||
previousFocus=null
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
import type { ReceiptAPI } from '../shared/types'
|
||||
declare global { interface Window { receipts: ReceiptAPI } }
|
||||
export {}
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self' about:; connect-src 'self' ws://127.0.0.1:5173; worker-src 'self' blob:"><title>收据工坊</title></head><body><div id="app"></div><script type="module" src="./main.ts"></script></body></html>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { computed } from 'vue'
|
||||
import { state } from './state'
|
||||
export type PanelScope='settings'|'seals'|'history'|'data'|'preview'|'export'
|
||||
const labels:Record<string,string>={
|
||||
settings:'正在保存设置…',
|
||||
'refresh-seals':'正在刷新印章列表…',
|
||||
'refresh-records':'正在刷新生成记录…','history-preview':'正在加载收据预览…','delete-batch':'正在删除批次记录…',
|
||||
'history-page':'正在加载生成记录…',
|
||||
import:'正在导入收据数据…',template:'正在保存导入模板…',export:'正在准备生成…',cancel:'正在取消…'
|
||||
}
|
||||
export function usePanelState(scope:PanelScope){
|
||||
return computed(()=>{
|
||||
const error=state.panelErrors.get(scope)||''
|
||||
if(state.exporting&&scope===state.exportPanel){
|
||||
const progress=state.progress?Math.round(state.progress.completed/Math.max(1,state.progress.total)*100):undefined
|
||||
return {loading:true,delay:0,label:state.cancelRequested?'正在取消…':state.progress?.stage||'正在准备生成收据…',progress,error}
|
||||
}
|
||||
const task=[...state.loadingTasks].find(key=>state.taskPanels.get(key)===scope)
|
||||
return {loading:!!task||scope==='preview'&&state.previewLoading,delay:task?0:120,label:task?labels[task]||'正在处理…':'正在更新收据预览…',progress:undefined,error}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
createApp(App).mount('#app')
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 UnoVue <https://github.com/unovue>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self' blob:; style-src 'unsafe-inline'; img-src 'self' data: blob:"></head><body><canvas id="canvas"></canvas><script type="module" src="./raster.ts"></script></body></html>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getDocument,GlobalWorkerOptions } from 'pdfjs-dist'
|
||||
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
||||
GlobalWorkerOptions.workerSrc=workerUrl
|
||||
declare global { interface Window {renderPdf:(base64:string,dpi:number)=>Promise<string>} }
|
||||
window.renderPdf=async(base64,dpi)=>{
|
||||
const data=Uint8Array.from(atob(base64),v=>v.charCodeAt(0))
|
||||
const doc=await getDocument({data}).promise
|
||||
try{
|
||||
const page=await doc.getPage(1),viewport=page.getViewport({scale:dpi/72})
|
||||
const canvas=document.getElementById('canvas') as HTMLCanvasElement
|
||||
canvas.width=Math.ceil(viewport.width);canvas.height=Math.ceil(viewport.height)
|
||||
await page.render({canvas,canvasContext:canvas.getContext('2d')!,viewport,background:'white'}).promise
|
||||
return canvas.toDataURL('image/png')
|
||||
}finally{await doc.destroy()}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { nextTick,reactive } from 'vue'
|
||||
import { defaultSettings } from '../shared/settings'
|
||||
import { conciseError } from '../shared/errors'
|
||||
import type { AppSettings,BatchSummary,ExportProgress,HistoryFilters,ImportDraft,SealImage } from '../shared/types'
|
||||
export const state=reactive({
|
||||
page:'workbench' as 'workbench'|'seals'|'history'|'settings',settings:defaultSettings(),draft:null as ImportDraft|null,
|
||||
selected:new Set<number>(),rowIndex:0,seals:[] as SealImage[],sealWarnings:[] as string[],currentSealId:'',
|
||||
previewHtml:'',previewLayout:'single' as 'single'|'print',previewLoading:false,batches:[] as BatchSummary[],historyTotal:0,historyFilters:{schools:[],grades:[],items:[]} as HistoryFilters,nextNumber:'',
|
||||
dataDirectory:'',version:'1.0.0',templates:[] as {id:string;name:string}[],
|
||||
settingsDirty:false,busy:new Set<string>(),loadingTasks:new Set<string>(),taskTargets:new Map<string,string>(),taskPanels:new Map<string,string>(),panelErrors:new Map<string,string>(),
|
||||
error:'',cancelRequested:false,exporting:false,exportBatchId:'',exportPanel:'export',progress:null as ExportProgress|null,ready:false
|
||||
})
|
||||
interface TaskFeedback {target?:string;feedback?:boolean;scope?:string}
|
||||
const processingTasks=new Set(['import','template','delete-batch'])
|
||||
const quietTasks=new Set(['init','settings-folder','folder','open','seal','cancel'])
|
||||
const loadingMinimums=new Map<string,Promise<void>>()
|
||||
function startLoading(key:string,minimumMs=500){
|
||||
if(state.loadingTasks.has(key))return
|
||||
state.loadingTasks.add(key)
|
||||
loadingMinimums.set(key,(async()=>{
|
||||
await nextTick()
|
||||
// Count visible time after the first painted frame, not from the click or IPC call.
|
||||
await new Promise<void>(resolve=>{
|
||||
let frame=0
|
||||
const finish=()=>{clearTimeout(timer);cancelAnimationFrame(frame);resolve()}
|
||||
const timer=setTimeout(finish,100)
|
||||
frame=requestAnimationFrame(()=>{frame=requestAnimationFrame(finish)})
|
||||
})
|
||||
if(minimumMs)await new Promise(resolve=>setTimeout(resolve,minimumMs))
|
||||
})())
|
||||
}
|
||||
function taskPanel(key:string):string{
|
||||
if(['settings','settings-folder'].includes(key))return 'settings'
|
||||
if(['refresh-records','history-preview','delete-batch'].includes(key))return 'history'
|
||||
if(['import','template'].includes(key))return 'data'
|
||||
if(['export','cancel'].includes(key))return state.page==='history'?'history':'export'
|
||||
if(key==='refresh-seals'||key==='seal')return state.page==='seals'?'seals':state.page==='settings'?'settings':'preview'
|
||||
if(key==='folder')return state.page==='seals'?'seals':state.taskTargets.get(key)==='output'?'export':'preview'
|
||||
return state.page==='workbench'?'export':state.page
|
||||
}
|
||||
let previewVersion=0,recordsVersion=0,sealsVersion=0
|
||||
async function fetchRecords(){
|
||||
const version=++recordsVersion,boot=await window.receipts.bootstrap()
|
||||
if(version===recordsVersion){state.batches=boot.batches;state.historyTotal=boot.historyTotal??boot.batches.length;state.historyFilters=boot.historyFilters||{schools:[],grades:[],items:[]};state.nextNumber=boot.nextNumber}
|
||||
}
|
||||
async function fetchSeals():Promise<boolean>{
|
||||
const version=++sealsVersion
|
||||
try{
|
||||
const list=await window.receipts.listSeals()
|
||||
if(version===sealsVersion){state.seals=list.images;state.sealWarnings=list.warnings}
|
||||
return true
|
||||
}catch(error){
|
||||
if(version===sealsVersion){state.seals=[];state.sealWarnings=[conciseError(error)]}
|
||||
return false
|
||||
}
|
||||
}
|
||||
export const actions={
|
||||
reportError(scope:string,message:string){if(scope==='init')state.error=message;else state.panelErrors.set(scope,message)},
|
||||
clearError(scope:string){state.panelErrors.delete(scope)},
|
||||
async task<T>(key:string,work:()=>Promise<T>,options:TaskFeedback={}):Promise<T|null>{
|
||||
if(state.busy.has(key))return null
|
||||
if(options.target!==undefined)state.taskTargets.set(key,options.target);else state.taskTargets.delete(key)
|
||||
const scope=options.scope||taskPanel(key);state.taskPanels.set(key,scope);state.panelErrors.delete(scope)
|
||||
state.busy.add(key);if(key==='init')state.error=''
|
||||
if(!quietTasks.has(key)&&(!processingTasks.has(key)||!window.receipts.onProcessing))startLoading(key,options.feedback===false?0:500)
|
||||
let result:T|null=null,errorMessage=''
|
||||
try{result=await work()}
|
||||
catch(error){errorMessage=conciseError(error)}
|
||||
finally{
|
||||
await loadingMinimums.get(key)
|
||||
loadingMinimums.delete(key);state.loadingTasks.delete(key);state.busy.delete(key)
|
||||
if(errorMessage)actions.reportError(scope,errorMessage)
|
||||
}
|
||||
return result
|
||||
},
|
||||
async init(){
|
||||
if(!window.receipts){state.error='请使用桌面程序打开收据工坊';return}
|
||||
window.receipts.onProcessing?.(key=>{if(state.busy.has(key))startLoading(key)})
|
||||
window.receipts.onProgress(p=>{
|
||||
state.progress=p;state.exporting=!p.finished;if(p.finished)state.cancelRequested=false
|
||||
if(p.finished){
|
||||
const failed=p.status==='failed'||p.status==='partial'
|
||||
if(failed)actions.reportError(state.exportPanel,conciseError(p.errors.slice(0,3).join(';')||p.stage))
|
||||
void actions.refreshRecords()
|
||||
}
|
||||
})
|
||||
await actions.task('init',async()=>{await actions.reload();state.ready=true;await actions.loadSeals();await actions.refreshPreview()},{feedback:false,scope:'init'})
|
||||
},
|
||||
async reload(){
|
||||
const boot=await window.receipts.bootstrap()
|
||||
state.settings=boot.settings;state.batches=boot.batches;state.historyTotal=boot.historyTotal??boot.batches.length;state.historyFilters=boot.historyFilters||{schools:[],grades:[],items:[]};state.nextNumber=boot.nextNumber
|
||||
state.dataDirectory=boot.dataDirectory;state.version=boot.version;state.templates=boot.templates
|
||||
state.currentSealId=boot.settings.defaultSealId
|
||||
},
|
||||
async refreshRecords(feedback=false){
|
||||
if(!feedback)return fetchRecords()
|
||||
await actions.task('refresh-records',async()=>{await fetchRecords();return true})
|
||||
},
|
||||
async loadSeals(feedback=false){
|
||||
if(!feedback)return fetchSeals()
|
||||
return actions.task('refresh-seals',async()=>{
|
||||
if(!await fetchSeals())throw Error(state.sealWarnings[0]||'印章列表刷新失败')
|
||||
return true
|
||||
})
|
||||
},
|
||||
async importExcel(){
|
||||
await actions.task('import',async()=>{
|
||||
const draft=await window.receipts.importExcel();if(!draft)return false
|
||||
state.draft=draft;state.selected=new Set(draft.rows.filter(r=>!r.errors.length).map(r=>r.index))
|
||||
state.rowIndex=draft.rows.find(r=>!r.errors.length)?.index??0;state.page='workbench';state.progress=null
|
||||
await actions.refreshPreview();return true
|
||||
})
|
||||
},
|
||||
async template(){
|
||||
let saved=''
|
||||
await actions.task('template',async()=>{saved=await window.receipts.saveExcelTemplate()||'';return !!saved})
|
||||
},
|
||||
async refreshPreview(showLoading=true){
|
||||
const version=++previewVersion
|
||||
const row=state.draft?.rows.find(r=>r.index===state.rowIndex)
|
||||
if(row?.errors.length){state.previewHtml='';state.previewLoading=false;return}
|
||||
if(state.draft&&state.previewLayout==='print'&&!state.selected.size){state.previewHtml='';state.previewLoading=false;return}
|
||||
state.previewLoading=showLoading
|
||||
try{
|
||||
const html=await window.receipts.preview({draftId:state.draft?.id,rowIndex:state.rowIndex,rowIndices:state.previewLayout==='print'?[...state.selected]:undefined,sealId:state.currentSealId,layout:state.previewLayout})
|
||||
if(version===previewVersion)state.previewHtml=html
|
||||
}catch(e){if(version===previewVersion){state.previewHtml='';actions.reportError(state.page==='settings'?'settings':'preview',conciseError(e))}}
|
||||
finally{if(version===previewVersion)state.previewLoading=false}
|
||||
},
|
||||
async selectSeal(id:string){
|
||||
await actions.task('seal',async()=>{
|
||||
state.settings=await window.receipts.saveSettings({...state.settings,defaultSealId:id})
|
||||
state.currentSealId=id;await actions.refreshPreview();return true
|
||||
},{target:id})
|
||||
},
|
||||
async chooseAndSaveFolder(kind:'output'|'seal'){
|
||||
await actions.task('folder',async()=>{
|
||||
const folder=await window.receipts.chooseFolder(kind);if(!folder)return false
|
||||
state.settings=await window.receipts.saveSettings({...state.settings,[kind==='seal'?'sealDirectory':'outputDirectory']:folder,...(kind==='seal'?{defaultSealId:''}:{})})
|
||||
if(kind==='seal'){state.currentSealId='';await actions.loadSeals();await actions.refreshPreview(false)}
|
||||
return true
|
||||
},{target:kind})
|
||||
},
|
||||
async saveSettings(settings:AppSettings){
|
||||
return actions.task('settings',async()=>{
|
||||
state.settings=await window.receipts.saveSettings(settings);state.currentSealId=state.settings.defaultSealId
|
||||
await actions.loadSeals();await actions.refreshRecords();await actions.refreshPreview();return true
|
||||
})
|
||||
},
|
||||
async export(batchId?:string){
|
||||
if(state.exporting||state.busy.has('delete-batch'))return
|
||||
if(!batchId&&(!state.draft||!state.selected.size)){actions.reportError('export','请先导入 Excel 并选择有效数据');return}
|
||||
if(!state.settings.exportPdf&&!state.settings.exportPng&&!state.settings.exportPrint){actions.reportError(state.page==='history'?'history':'export','请至少选择一种导出内容');return}
|
||||
await actions.task('export',async()=>{
|
||||
state.exporting=true;state.cancelRequested=false;state.exportBatchId=batchId||'';state.exportPanel=state.page==='history'?'history':'export';state.progress=null
|
||||
try{
|
||||
state.settings=await window.receipts.saveSettings({...state.settings})
|
||||
await window.receipts.exportReceipts({batchId,draftId:state.draft?.id,rowIndices:[...state.selected],sealId:state.currentSealId,
|
||||
options:{pdf:state.settings.exportPdf,png:state.settings.exportPng,print:state.settings.exportPrint,receiptsPerPage:state.settings.receiptsPerPage,cutLine:state.settings.cutLine}})
|
||||
}catch(e){state.exporting=false;throw e}
|
||||
},{target:batchId||''})
|
||||
},
|
||||
async deleteBatch(id:string){
|
||||
if(state.exporting)return
|
||||
await actions.task('delete-batch',async()=>{
|
||||
if(!window.receipts.deleteBatch)throw Error('请关闭程序并重新启动本地调试,以启用删除记录功能')
|
||||
if(!await window.receipts.deleteBatch(id))return false
|
||||
if(state.draft?.previousBatchId===id)state.draft.previousBatchId=undefined
|
||||
if(state.progress?.batchId===id)state.progress=null
|
||||
await actions.refreshRecords();return true
|
||||
},{target:id})
|
||||
},
|
||||
async open(folder:string){await actions.task('open',async()=>{await window.receipts.openFolder(folder);return true},{target:folder})},
|
||||
async cancel(){await actions.task('cancel',async()=>{await window.receipts.cancelExport();state.cancelRequested=true;return true})}
|
||||
}
|
||||
export function statusText(status:BatchSummary['status']):string{return {reserved:'已分配编号',running:'生成中',completed:'已完成',partial:'部分完成',failed:'失败',cancelled:'已取消'}[status]}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { computed,onBeforeUnmount,reactive,ref,watch } from 'vue'
|
||||
import { History,RefreshCw,FolderOpen,RotateCcw,Eye,X,Trash2,ChevronLeft,ChevronRight } from '@lucide/vue'
|
||||
import { state,actions,statusText } from '../state'
|
||||
import { displayMoney } from '../../core/money'
|
||||
import ReceiptPreview from '../components/ReceiptPreview.vue'
|
||||
import ActionButton from '../components/ActionButton.vue'
|
||||
import LoadingRegion from '../components/LoadingRegion.vue'
|
||||
import { usePanelState } from '../loading'
|
||||
import {historyPageSizes} from '../../shared/types'
|
||||
import type {BatchPageQuery,BatchSummary,HistoryPageSize} from '../../shared/types'
|
||||
const panel=usePanelState('history')
|
||||
const query=reactive({search:'',school:'',grade:'',item:''}),html=ref(''),title=ref('')
|
||||
const batches=ref<BatchSummary[]>([]),searching=ref(true),total=ref(0),page=ref(1)
|
||||
const pageSize=ref<HistoryPageSize>(state.settings.historyPageSize||5)
|
||||
const previewMatch=ref(false)
|
||||
const hasFilters=computed(()=>Object.values(query).some(value=>value.trim()))
|
||||
const pageCount=computed(()=>Math.max(1,Math.ceil(total.value/pageSize.value)))
|
||||
const rangeStart=computed(()=>total.value?(page.value-1)*pageSize.value+1:0)
|
||||
const rangeEnd=computed(()=>Math.min(page.value*pageSize.value,total.value))
|
||||
const pageNumbers=computed(()=>{
|
||||
const start=Math.max(1,Math.min(page.value-2,pageCount.value-4))
|
||||
return Array.from({length:Math.min(5,pageCount.value)},(_,index)=>start+index)
|
||||
})
|
||||
let request=0,timer:ReturnType<typeof setTimeout>|undefined,queryFailed=false
|
||||
watch(()=>[query.search,query.school,query.grade,query.item],()=>{page.value=1;schedulePage()})
|
||||
watch(()=>state.batches,()=>schedulePage(),{immediate:true})
|
||||
function schedulePage(animated=false,persistSize=false){
|
||||
clearTimeout(timer)
|
||||
const version=++request
|
||||
searching.value=true
|
||||
const filters={...query,page:page.value,pageSize:pageSize.value}
|
||||
if(animated){
|
||||
const settings={...state.settings,historyPageSize:pageSize.value}
|
||||
void actions.task('history-page',async()=>{
|
||||
await Promise.all([findBatches(version,filters),persistSize?window.receipts.saveSettings(settings):Promise.resolve()])
|
||||
return true
|
||||
},{scope:'history'})
|
||||
}else timer=setTimeout(()=>{void findBatches(version,filters)},hasFilters.value?180:0)
|
||||
}
|
||||
async function findBatches(version:number,filters:BatchPageQuery){
|
||||
try{
|
||||
const result=await window.receipts.listBatchPage(filters)
|
||||
if(version===request){batches.value=result.items;total.value=result.total;page.value=result.page;pageSize.value=result.pageSize;if(queryFailed)actions.clearError('history');queryFailed=false}
|
||||
}catch(error){if(version===request){batches.value=[];total.value=0;page.value=1;queryFailed=true;actions.reportError('history',error instanceof Error?error.message:'加载生成记录失败')}}
|
||||
finally{if(version===request)searching.value=false}
|
||||
}
|
||||
function changePage(next:number){
|
||||
if(searching.value||panel.value.loading||next<1||next>pageCount.value||next===page.value)return
|
||||
page.value=next;schedulePage(true)
|
||||
}
|
||||
function changePageSize(){
|
||||
page.value=1;state.settings.historyPageSize=pageSize.value;schedulePage(true,true)
|
||||
}
|
||||
function clearFilters(){query.search='';query.school='';query.grade='';query.item=''}
|
||||
function category(batch:BatchSummary,field:'school'|'grade'|'item',full=false){
|
||||
const labels=[...new Set((batch.groups||[]).map(group=>group[field]))]
|
||||
if(!labels.length)return '未分类'
|
||||
return full?labels.join('、'):labels.slice(0,2).join('、')+(labels.length>2?' 等 '+labels.length+' 项':'')
|
||||
}
|
||||
onBeforeUnmount(()=>{clearTimeout(timer);request++})
|
||||
async function preview(batch:BatchSummary){await actions.task('history-preview',async()=>{html.value=await window.receipts.preview({batchId:batch.id,receiptNumber:batch.match?.number,layout:'single'});title.value=batch.match?batch.match.customer+' · '+batch.match.number:batch.sourceName;previewMatch.value=!!batch.match;return true},{target:batch.id})}
|
||||
function date(v:string){return new Date(v).toLocaleString('zh-CN',{hour12:false})}
|
||||
</script>
|
||||
<template>
|
||||
<LoadingRegion as="section" class="panel" data-region="history" :loading="panel.loading" :label="panel.label" :progress="panel.progress" :error="panel.error" @dismiss-error="actions.clearError('history')"><div class="panel-heading"><div><h2>最近生成的批次 <span class="badge">{{total}}</span></h2><p>重新导出沿用原编号、原数据和原印章,使用当前导出选项。</p></div><ActionButton class="button subtle" task="refresh-records" @click="actions.refreshRecords(true)"><template #icon><RefreshCw :size="16"/></template>刷新</ActionButton></div>
|
||||
<div v-if="!total&&!hasFilters&&!searching" class="large-empty"><span class="empty-circle"><History :size="34" :stroke-width="1.4"/></span><h3>每一批收据,都有迹可循</h3><p>完成首次生成后,可在这里查看记录和重新导出。</p><button class="button primary" @click="state.page='workbench'">前往工作台</button></div>
|
||||
<template v-else>
|
||||
<div class="history-filters"><label>学校<select class="input" aria-label="按学校查找记录" v-model="query.school"><option value="">全部学校</option><option v-for="school in state.historyFilters.schools" :key="school">{{school}}</option></select></label><label>年级<select class="input" aria-label="按年级查找记录" v-model="query.grade"><option value="">全部年级</option><option v-for="grade in state.historyFilters.grades" :key="grade">{{grade}}</option></select></label><label>收费项目<select class="input" aria-label="按收费项目查找记录" v-model="query.item"><option value="">全部收费项目</option><option v-for="item in state.historyFilters.items" :key="item">{{item}}</option></select></label></div>
|
||||
<div class="history-search"><input v-model="query.search" class="input" maxlength="200" placeholder="输入文件名、任意收据编号或学生姓名" aria-label="搜索生成记录"><button v-if="hasFilters" class="text-button" @click="clearFilters">清空筛选</button><span aria-live="polite">{{searching?'正在加载记录…':'共 '+total+' 个批次'}}</span></div>
|
||||
<p v-if="hasFilters" class="history-filter-note">按收据内容查找,下面显示匹配收据所在的完整批次;重新导出会导出该批全部收据。</p>
|
||||
<div class="history-table-wrap"><table class="data-table history-table"><thead><tr><th>生成批次</th><th>学校 / 年级 / 收费项目</th><th>编号范围</th><th>张数 / 金额</th><th>状态</th><th>操作</th></tr></thead><TransitionGroup tag="tbody" name="history-row"><tr v-for="batch in batches" :key="batch.id"><td class="history-source"><strong>{{batch.sourceName}}</strong><span class="table-secondary">{{date(batch.createdAt)}}</span><span v-if="batch.match" class="history-match">匹配 {{batch.match.count}} 张:{{batch.match.customer}} · {{batch.match.number}}</span></td><td class="history-category"><strong :title="category(batch,'school',true)">{{category(batch,'school')}}</strong><span class="table-secondary" :title="category(batch,'grade',true)">{{category(batch,'grade')}}</span><span class="table-secondary" :title="category(batch,'item',true)">{{category(batch,'item')}}</span></td><td class="numeric">{{batch.firstNumber}}<span class="table-secondary">至 {{batch.lastNumber}}</span></td><td>{{batch.count}} 张<span class="table-secondary">¥{{displayMoney(batch.totalCents)}}</span></td><td><span :class="['state-tag',batch.status==='completed'?'ok':batch.status==='failed'?'danger':'warning']">{{statusText(batch.status)}}</span><span v-if="batch.message&&batch.status!=='completed'" class="table-secondary history-message" :title="batch.message">{{batch.message}}</span></td><td><div class="button-row">
|
||||
<ActionButton class="icon-button" task="history-preview" :target="batch.id" icon-only title="查看原收据" aria-label="查看原收据" :disabled="state.busy.has('history-preview')" @click="preview(batch)"><template #icon><Eye :size="16"/></template></ActionButton>
|
||||
<ActionButton class="icon-button" task="open" :target="batch.outputDirectory" icon-only title="打开输出目录" aria-label="打开输出目录" :disabled="!batch.outputDirectory" @click="actions.open(batch.outputDirectory)"><template #icon><FolderOpen :size="16"/></template></ActionButton>
|
||||
<ActionButton class="button subtle compact" task="export" :target="batch.id" :loading="state.exporting&&state.exportBatchId===batch.id" :disabled="state.exporting||state.busy.has('delete-batch')" @click="actions.export(batch.id)"><template #icon><RotateCcw :size="14"/></template>重新导出</ActionButton>
|
||||
<ActionButton class="button danger compact" task="delete-batch" :target="batch.id" :disabled="state.exporting||state.busy.has('delete-batch')||batch.status==='running'||batch.status==='reserved'" @click="actions.deleteBatch(batch.id)"><template #icon><Trash2 :size="14"/></template>删除</ActionButton>
|
||||
</div></td></tr></TransitionGroup></table><div v-if="!batches.length" class="history-no-results">{{searching?'正在加载记录…':'没有符合条件的批次,可更换条件或清空筛选。'}}</div></div>
|
||||
<div class="history-pagination">
|
||||
<span class="history-range" aria-live="polite">共 {{total}} 条,显示第 {{rangeStart}}–{{rangeEnd}} 条</span>
|
||||
<label class="history-page-size">每页条数<select class="input" aria-label="生成记录每页条数" v-model.number="pageSize" :disabled="searching||panel.loading" @change="changePageSize"><option v-for="size in historyPageSizes" :key="size" :value="size">{{size}} 条/页</option></select></label>
|
||||
<nav class="history-page-controls" aria-label="生成记录分页">
|
||||
<button class="history-page-button" type="button" aria-label="生成记录第一页" :disabled="searching||panel.loading||page<=1" @click="changePage(1)">首页</button>
|
||||
<button class="history-page-button" type="button" aria-label="生成记录上一页" :disabled="searching||panel.loading||page<=1" @click="changePage(page-1)"><ChevronLeft :size="15"/></button>
|
||||
<button v-for="number in pageNumbers" :key="number" class="history-page-button" :class="{active:page===number}" type="button" :aria-label="'生成记录第 '+number+' 页'" :aria-current="page===number?'page':undefined" :disabled="searching||panel.loading" @click="changePage(number)">{{number}}</button>
|
||||
<button class="history-page-button" type="button" aria-label="生成记录下一页" :disabled="searching||panel.loading||page>=pageCount" @click="changePage(page+1)"><ChevronRight :size="15"/></button>
|
||||
<button class="history-page-button" type="button" aria-label="生成记录末页" :disabled="searching||panel.loading||page>=pageCount" @click="changePage(pageCount)">末页</button>
|
||||
<span class="history-page-position">第 {{page}} / {{pageCount}} 页</span>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
<template #loading-actions><ActionButton v-if="state.exporting" class="button" task="cancel" :loading="state.cancelRequested||state.busy.has('cancel')" @click="actions.cancel()">{{state.cancelRequested?'正在取消…':'取消生成'}}</ActionButton></template>
|
||||
</LoadingRegion>
|
||||
<div v-if="html" class="modal-backdrop" @click.self="html=''"><section class="modal-card"><div class="panel-heading"><div><h2>原收据预览</h2><p>{{title}}</p></div><button class="icon-button" aria-label="关闭原收据预览" @click="html=''"><X :size="19"/></button></div><ReceiptPreview :html="html"/><div class="modal-footer">{{previewMatch?'显示符合查找条件的第一张原收据':'显示本批第一张收据'}},沿用正式编号。</div></section></div>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { FolderOpen,RefreshCw,Check,Stamp,Image } from '@lucide/vue'
|
||||
import { state,actions } from '../state'
|
||||
import ActionButton from '../components/ActionButton.vue'
|
||||
import LoadingRegion from '../components/LoadingRegion.vue'
|
||||
import { usePanelState } from '../loading'
|
||||
const panel=usePanelState('seals')
|
||||
</script>
|
||||
<template>
|
||||
<LoadingRegion as="section" class="panel" data-region="seals" :loading="panel.loading" :label="panel.label" :error="panel.error" @dismiss-error="actions.clearError('seals')">
|
||||
<div class="panel-heading"><div><h2>我的印章 <span class="badge">{{state.seals.length}}</span></h2><p>支持 PNG、JPG 和 WebP,透明背景 PNG 的盖章效果更好。</p></div><div class="button-row">
|
||||
<ActionButton class="button subtle" task="refresh-seals" @click="actions.loadSeals(true)"><template #icon><RefreshCw :size="16"/></template>刷新列表</ActionButton>
|
||||
<ActionButton class="button primary" task="folder" target="seal" @click="actions.chooseAndSaveFolder('seal')" :disabled="state.exporting"><template #icon><FolderOpen :size="16"/></template>{{state.settings.sealDirectory?'更换文件夹':'绑定印章文件夹'}}</ActionButton>
|
||||
</div></div>
|
||||
<div v-if="state.settings.sealDirectory" class="folder-banner"><FolderOpen :size="18"/><span :title="state.settings.sealDirectory">{{state.settings.sealDirectory}}</span><ActionButton class="text-button" task="open" :target="state.settings.sealDirectory" @click="actions.open(state.settings.sealDirectory)">打开文件夹</ActionButton></div>
|
||||
<div v-for="warning in state.sealWarnings" :key="warning" class="inline-note warning">{{warning}}</div>
|
||||
<div v-if="!state.seals.length" class="large-empty"><span class="empty-circle"><Stamp :size="35" :stroke-width="1.4"/></span><h3>{{state.settings.sealDirectory?'文件夹里还没有可用印章':'让常用印章随手可选'}}</h3><p>{{state.settings.sealDirectory?'将印章图片放入已绑定的文件夹,然后刷新列表。':'选择保存印章图片的本地文件夹,程序会记住它。'}}</p><ActionButton class="button primary" :task="state.settings.sealDirectory?'open':'folder'" :target="state.settings.sealDirectory||'seal'" :disabled="state.exporting" @click="state.settings.sealDirectory?actions.open(state.settings.sealDirectory):actions.chooseAndSaveFolder('seal')"><template #icon><FolderOpen :size="16"/></template>{{state.settings.sealDirectory?'打开印章文件夹':'选择印章文件夹'}}</ActionButton></div>
|
||||
<div v-else class="seal-grid"><button v-for="seal in state.seals" :key="seal.id" :class="['seal-card',{selected:state.currentSealId===seal.id}]" :disabled="state.exporting||state.busy.has('seal')" :aria-busy="state.busy.has('seal')&&state.taskTargets.get('seal')===seal.id||undefined" @click="actions.selectSeal(seal.id)"><div class="seal-image-stage"><img :src="seal.dataUrl" :alt="seal.name"><span v-if="state.currentSealId===seal.id" class="seal-check"><Check :size="15"/></span></div><div class="seal-card-label"><Image :size="15"/><strong :title="seal.name">{{seal.name}}</strong></div><small>{{state.busy.has('seal')&&state.taskTargets.get('seal')===seal.id?'正在切换印章…':state.currentSealId===seal.id?'当前使用 · 下次自动记住':'点击设为当前印章'}}</small></button></div>
|
||||
</LoadingRegion>
|
||||
<div class="helper-note"><Stamp :size="17"/><span>印章位置和大小可在偏好设置中调整。生成后,程序会保留当时的印章快照,方便按原样补打。</span><button class="text-button" @click="state.page='settings'">调整盖章位置</button></div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed,reactive,watch } from 'vue'
|
||||
import { FolderOpen,Save,FolderHeart,Printer,Stamp,Hash } from '@lucide/vue'
|
||||
import { state,actions } from '../state'
|
||||
import ActionButton from '../components/ActionButton.vue'
|
||||
import LoadingRegion from '../components/LoadingRegion.vue'
|
||||
import { usePanelState } from '../loading'
|
||||
import {receiptFileStem} from '../../shared/file-names'
|
||||
const panel=usePanelState('settings')
|
||||
const form=reactive({...state.settings})
|
||||
watch(()=>state.settings,value=>Object.assign(form,value))
|
||||
const dirty=computed(()=>JSON.stringify(form)!==JSON.stringify(state.settings))
|
||||
const filenameExample=computed(()=>{
|
||||
const row=state.draft?.rows.find(row=>row.index===state.rowIndex&&!row.errors.length)||state.draft?.rows.find(row=>!row.errors.length)
|
||||
const number=form.numberPrefix===state.settings.numberPrefix?state.nextNumber:form.numberPrefix+'00000001'
|
||||
return receiptFileStem(number||'SJ00000001',row?.receipt.customer||'巴南中学 高2028届 张三',form.fileNamePattern)+(form.exportPng&&!form.exportPdf?'.png':'.pdf')
|
||||
})
|
||||
watch(dirty,value=>{state.settingsDirty=value},{immediate:true})
|
||||
async function choose(kind:'output'|'seal'){
|
||||
await actions.task('settings-folder',async()=>{const folder=await window.receipts.chooseFolder(kind);if(!folder)return false;if(kind==='output')form.outputDirectory=folder;else{form.sealDirectory=folder;form.defaultSealId=''};return true},{target:kind})
|
||||
}
|
||||
async function save(){await actions.saveSettings({...form})}
|
||||
</script>
|
||||
<template>
|
||||
<LoadingRegion as="form" class="settings-layout" data-region="settings" :loading="panel.loading" :label="panel.label" :error="panel.error" @dismiss-error="actions.clearError('settings')" @submit.prevent="save">
|
||||
<section class="panel settings-panel"><div class="settings-section-heading"><span class="setting-icon"><FolderHeart :size="21"/></span><div><h2>文件与常用选择</h2><p>设置一次,以后自动记住。</p></div></div>
|
||||
<div class="form-field"><label for="output-directory">默认导出文件夹</label><div class="path-input"><input id="output-directory" :value="form.outputDirectory" readonly placeholder="选择生成文件的存放位置"><ActionButton class="button subtle compact" task="settings-folder" target="output" :disabled="state.busy.has('settings-folder')" @click="choose('output')"><template #icon><FolderOpen :size="15"/></template>选择</ActionButton></div><small>批次内按“学校 → 年级 → 收费项目”分层保存收据和打印版。</small></div>
|
||||
<div class="form-field"><label for="seal-directory">印章文件夹</label><div class="path-input"><input id="seal-directory" :value="form.sealDirectory" readonly placeholder="选择本地印章图片文件夹"><ActionButton class="button subtle compact" task="settings-folder" target="seal" :disabled="state.busy.has('settings-folder')" @click="choose('seal')"><template #icon><FolderOpen :size="15"/></template>选择</ActionButton></div><small>启动时读取 PNG、JPG 和 WebP 图片,列表显示文件名与缩略图。</small></div>
|
||||
<div class="form-field"><label for="filename-pattern">导出的文件叫什么名字</label><select id="filename-pattern" class="input" v-model="form.fileNamePattern"><option value="number_customer">编号在前(编号_学校 年级 姓名)</option><option value="customer_number">姓名信息在前(学校 年级 姓名_编号)</option><option value="number">只用收据编号</option></select><div class="filename-example" aria-live="polite"><span>文件名示例</span><strong>{{filenameExample}}</strong></div><small>只影响单张 PDF、图片的文件名。每次生成新建批次文件夹,成功后自动打开。</small></div>
|
||||
<div class="form-two-columns"><div class="form-field"><label for="cashier">默认出纳</label><input id="cashier" class="input" v-model="form.defaultCashier" maxlength="20" placeholder="可留空"></div><div class="form-field"><label for="reviewer">默认复核人</label><input id="reviewer" class="input" v-model="form.defaultReviewer" maxlength="20" placeholder="可留空"></div></div><p class="field-note">Excel 中填写的姓名优先;留空时使用这里的默认值。</p>
|
||||
</section>
|
||||
<section class="panel settings-panel"><div class="settings-section-heading"><span class="setting-icon"><Printer :size="21"/></span><div><h2>收据与打印</h2><p>单张分享、批量打印,各有合适的格式。</p></div></div>
|
||||
<div class="form-field"><label for="receipt-template">收据版式</label><select id="receipt-template" class="input" v-model="form.templateId"><option v-for="template in state.templates" :key="template.id" :value="template.id">{{template.name}}</option></select></div>
|
||||
<div class="form-field"><label>默认导出内容</label><div class="checkbox-row"><label><input type="checkbox" v-model="form.exportPdf">单张 PDF</label><label><input type="checkbox" v-model="form.exportPng">单张 PNG</label><label><input type="checkbox" v-model="form.exportPrint">A4 打印版</label></div></div>
|
||||
<div class="form-two-columns"><div class="form-field"><label for="receipts-per-page">A4 每页收据数</label><select id="receipts-per-page" class="input" v-model="form.receiptsPerPage"><option :value="2">上下 2 张</option><option :value="1">居中 1 张</option></select></div><div class="form-field"><label for="png-dpi">图片清晰度</label><select id="png-dpi" class="input" v-model="form.pngDpi"><option :value="300">300 DPI · 适合打印</option><option :value="150">150 DPI · 文件更小</option></select></div></div>
|
||||
<label class="toggle-line"><span>两张收据之间显示裁切线</span><input type="checkbox" v-model="form.cutLine" role="switch"></label>
|
||||
</section>
|
||||
<section class="panel settings-panel"><div class="settings-section-heading"><span class="setting-icon"><Stamp :size="21"/></span><div><h2>印章位置与大小</h2><p>相对标准位置微调,保存后在工作台预览。</p></div></div>
|
||||
<div class="form-field"><label for="seal-width">印章宽度 <span>{{form.sealWidthMm}} mm</span></label><input id="seal-width" type="range" min="15" max="55" step="1" v-model.number="form.sealWidthMm"><small>图片按原比例缩放,默认宽度 35 毫米。</small></div>
|
||||
<div class="form-two-columns"><div class="form-field"><label for="seal-x">水平偏移 / mm</label><input id="seal-x" type="number" class="input" min="-20" max="20" v-model.number="form.sealOffsetX"><small>正数向右,负数向左。</small></div><div class="form-field"><label for="seal-y">垂直偏移 / mm</label><input id="seal-y" type="number" class="input" min="-12" max="12" v-model.number="form.sealOffsetY"><small>正数向下,负数向上。</small></div></div>
|
||||
</section>
|
||||
<section class="panel settings-panel"><div class="settings-section-heading"><span class="setting-icon"><Hash :size="21"/></span><div><h2>自动编号</h2><p>跨批次连续递增,退出后保留进度。</p></div></div>
|
||||
<div class="form-field"><label for="number-prefix">编号前缀</label><input id="number-prefix" class="input" v-model="form.numberPrefix" maxlength="16" pattern="[A-Za-z0-9_-]{1,16}" required><small>支持字母、数字、短横线和下划线,例如 SJ-A。</small></div>
|
||||
<div class="number-preview"><span>当前前缀的下一个编号</span><strong>{{form.numberPrefix===state.settings.numberPrefix?state.nextNumber:'保存后显示新前缀的流水号'}}</strong></div>
|
||||
<div class="inline-note">预览不占号;重新导出原批次沿用原编号。流水号由系统管理,保留生成记录即可安全补打。</div><p class="field-note">同一单位使用多台电脑时,请分别设置不同前缀。</p>
|
||||
</section>
|
||||
<div class="settings-save-bar"><span>{{dirty?'有尚未保存的更改':'设置已与本地同步'}}</span><ActionButton type="submit" class="button primary" task="settings" :disabled="state.exporting"><template #icon><Save :size="17"/></template>保存偏好设置</ActionButton></div>
|
||||
</LoadingRegion>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { computed,ref } from 'vue'
|
||||
import { Upload,Download,FileSpreadsheet,ArrowRight,Printer,FolderOpen,RefreshCw,Check,ChevronLeft,ChevronRight,LoaderCircle,Settings2,Stamp,AlertCircle,Search } from '@lucide/vue'
|
||||
import { state,actions } from '../state'
|
||||
import { displayMoney } from '../../core/money'
|
||||
import ReceiptPreview from '../components/ReceiptPreview.vue'
|
||||
import ActionButton from '../components/ActionButton.vue'
|
||||
import LoadingRegion from '../components/LoadingRegion.vue'
|
||||
import ReceiptGroupTree from '../components/ReceiptGroupTree.vue'
|
||||
import {importGroupTree,summarizeReceiptGroups} from '../../shared/receipt-groups'
|
||||
import { usePanelState } from '../loading'
|
||||
const dataPanel=usePanelState('data'),previewPanel=usePanelState('preview'),exportPanel=usePanelState('export')
|
||||
const search=ref(''),filter=ref('all'),page=ref(0)
|
||||
const rows=computed(()=>state.draft?.rows??[])
|
||||
const invalid=computed(()=>rows.value.filter(r=>r.errors.length).length)
|
||||
const warnings=computed(()=>rows.value.filter(r=>r.warnings.length).length)
|
||||
const selectedRows=computed(()=>rows.value.filter(r=>state.selected.has(r.index)))
|
||||
const total=computed(()=>selectedRows.value.reduce((s,r)=>s+r.receipt.amountCents,0))
|
||||
const filtered=computed(()=>rows.value.filter(r=>(filter.value!=='errors'||r.errors.length||r.warnings.length)&&(!search.value||[r.receipt.customer,r.receipt.item,r.receipt.date].some(v=>v.includes(search.value)))))
|
||||
const pages=computed(()=>Math.max(1,Math.ceil(filtered.value.length/50)))
|
||||
const visible=computed(()=>filtered.value.slice(Math.min(page.value,pages.value-1)*50,(Math.min(page.value,pages.value-1)+1)*50))
|
||||
const allSelected=computed(()=>rows.value.some(r=>!r.errors.length)&&rows.value.filter(r=>!r.errors.length).every(r=>state.selected.has(r.index)))
|
||||
const groupTree=computed(()=>importGroupTree(rows.value,state.selected))
|
||||
const groupCount=computed(()=>summarizeReceiptGroups(rows.value.filter(row=>!row.errors.length).map(row=>row.receipt)).length)
|
||||
const selectedSeal=computed(()=>state.seals.find(s=>s.id===state.currentSealId))
|
||||
function toggle(index:number){if(state.exporting)return;if(state.selected.has(index))state.selected.delete(index);else state.selected.add(index);if(state.previewLayout==='print')void actions.refreshPreview()}
|
||||
function toggleAll(){if(state.exporting)return;if(allSelected.value)state.selected.clear();else state.selected=new Set(rows.value.filter(r=>!r.errors.length).map(r=>r.index));if(state.previewLayout==='print')void actions.refreshPreview()}
|
||||
function toggleGroup(indices:number[]){
|
||||
if(state.exporting||!indices.length)return
|
||||
const deselect=indices.every(index=>state.selected.has(index))
|
||||
for(const index of indices){if(deselect)state.selected.delete(index);else state.selected.add(index)}
|
||||
const index=indices.find(index=>state.selected.has(index))??rows.value.find(row=>state.selected.has(row.index))?.index
|
||||
if(index!==undefined)focus(index)
|
||||
else if(state.previewLayout==='print')void actions.refreshPreview()
|
||||
}
|
||||
function focus(index:number){state.rowIndex=index;void actions.refreshPreview()}
|
||||
function layout(value:'single'|'print'){state.previewLayout=value;void actions.refreshPreview()}
|
||||
const percent=computed(()=>state.progress?Math.round(state.progress.completed/Math.max(1,state.progress.total)*100):0)
|
||||
</script>
|
||||
<template>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><span class="stat-icon green"><FileSpreadsheet :size="21"/></span><div><span class="stat-label">待生成收据</span><strong>{{state.selected.size}}<small> 张</small></strong></div><span class="stat-sub">{{rows.length?'共导入 '+rows.length+' 条':'从 Excel 开始'}}</span></div>
|
||||
<div class="stat-card"><span class="stat-icon amber">¥</span><div><span class="stat-label">本批合计金额</span><strong class="money-stat">{{displayMoney(total)}}</strong></div><span class="stat-sub">人民币 / 元</span></div>
|
||||
<div class="stat-card"><span class="stat-icon blue"><Stamp :size="21"/></span><div class="stat-seal"><span class="stat-label">本批使用印章</span><strong class="seal-name">{{selectedSeal?.name||'暂未选择'}}</strong></div><button class="text-button" @click="state.page='seals'">管理 <ArrowRight :size="13"/></button></div>
|
||||
</div>
|
||||
<details v-if="state.draft" :key="state.draft.id" class="panel group-review" open>
|
||||
<summary class="group-review-heading"><div><h2>按分组核对收据 <span class="badge">{{groupCount}} 组</span></h2><p>学校 → 年级 → 收费项目,也是导出文件夹的层级;可整组勾选。</p></div><span>{{state.selected.size}} 张已选 · ¥{{displayMoney(total)}}</span></summary>
|
||||
<div class="group-review-body"><ReceiptGroupTree :nodes="groupTree" :disabled="state.exporting" :expanded="groupCount<=20" @toggle="toggleGroup"/></div>
|
||||
<div class="group-review-footer"><span>金额随勾选更新,待修改的数据不会生成。</span><button class="text-button" :disabled="state.exporting||!rows.some(row=>!row.errors.length)" @click="toggleAll">{{allSelected?'取消全选':'选择全部有效数据'}}</button></div>
|
||||
</details>
|
||||
<div class="workspace-grid">
|
||||
<LoadingRegion as="section" class="panel data-panel" data-region="data" :loading="dataPanel.loading" :label="dataPanel.label" :error="dataPanel.error" @dismiss-error="actions.clearError('data')">
|
||||
<div class="panel-heading"><div><h2>收据数据 <span v-if="rows.length" class="badge">{{rows.length}}</span></h2><p>每行数据,对应一张收据</p></div><div class="button-row"><ActionButton class="button subtle compact" task="template" @click="actions.template()"><template #icon><Download :size="15"/></template>下载模板</ActionButton><ActionButton class="button primary compact" task="import" @click="actions.importExcel()" :disabled="state.exporting"><template #icon><Upload :size="15"/></template>导入 Excel</ActionButton></div></div>
|
||||
<div v-if="!state.draft" class="import-empty"><div class="empty-file-icon"><FileSpreadsheet :size="36" :stroke-width="1.4"/><span class="file-tag">XLSX</span></div><h3>把表格交给收据工坊</h3><p>导入填写好的 Excel,自动校验并生成收据。<br>支持 .xlsx,每次最多 5,000 条。</p><ActionButton class="button primary" task="import" @click="actions.importExcel()"><template #icon><Upload :size="16"/></template>选择 Excel 文件</ActionButton><ActionButton class="text-button" task="template" @click="actions.template()">第一次使用?先下载填写模板 <ArrowRight :size="13"/></ActionButton><div class="import-steps"><span><i>1</i>填写表格</span><span class="step-line"></span><span><i>2</i>核对预览</span><span class="step-line"></span><span><i>3</i>一键生成</span></div></div>
|
||||
<template v-else>
|
||||
<div class="file-bar"><FileSpreadsheet :size="16"/><span :title="state.draft.fileName">{{state.draft.fileName}}</span><small>已选择 {{state.selected.size}} 条</small></div>
|
||||
<div v-if="state.draft.previousBatchId" class="inline-note warning"><AlertCircle :size="15"/><span>此文件已有生成记录。补打请使用原编号。</span><button class="text-button" @click="state.page='history'">查看记录</button></div>
|
||||
<div class="table-toolbar"><div class="segmented"><button :class="{active:filter==='all'}" @click="filter='all';page=0">全部</button><button :class="{active:filter==='errors'}" @click="filter='errors';page=0">需核对 {{invalid+warnings}}</button></div><label class="search-input"><Search :size="14"/><input v-model="search" placeholder="搜索学校、姓名或项目" @input="page=0"></label></div>
|
||||
<div class="data-table-scroll"><table class="data-table"><thead><tr><th class="check-cell"><input type="checkbox" aria-label="全选有效数据" :disabled="state.exporting||!rows.some(row=>!row.errors.length)" :checked="allSelected" @change="toggleAll"></th><th>Excel 行</th><th>客户 / 收费项目</th><th class="align-right">金额(元)</th><th>状态</th></tr></thead><tbody><tr v-for="row in visible" :key="row.index" :class="{focused:state.rowIndex===row.index,invalid:row.errors.length}" @click="focus(row.index)"><td class="check-cell"><input type="checkbox" :aria-label="'选择第 '+row.excelRow+' 行'" :checked="state.selected.has(row.index)" :disabled="!!row.errors.length||state.exporting" @click.stop @change="toggle(row.index)"></td><td class="row-number">{{row.excelRow}}</td><td><strong>{{row.receipt.customer||'未填写客户'}}</strong><span class="table-secondary">{{row.receipt.item||'未填写项目'}} · {{row.receipt.date||'日期无效'}}</span><span v-for="error in row.errors" :key="error" class="row-error">{{error}}</span><span v-for="warning in row.warnings" :key="warning" class="row-warning">{{warning}}</span></td><td class="align-right numeric">{{row.receipt.amountCents?displayMoney(row.receipt.amountCents):'—'}}</td><td><span v-if="row.errors.length" class="state-tag danger">待修改</span><span v-else-if="row.warnings.length" class="state-tag warning">请核对</span><span v-else class="valid-label"><Check :size="13"/>有效</span></td></tr><tr v-if="!visible.length"><td colspan="5" class="empty-table">没有符合条件的数据</td></tr></tbody></table></div>
|
||||
<div class="table-footer"><span>{{invalid?'有 '+invalid+' 条错误,请修改 Excel 后重新导入':'数据校验通过,点击任意行预览'}}</span><div class="pagination"><button aria-label="上一页" :disabled="page===0" @click="page--"><ChevronLeft :size="16"/></button><span>{{Math.min(page+1,pages)}} / {{pages}}</span><button aria-label="下一页" :disabled="page>=pages-1" @click="page++"><ChevronRight :size="16"/></button></div></div>
|
||||
</template>
|
||||
</LoadingRegion>
|
||||
<LoadingRegion as="section" class="panel preview-panel" data-region="preview" :loading="previewPanel.loading" :label="previewPanel.label" :delay="previewPanel.delay" :error="previewPanel.error" @dismiss-error="actions.clearError('preview')"><div class="panel-heading"><div><h2>收据预览</h2><p>{{state.draft?'点击左侧数据,核对收据内容':'示例样张 · 导入后替换为实际数据'}}</p></div><span class="preview-live"><span class="status-dot"></span>实时预览</span></div>
|
||||
<div class="preview-toolbar"><div class="segmented"><button :class="{active:state.previewLayout==='single'}" @click="layout('single')">单张收据</button><button :class="{active:state.previewLayout==='print'}" @click="layout('print')">A4 打印排版</button></div><span>{{state.previewLayout==='single'?'190 × 140 mm':'A4 · 每页 '+state.settings.receiptsPerPage+' 张'}}</span></div>
|
||||
<ReceiptPreview :html="state.previewHtml" :layout="state.previewLayout"/>
|
||||
<div class="seal-select-row"><Stamp :size="17"/><label for="workbench-seal">本批印章</label><select id="workbench-seal" :value="state.currentSealId" :disabled="state.exporting||state.busy.has('seal')" @change="actions.selectSeal(($event.target as HTMLSelectElement).value)"><option value="">不使用印章</option><option v-if="state.currentSealId&&!selectedSeal" :value="state.currentSealId">原印章文件已丢失,请重新选择</option><option v-for="seal in state.seals" :key="seal.id" :value="seal.id">{{seal.name}}</option></select><ActionButton class="icon-button" task="refresh-seals" icon-only title="刷新印章列表" aria-label="刷新印章列表" @click="actions.loadSeals(true)"><template #icon><RefreshCw :size="15"/></template></ActionButton></div>
|
||||
<ActionButton v-if="!state.settings.sealDirectory" class="bind-seal-link" task="folder" target="seal" @click="actions.chooseAndSaveFolder('seal')"><template #icon><FolderOpen :size="14"/></template>绑定印章文件夹,下次自动加载</ActionButton>
|
||||
<p class="preview-footnote">预览不占用编号,正式生成后分配并保留。</p>
|
||||
</LoadingRegion>
|
||||
</div>
|
||||
<LoadingRegion as="section" class="panel export-panel" data-region="export" :loading="exportPanel.loading" :label="exportPanel.label" :progress="exportPanel.progress" :error="exportPanel.error" @dismiss-error="actions.clearError('export')">
|
||||
<div class="export-options">
|
||||
<div class="export-title"><Printer :size="19"/><h2>生成与导出</h2></div>
|
||||
<div class="export-format-groups">
|
||||
<fieldset class="export-format-group">
|
||||
<legend>单张文件</legend>
|
||||
<div class="export-format-controls"><label><input type="checkbox" v-model="state.settings.exportPdf" :disabled="state.exporting">单张 PDF</label><label><input type="checkbox" v-model="state.settings.exportPng" :disabled="state.exporting">单张 PNG</label></div>
|
||||
<p>每张收据独立保存,按学校、年级、收费项目分目录</p>
|
||||
</fieldset>
|
||||
<fieldset class="export-format-group export-print-group">
|
||||
<legend>A4 批量打印</legend>
|
||||
<div class="export-format-controls">
|
||||
<label><input type="checkbox" v-model="state.settings.exportPrint" :disabled="state.exporting">A4 打印版</label>
|
||||
<label class="print-layout-control"><span>每页张数</span><select aria-label="打印每页张数" v-model="state.settings.receiptsPerPage" :disabled="state.exporting||!state.settings.exportPrint" @change="actions.refreshPreview()"><option :value="2">每页 2 张</option><option :value="1">每页 1 张</option></select></label>
|
||||
</div>
|
||||
<p>每个学校、年级、收费项目单独合并为打印 PDF</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="state.progress" class="progress-area"><div><span class="progress-stage">{{state.progress.stage}}</span><strong>{{percent}}%</strong></div><div class="progress-track"><div :style="{width:percent+'%'}"></div></div><div v-if="state.progress.errors.length" class="row-error">{{state.progress.errors.length}} 项未完成,可在生成记录中重试。</div></div>
|
||||
<div class="export-bottom"><div class="output-location"><FolderOpen :size="17"/><div><span>保存到</span><ActionButton class="output-path-action" task="folder" target="output" :title="state.settings.outputDirectory" @click="actions.chooseAndSaveFolder('output')" :disabled="state.exporting">{{state.settings.outputDirectory||'选择导出文件夹'}}</ActionButton></div><button class="icon-button" aria-label="打开导出设置" @click="state.page='settings'"><Settings2 :size="16"/></button></div><ActionButton v-if="state.exporting" class="button" task="cancel" :loading="state.cancelRequested||state.busy.has('cancel')" @click="actions.cancel()">取消生成</ActionButton><ActionButton v-else class="button primary export-button" task="export" :disabled="!state.selected.size" @click="actions.export()"><template #icon><Printer :size="17"/></template>生成 {{state.selected.size||''}} 张收据<ArrowRight :size="16"/></ActionButton></div>
|
||||
<template #loading-actions><ActionButton v-if="state.exporting" class="button" task="cancel" :loading="state.cancelRequested||state.busy.has('cancel')" @click="actions.cancel()">{{state.cancelRequested?'正在取消…':'取消生成'}}</ActionButton></template>
|
||||
</LoadingRegion>
|
||||
</template>
|
||||
Reference in New Issue
Block a user