优化提交预检、废卡按钮置灰与等待超时提示

This commit is contained in:
24kycj
2026-09-09 23:45:25 +08:00
parent ac566846c9
commit 9ab1bcbd5e
25 changed files with 1442 additions and 100 deletions
+10 -1
View File
@@ -2,6 +2,7 @@ import { resolve } from 'path'
import { readFileSync } from 'fs'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import vue from '@vitejs/plugin-vue'
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
const pkg = JSON.parse(readFileSync(resolve('package.json'), 'utf-8')) as { version: string }
@@ -25,6 +26,14 @@ export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(pkg.version)
},
plugins: [vue()]
plugins: [
vue(),
// 构建时预编译多语言消息 + 使用 vue-i18n runtime 构建,
// 避免运行时 new Function() 编译消息(会被 CSP script-src 'self' 拦截导致白屏)
VueI18nPlugin({
include: [resolve('src/renderer/src/i18n/locales/**')],
runtimeOnly: true
})
]
}
})
+938 -8
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -90,6 +90,7 @@
}
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^1.6.0",
"@vitejs/plugin-vue": "^4.6.2",
"electron": "20.3.12",
"electron-builder": "^24.13.3",
+14
View File
@@ -106,6 +106,20 @@ function createWindow(): void {
)
})
// 页面加载失败(dev server 未就绪 / 生产文件缺失)时记录并提示,避免静默白屏
mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
log.error('renderer did-fail-load', { errorCode, errorDescription, validatedURL })
if (errorCode !== -3) {
mainWindow?.webContents.executeJavaScript(
`document.getElementById('app').innerHTML = '<pre style="margin:16px;padding:16px;background:#fff5f5;border:1px solid #feb2b2;border-radius:8px;color:#c53030;font-size:13px;white-space:pre-wrap;">[main:did-fail-load] ${errorCode} ${errorDescription}\\n${validatedURL}</pre>'`
).catch(() => undefined)
}
})
mainWindow.webContents.on('preload-error', (_event, preloadPath, error) => {
log.error('preload-error', { preloadPath, error: String(error) })
})
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
+22 -4
View File
@@ -136,7 +136,12 @@ export function registerIpcHandlers(): void {
assertReady()
const dll = await loadDllModule()
const code = dll.dllPrinterReset()
return code === CS_OK ? ok() : fail(code, '重置失败')
// 物理动作指令:负数为错误,0 或正数(状态/警告码)均视为指令已被打印机接受
if (code < 0) {
log.warn('SAPI_PrinterResetprinter returned non-OK code', code)
return fail(code, '重置失败')
}
return ok({ code })
} catch (err) {
return fail(CS_FAIL, String(err))
}
@@ -148,7 +153,11 @@ export function registerIpcHandlers(): void {
const dll = await loadDllModule()
if (!dll.isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE')
const code = dll.dllPrinterReject()
return code === CS_OK ? ok() : fail(code, '废卡失败')
if (code < 0) {
log.warn('SAPI_PrinterMovetoreject returned non-OK code', code)
return fail(code, '废卡失败')
}
return ok({ code })
} catch (err) {
return fail(CS_FAIL, String(err))
}
@@ -161,7 +170,11 @@ export function registerIpcHandlers(): void {
const dll = await loadDllModule()
if (!dll.isUsbReaderApiAvailable()) return fail(CS_FAIL, 'USB_READER_API_UNAVAILABLE')
const code = dll.dllPrinterMoveToUsbReader()
return code === CS_OK ? ok() : fail(code, '读卡失败')
if (code < 0) {
log.warn('SAPI_PrinterMovetousbreader returned non-OK code', code)
return fail(code, '读卡失败')
}
return ok({ code })
} catch (err) {
return fail(CS_FAIL, String(err))
}
@@ -174,7 +187,12 @@ export function registerIpcHandlers(): void {
const dll = await loadDllModule()
if (!dll.isHopperApiAvailable()) return fail(CS_FAIL, 'HOPPER_API_UNAVAILABLE')
const code = dll.dllPrinterMoveToHopper()
return code === CS_OK ? ok() : fail(code, '退卡失败')
if (code < 0) {
log.warn('SAPI_PrinterMovetohopper returned non-OK code', code)
return fail(code, '退卡失败')
}
// 卡已物理退出但返回正数状态码时,不再误报失败
return ok({ code })
} catch (err) {
return fail(CS_FAIL, String(err))
}
+11 -1
View File
@@ -80,6 +80,15 @@ export function startJobPoll(id: string): void {
const cancelled = r.jobState === 6
const finished = r.jobState === 100
const terminal = failed || cancelled
// 失败当刻立即取打印机错误串(与 USB 收集轮询对齐),避免渲染层事后取到通用/过期文案
let errorMessage = ''
if (failed) {
try {
errorMessage = dll.dllGetPrinterErrorStr(-1) || ''
} catch (e) {
log.warn('GetPrinterErrorStr on job failed failed', e)
}
}
const tick = {
jobId,
queryErrorCode: r.queryErrorCode,
@@ -88,7 +97,8 @@ export function startJobPoll(id: string): void {
terminal,
failed,
cancelled,
finished
finished,
errorMessage
}
emitTrace('[poll] job:poll-tick', tick)
send('job:poll-tick', tick)
@@ -44,6 +44,8 @@ let SAPI_PrinterMovetoreject: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterMovetousbreader: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterMovetohopper: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterCardPosition: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterCheckstatus: any = null
@@ -55,6 +57,7 @@ let hasCheckstatusApi = false
let hasCancelApi = false
let hasUploadApi = false
let hasUsbReaderApi = false
let hasHopperApi = false
let loggedCancelMissing = false
let loggedRejectMissing = false
@@ -206,6 +209,7 @@ function loadLibrary(): void {
cancel: hasCancelApi,
reject: hasRejectApi,
usbReader: hasUsbReaderApi,
hopper: hasHopperApi,
cardPosition: hasCardPositionApi,
checkstatus: hasCheckstatusApi
})
@@ -231,6 +235,11 @@ export function isUsbReaderApiAvailable(): boolean {
return hasUsbReaderApi
}
export function isHopperApiAvailable(): boolean {
loadLibrary()
return hasHopperApi
}
export function isCardPositionApiAvailable(): boolean {
loadLibrary()
return hasCardPositionApi
+2
View File
@@ -7,6 +7,8 @@ const channels = {
'dll:printer-status',
'dll:printer-reset',
'dll:printer-reject',
'dll:printer-read-card',
'dll:printer-eject-card',
'dll:printer-error-str',
'dll:job-create',
'dll:job-cancel',
+6 -2
View File
@@ -15,7 +15,9 @@ export const ICON_NAMES = [
'stop',
'warning',
'id-card',
'eject'
'eject',
'eye',
'eye-slash'
] as const
export type IconName = (typeof ICON_NAMES)[number]
@@ -38,5 +40,7 @@ export const ICON_FA_CLASS: Record<IconName, string> = {
stop: 'fas fa-stop',
warning: 'fas fa-exclamation-triangle',
'id-card': 'fas fa-id-card',
eject: 'fas fa-eject'
eject: 'fas fa-eject',
eye: 'fas fa-eye',
'eye-slash': 'fas fa-eye-slash'
}
@@ -1,12 +1,28 @@
import { dllPrinterInfo, dllPrinterStatus, parsePrinterInfo } from '@/api/cardsoon'
import { useConfigStore } from '@/stores/config'
/** 状态是否表示打印机未连接/不可用 */
function isDisconnectedStatus(text: string): boolean {
return !text || text === '—' || text.includes('未连接') || text.includes('Not connected')
}
let prevStatusText = ''
export async function refreshLiveStatus(): Promise<void> {
const store = useConfigStore()
try {
const r = await dllPrinterStatus()
if (r.ok && r.data?.statusText) {
store.setPrinter({ ...store.printer, statusText: r.data.statusText })
const newStatus = r.data.statusText
// 检测状态变化:从未连接 → 已连接时,立即刷新打印机信息(型号/isSingleSide等)
const wasDisconnected = isDisconnectedStatus(prevStatusText)
const nowConnected = !isDisconnectedStatus(newStatus)
store.setPrinter({ ...store.printer, statusText: newStatus })
if (wasDisconnected && nowConnected) {
// 打印机刚连上,立即拉取型号/色带等信息
void refreshPrinterInfo()
}
prevStatusText = newStatus
}
} catch {
/* ignore */
+10 -1
View File
@@ -36,8 +36,10 @@ export default {
home: {
toolsTitle: 'Tools',
tasksTitle: 'Tasks',
readCard: 'Read',
ejectCard: 'Eject',
dataDistribute: 'Data Distribution',
dataDistributeDesc: 'Distribute data to print cards',
dataDistributeDesc: 'Distribute data onto cards',
dataCollect: 'Data Collection',
dataCollectDesc: 'Collect data from cards'
},
@@ -53,8 +55,12 @@ export default {
dongleHint: '(101 = unlimited)',
dongleAuthLabel: 'Auth Code',
donglePassword: 'Enter authorization code',
showAuth: 'Show auth code',
hideAuth: 'Hide auth code',
templatePreview: 'Label Preview',
addLabel: 'Add Label',
removeLabel: 'Remove',
templateRemoved: 'Label removed',
doubleSide: 'Double-sided',
singleSideHint: 'Single-side printer detected, please select a side (front or back)',
frontSide: 'Front',
@@ -172,6 +178,9 @@ export default {
templateNotExist: 'Template file does not exist',
printFlagMismatch: 'Print side mismatch, please reselect',
singleSideMustPick: 'Single-side printer, please select front or back',
pickSideRequired: 'Please select a print side (front or back) before submitting',
printerNotConnected: 'Printer not connected, please connect the printer first',
printerNotReady: 'Printer not connected or in fault (e.g. card jam). Please fix the device before submitting',
dongleAuthRequired: 'Please enter authorization code',
dongleCountInvalid: 'Dongle count must be 0 or 1-101',
createJobFailed: 'Failed to create task',
+13 -3
View File
@@ -36,6 +36,8 @@ export default {
home: {
toolsTitle: '工具',
tasksTitle: '任务',
readCard: '读卡',
ejectCard: '退卡',
dataDistribute: '数据分发',
dataDistributeDesc: '分发数据到打印卡片',
dataCollect: '数据收集',
@@ -53,12 +55,16 @@ export default {
dongleHint: '101 为不限次数)',
dongleAuthLabel: '授权码',
donglePassword: '请输入授权码',
showAuth: '显示授权码',
hideAuth: '隐藏授权码',
templatePreview: '标签预览',
addLabel: '添加标签',
removeLabel: '移除标签',
templateRemoved: '已移除标签',
doubleSide: '双面',
singleSideHint: '当前为单面打印机,请选择打印面(正面或背面)',
frontSide: 'FRONT',
backSide: 'BACK',
frontSide: '正面',
backSide: '背面',
imageFieldNotSelected: '未选择图片',
templateLoaded: '已加载标签模板',
templateNoPreview: '模板已打开,但未解析到可预览内容',
@@ -160,7 +166,9 @@ export default {
startUsbFailed: '启动 USB 收集失败',
usbWaitComplete: '请先等待 USB 收集完成',
cannotEnterRunning: '无法进入运行页,已取消任务',
initFailedHint: '初始化失败,请检查任务目录权限'
initFailedHint: '初始化失败,请检查任务目录权限',
waitTimeoutCollect: '等待时间较长:请检查 U 盘数据线是否连接、卡片是否插好(可把卡插反后重试)',
waitTimeoutDistribute: '等待进卡时间较长:请检查卡片是否到位、打印机是否夹卡或报错'
},
validation: {
noTask: '请配置拷贝路径或打印模板',
@@ -173,6 +181,8 @@ export default {
printFlagMismatch: '打印面数不匹配,请重新选择',
singleSideMustPick: '当前为单面打印机,请选择正面或背面',
pickSideRequired: '请选择打印面(正面或背面)后再提交',
printerNotConnected: '打印机未连接,请先连接打印机后再提交',
printerNotReady: '打印机未连接或设备异常(如夹卡),请排除故障后再提交',
dongleAuthRequired: '请输入授权码',
dongleCountInvalid: '加密狗次数须为 0 或 1-101',
createJobFailed: '创建任务失败',
+10 -2
View File
@@ -36,6 +36,8 @@ export default {
home: {
toolsTitle: '工具',
tasksTitle: '任務',
readCard: '讀卡',
ejectCard: '退卡',
dataDistribute: '數據分發',
dataDistributeDesc: '分發數據到列印卡片',
dataCollect: '數據收集',
@@ -53,12 +55,16 @@ export default {
dongleHint: '101 為不限次數)',
dongleAuthLabel: '授權碼',
donglePassword: '請輸入授權碼',
showAuth: '顯示授權碼',
hideAuth: '隱藏授權碼',
templatePreview: '標籤預覽',
addLabel: '新增標籤',
removeLabel: '移除標籤',
templateRemoved: '已移除標籤',
doubleSide: '雙面',
singleSideHint: '當前為單面印表機,請選擇列印面(正面或背面)',
frontSide: 'FRONT',
backSide: 'BACK',
frontSide: '正面',
backSide: '背面',
imageFieldNotSelected: '未選擇圖片',
templateLoaded: '已載入標籤模板',
templateNoPreview: '模板已開啟,但未解析到可預覽內容',
@@ -173,6 +179,8 @@ export default {
printFlagMismatch: '列印面數不匹配,請重新選擇',
singleSideMustPick: '當前為單面印表機,請選擇正面或背面',
pickSideRequired: '請選擇列印面(正面或背面)後再提交',
printerNotConnected: '印表機未連接,請先連接印表機後再提交',
printerNotReady: '印表機未連接或設備異常(如夾卡),請排除故障後再提交',
dongleAuthRequired: '請輸入授權碼',
dongleCountInvalid: '加密狗次數須為 0 或 1-101',
createJobFailed: '建立任務失敗',
+50 -31
View File
@@ -24,50 +24,71 @@ import './styles/shell.css'
// preload 未注入时(如普通浏览器调试/ preload 加载失败)不再白屏,直接跳过桌面侧能力
const hasApi = typeof window.cardsoonApi !== 'undefined'
if (hasApi) {
window.cardsoonApi.on('app:trace', (payload) => {
const p = payload as { level: string; message: string; data?: Record<string, unknown> }
if (p.level === 'error') console.error(p.message, p.data ?? '')
else console.log(p.message, p.data ?? '')
})
}
async function setTrace(on: boolean): Promise<void> {
await window.cardsoonApi.invoke('config:set', { traceEnabled: on })
console.info(`[trace] 控制台日志已${on ? '开启' : '关闭'}`)
}
const w = window as Window & { trace?: (on?: boolean) => Promise<void>; dllTrace?: (on?: boolean) => Promise<void> }
if (hasApi) {
w.trace = async (on = true) => setTrace(on)
w.dllTrace = w.trace
void (async () => {
const cfg = await configGet()
const on = cfg.ok && cfg.data?.traceEnabled === true
console.info(`[trace] 控制台日志: ${on ? '已开启' : '已关闭'},执行 trace(false) 关闭`)
})()
}
const w = window as Window & { trace?: (on?: boolean) => Promise<void>; dllTrace?: (on?: boolean) => Promise<void> }
w.trace = async (on = true) => setTrace(on)
w.dllTrace = w.trace
void (async () => {
const cfg = await configGet()
const on = cfg.ok && cfg.data?.traceEnabled === true
console.info(`[trace] 控制台日志: ${on ? '已开启' : '已关闭'},执行 trace(false) 关闭`)
})()
/**
*
* 便 DevTools
* Vue /
*
*/
let appMounted = false
function showFatalError(stage: string, err: unknown): void {
if (appMounted) {
console.error(`[renderer:${stage}]`, err)
return
}
const el = document.getElementById('app')
if (!el) return
const detail = err instanceof Error ? `${err.message}\n\n${err.stack ?? ''}` : String(err)
el.innerHTML = `<pre style="margin:16px;padding:16px;background:#fff5f5;border:1px solid #feb2b2;border-radius:8px;color:#c53030;font-size:13px;white-space:pre-wrap;word-break:break-all;">[renderer:${stage}]\n${detail}</pre>`
}
window.addEventListener('error', (e) => {
showFatalError('window.onerror', e.error ?? e.message)
})
window.addEventListener('unhandledrejection', (e) => {
showFatalError('unhandledrejection', e.reason)
})
try {
const app = createApp(App)
app.config.errorHandler = (err, _instance, info) => {
console.error('[vue errorHandler]', err, info)
if (!appMounted) showFatalError(`vue:${info}`, err)
}
const pinia = createPinia()
app.use(pinia)
@@ -76,10 +97,7 @@ app.use(i18n)
app.use(router)
void (async () => {
await useNetworkAuthStore().loadFromStorage()
const dongleStore = useDongleAuthStore()
@@ -87,10 +105,11 @@ void (async () => {
await dongleStore.loadFromSecrets()
useDistributeFormStore().dongleAuthCode = dongleStore.authCode
})()
app.mount('#app')
appMounted = true
} catch (err) {
showFatalError('bootstrap', err)
}
+5
View File
@@ -18,6 +18,11 @@ export const useJobStore = defineStore('job', {
clearActiveJob() {
this.jobId = ''
},
/** 重置次数计数器(不影响当前 jobId/lastJobJson */
resetCounts() {
this.successCount = 0
this.failCount = 0
},
reset() {
this.jobId = ''
this.lastJobJson = ''
@@ -65,3 +65,15 @@
.fa-exclamation-triangle::before {
content: '\f071';
}
.fa-id-card::before {
content: '\f2c2';
}
.fa-eject::before {
content: '\f052';
}
.fa-eye::before {
content: '\f06e';
}
.fa-eye-slash::before {
content: '\f070';
}
+87 -10
View File
@@ -73,22 +73,99 @@
color: var(--cs-primary);
}
/* 读卡 / 退卡 并排按钮行 */
.m-tool-row {
/* 不支持的工具按钮(如当前 DLL 无废卡接口):置灰禁用 */
.m-tool-btn:disabled {
background: #f1f3f5;
border-color: #e9ecef;
color: #adb5bd;
cursor: not-allowed;
box-shadow: none;
}
.m-tool-btn:disabled:hover {
border-color: #e9ecef;
box-shadow: none;
}
.m-tool-btn:disabled i,
.m-tool-btn:disabled:hover i {
color: #ced4da;
}
/* ========== 读卡 / 退卡 PK 式斜切分隔按钮 ========== */
.m-tool-versus {
position: relative;
width: 100%;
height: 48px;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 6px;
overflow: hidden;
display: flex;
gap: 8px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.m-tool-btn--half {
flex: 1;
min-width: 0;
justify-content: center;
padding: 0 8px;
.m-tool-versus:hover {
border-color: var(--cs-primary);
box-shadow: 0 4px 12px rgba(0, 128, 0, 0.10);
}
.m-versus-half {
position: relative;
z-index: 1;
width: 50%;
height: 100%;
border: none;
background: transparent;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #495057;
cursor: pointer;
white-space: nowrap;
transition: background 0.18s ease, color 0.18s ease;
}
.m-tool-btn--half i {
width: 16px;
.m-versus-half span {
white-space: nowrap;
overflow: hidden;
text-overflow: clip;
}
.m-versus-half i {
font-size: 14px;
flex-shrink: 0;
color: #6c757d;
transition: color 0.18s ease;
}
/* 左半:内容朝中间(右对齐),右边缘斜切为 \ 形 */
.m-versus-half--read {
justify-content: flex-end;
padding-right: 12px;
clip-path: polygon(0 0, calc(100% - 9px) 0, calc(100% + 9px) 100%, 0 100%);
}
/* 右半:内容朝中间(左对齐),左边缘斜切为 \ 形 */
.m-versus-half--eject {
justify-content: flex-start;
padding-left: 12px;
clip-path: polygon(calc(0% - 9px) 0, 100% 0, 100% 100%, calc(0% + 9px) 100%);
}
.m-versus-half:hover {
background: rgba(0, 128, 0, 0.08);
color: var(--cs-primary);
}
.m-versus-half:hover i {
color: var(--cs-primary);
}
.m-versus-half:active {
background: rgba(0, 128, 0, 0.16);
}
/* ========== 垂直分隔线 ========== */
+31 -1
View File
@@ -111,7 +111,7 @@
}
.m-panel-toolbar .c-checkbox-item .c-input.dog-auth {
width: 110px;
width: 140px;
height: 20px;
padding: 0 4px;
font-size: 9px;
@@ -120,6 +120,36 @@
margin-left: 3px;
}
/* 授权码输入框容器 + 小眼睛 */
.m-panel-toolbar .c-checkbox-item .dog-auth-wrap {
position: relative;
display: inline-flex;
align-items: center;
margin-left: 3px;
}
.m-panel-toolbar .c-checkbox-item .dog-auth-toggle {
position: absolute;
right: 2px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: #adb5bd;
font-size: 10px;
}
.m-panel-toolbar .c-checkbox-item .dog-auth-toggle:hover {
color: #495057;
}
/* 授权码标签 */
.m-panel-toolbar .c-checkbox-item .dog-auth-label {
font-size: 9px;
+2
View File
@@ -25,6 +25,8 @@ export interface JobPollPayload {
failed: boolean
cancelled: boolean
finished: boolean
/** 任务失败当刻由主进程取到的打印机错误串 */
errorMessage?: string
}
export interface UsbPollPayload {
@@ -1,6 +1,6 @@
import type { DistributeFormState } from '@/stores/distributeForm'
import i18n from '@/i18n'
import { fsPathExists } from '@/api/cardsoon'
import { fsPathExists, dllPrinterStatus } from '@/api/cardsoon'
import { resolveJobTasks, validateJobConfig } from '@/utils/validateJobConfig'
import { resolveCopyNetworkHosts } from '@/utils/copyNetworkHosts'
import { useNetworkAuthStore } from '@/stores/networkAuth'
@@ -22,11 +22,47 @@ export function printFlagMismatch(printFlag: number, templateFlag: number): bool
)
}
/**
* /
* - null /
* - null DLL
* DLL statusCode
*/
export async function preflightPrinterStatus(): Promise<string | null> {
try {
const r = await dllPrinterStatus()
if (!r.ok) {
const msg = r.message || ''
// DLL 不支持状态查询时无法判断,放行交给后续流程
if (msg.includes('不支持') || msg.toLowerCase().includes('not supported')) {
return null
}
return t('validation.printerNotReady')
}
const data = r.data
if (!data) return t('validation.printerNotReady')
// 健康:主进程返回了明确的非负状态码(0 空闲 / 忙碌 / 打印中)
if (typeof data.statusCode === 'number' && data.statusCode >= 0) return null
// 缓存兜底 + liveError:实时查询失败(断连 / 故障码)
if (data.fromCache && data.liveError) return t('validation.printerNotReady')
// 无状态码(未初始化 / 未知异常):保守拦截
return t('validation.printerNotReady')
} catch {
// 查询异常时不阻塞,让后续流程处理
return null
}
}
export async function validateJobPreflight(form: DistributeFormState): Promise<string | null> {
const err = validateJobConfig(form)
if (err) return err
// 打印机硬件预检:打印/拷贝任务都需要设备,未连接或故障码(如夹卡)时拦截提交
const { hasCopy, hasPrint } = resolveJobTasks(form)
if (hasPrint || hasCopy) {
const fault = await preflightPrinterStatus()
if (fault) return fault
}
if (hasCopy) {
const paths = form.pathList.map((x) => x.path)
@@ -67,6 +67,7 @@ import AppIcon from '@/components/AppIcon.vue'
import { useCollectStore } from '@/stores/collect'
import { useAppStore } from '@/stores/app'
import { dialogOpenDirectory, dllUsbCopy } from '@/api/cardsoon'
import { preflightPrinterStatus } from '@/utils/validateJobPreflight'
const router = useRouter()
const { t } = useI18n()
@@ -102,6 +103,12 @@ async function onSubmit(): Promise<void> {
notify.warning(t('notify.selectDirFirst'))
return
}
//
const fault = await preflightPrinterStatus()
if (fault) {
notify.warning(fault)
return
}
const r = await dllUsbCopy(dest, collectStore.cardOutput)
if (!r.ok) {
notify.error(r.message || t('notify.startUsbFailed'))
@@ -70,13 +70,23 @@
<span class="dog-hint">{{ dongleHint }}</span>
<template v-if="formStore.dongleEnabled">
<span class="dog-auth-label">{{ t('distributeConfig.dongleAuthLabel') }}</span>
<div class="dog-auth-wrap">
<input
v-model="formStore.dongleAuthCode"
type="password"
:type="showAuthCode ? 'text' : 'password'"
class="c-input dog-auth"
:placeholder="t('distributeConfig.donglePassword')"
@input="onDongleAuthInput"
/>
<button
type="button"
class="dog-auth-toggle"
:title="showAuthCode ? t('distributeConfig.hideAuth') : t('distributeConfig.showAuth')"
@click="showAuthCode = !showAuthCode"
>
<AppIcon :name="showAuthCode ? 'eye-slash' : 'eye'" size="sm" />
</button>
</div>
</template>
</label>
</div>
@@ -106,6 +116,14 @@
<button type="button" class="c-button-cs" @click="pickTemplate">
{{ t('distributeConfig.addLabel') }}
</button>
<button
v-if="formStore.templateFile"
type="button"
class="c-button-cs"
@click="removeTemplate"
>
{{ t('distributeConfig.removeLabel') }}
</button>
</div>
</div>
<div v-if="templatePreviewLoaded && hasDoubleSide" class="m-print-side-picker">
@@ -202,7 +220,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { cleanPathPattern } from '@shared/path-pattern'
@@ -249,6 +267,18 @@ const dongleStore = useDongleAuthStore()
const networkDialogVisible = ref(false)
const networkDialogHost = ref('')
const networkDialogShare = ref('')
const showAuthCode = ref(false)
// printFlag
watch(
() => configStore.printer.isSingleSide,
(isSingle) => {
if (isSingle && formStore.printFlag === 1) {
const flag = formStore.templatePreview?.templateFlag ?? 1
formStore.printFlag = defaultPrintFlagForTemplate(flag, true)
}
}
)
let dongleAuthPersistTimer: ReturnType<typeof setTimeout> | null = null
@@ -347,6 +377,13 @@ function onClear(): void {
notify.info(t('distributeConfig.cleared'))
}
function removeTemplate(): void {
formStore.templateFile = ''
formStore.templatePreview = null
formStore.printFlag = 1
notify.info(t('distributeConfig.templateRemoved'))
}
const dongleInputValue = computed(() =>
formStore.dongleEnabled ? formStore.dongleInstallCount : -1
)
@@ -492,6 +529,8 @@ async function onSubmit(): Promise<void> {
return
}
jobStore.submitting = true
// jobId/lastJobJson
jobStore.resetCounts()
try {
const created = await createDistributeJob(formStore)
if (!created.ok) {
@@ -115,6 +115,11 @@ import type { CardPositionPollPayload, JobPollPayload, UsbPollPayload } from '@/
const CIRCLE_LEN = 283
/** 等待进卡/读 U 盘超过该时长仍无进展,给出可操作提示(每个等待回合提示一次) */
const WAIT_WARN_MS = 45000
/** 看门狗检查间隔 */
const WAIT_WATCHDOG_MS = 5000
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
@@ -147,6 +152,9 @@ let lastCardPosition = -1
let usbAwaitNewCycle = false
let queryFailStreak = 0
let usbQueryFailStreak = 0
let waitSince = 0
let waitWarned = false
let waitWatchdog: ReturnType<typeof setInterval> | null = null
const displayProgress = computed(() => Math.round(progress.value))
@@ -193,6 +201,41 @@ function clearPollListeners(): void {
unsubCard = null
}
/** 进入"等待进卡/读 U 盘"回合(已在等待中则不重复计时) */
function markWaiting(): void {
if (waitSince === 0) {
waitSince = Date.now()
waitWarned = false
}
}
/** 离开等待状态(进卡成功 / 任务结束 / 重新提交) */
function clearWaiting(): void {
waitSince = 0
waitWarned = false
}
function stopWaitWatchdog(): void {
if (waitWatchdog) {
clearInterval(waitWatchdog)
waitWatchdog = null
}
clearWaiting()
}
function startWaitWatchdog(): void {
stopWaitWatchdog()
waitWatchdog = setInterval(() => {
if (phase.value !== 'running' || waitSince === 0 || waitWarned) return
if (Date.now() - waitSince < WAIT_WARN_MS) return
waitWarned = true
// U 线//
notify.warning(
isCollect.value ? t('notify.waitTimeoutCollect') : t('notify.waitTimeoutDistribute')
)
}, WAIT_WATCHDOG_MS)
}
async function releasePolls(resetMode: boolean): Promise<void> {
clearPollListeners()
await pollCardPositionStop()
@@ -230,6 +273,7 @@ async function startCardPositionWatch(
}
async function enterWaitPhase(next: 'completed' | 'failed', errorText = ''): Promise<void> {
clearWaiting()
if (phase.value !== next) {
phase.value = next
if (next === 'completed') {
@@ -311,6 +355,7 @@ async function resubmitTask(): Promise<void> {
collectHint.value = ''
queryFailStreak = 0
usbQueryFailStreak = 0
clearWaiting()
const r = await dllUsbCopy(dest, collectStore.cardOutput, { resubmit: true })
if (!r.ok) {
await backToWait(r.message || t('notify.resubmitCollectFailed'))
@@ -318,6 +363,7 @@ async function resubmitTask(): Promise<void> {
}
usbAwaitNewCycle = true
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
markWaiting()
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
void refreshLiveStatus()
return
@@ -336,6 +382,7 @@ async function resubmitTask(): Promise<void> {
workflowStep.value = 1
waitCard.value = false
queryFailStreak = 0
clearWaiting()
const created = await createDistributeJob(formStore, { resubmit: true })
if (!created.ok) {
await backToWait(created.message)
@@ -371,9 +418,12 @@ function applyJobProgress(p: JobPollPayload): void {
const ui = mapJobStateToUi(p.jobState)
workflowStep.value = ui.workflowStep
waitCard.value = ui.hint === 'waitCard'
if (ui.hint === 'waitCard') markWaiting()
else clearWaiting()
if (p.failed) {
jobStore.failCount += 1
void enterFailedPhase()
// 使
void enterFailedPhase(p.errorMessage || '')
return
}
if (p.cancelled) {
@@ -419,6 +469,9 @@ function applyUsbProgress(p: UsbPollPayload): void {
const copyProgress = clampUsbCopyProgress(p.progress)
workflowStep.value = p.taskStatus === USB_TASK_COPYING ? 2 : 1
collectHint.value = usbTaskStatusHint(p.taskStatus)
// PREPARING/ U COPYING
if (p.taskStatus === USB_TASK_PREPARING) markWaiting()
else clearWaiting()
setProgress(copyProgress)
}
@@ -459,6 +512,8 @@ onMounted(async () => {
workflowStep.value = 1
setProgress(0)
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
startWaitWatchdog()
markWaiting()
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
void refreshLiveStatus()
return
@@ -470,6 +525,8 @@ onMounted(async () => {
}
appStore.setMode('distributing')
setProgress(0)
clearWaiting()
startWaitWatchdog()
const started = await pollJobStart(jobStore.jobId)
if (!started.ok) {
notify.error(started.message || t('notify.pollStartFailed'))
@@ -481,6 +538,7 @@ onMounted(async () => {
})
onUnmounted(() => {
stopWaitWatchdog()
if (finishing || phase.value !== 'running') return
void releasePolls(true)
if (isCollect.value && appStore.mode === 'usbCopying') {
@@ -537,11 +595,12 @@ async function onStop(): Promise<void> {
<style scoped>
.m-error-detail {
margin-top: 6px;
font-size: 10px;
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: #dc3545;
font-weight: 600;
max-width: 320px;
max-width: 340px;
word-break: break-all;
}
+22 -6
View File
@@ -9,18 +9,34 @@
<AppIcon name="redo" />
<span>{{ t('common.resetPrinter') }}</span>
</button>
<button type="button" class="m-tool-btn" @click="onReject">
<button
type="button"
class="m-tool-btn"
:disabled="!configStore.rejectApiAvailable"
:title="configStore.rejectApiAvailable ? t('common.discardCard') : t('notify.rejectUnavailable')"
@click="onReject"
>
<AppIcon name="trash" />
<span>{{ t('common.discardCard') }}</span>
</button>
<div class="m-tool-row">
<button type="button" class="m-tool-btn m-tool-btn--half" @click="onReadCard">
<AppIcon name="id-card" />
<div class="m-tool-versus">
<button
type="button"
class="m-versus-half m-versus-half--read"
:title="t('home.readCard')"
@click="onReadCard"
>
<AppIcon name="id-card" size="sm" />
<span>{{ t('home.readCard') }}</span>
</button>
<button type="button" class="m-tool-btn m-tool-btn--half" @click="onEjectCard">
<AppIcon name="eject" />
<button
type="button"
class="m-versus-half m-versus-half--eject"
:title="t('home.ejectCard')"
@click="onEjectCard"
>
<span>{{ t('home.ejectCard') }}</span>
<AppIcon name="eject" size="sm" />
</button>
</div>
<button type="button" class="m-tool-btn" @click="onTemplate">
File diff suppressed because one or more lines are too long