Compare commits

...

5 Commits

Author SHA1 Message Date
24kycj 9ab1bcbd5e 优化提交预检、废卡按钮置灰与等待超时提示 2026-09-09 23:45:25 +08:00
24kycj ac566846c9 优化bug 2026-09-09 10:36:42 +08:00
24kycj 19858f9a62 优化调整 2026-06-30 17:09:17 +08:00
24kycj 3918522831 chore: 更新 native 依赖
- 更新 workDll.dll

- 添加 OpenCV Release 版 opencv_world490.dll

- 添加 simhei.ttf 字体资源

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 23:21:55 +08:00
24kycj 7862ff2b2a fix: 测试联调四项修复
- 授权码并入加密狗行,password 掩码,窄宽样式

- 打印机信息仅调用 SAPI_GetPrinterInfo,移除 Ex 避免崩溃

- net_info 改为 JSON 数组,修复双重编码

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 21:08:37 +08:00
49 changed files with 2758 additions and 402 deletions
+16 -1
View File
@@ -1,6 +1,10 @@
import { resolve } from 'path' import { resolve } from 'path'
import { readFileSync } from 'fs'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import vue from '@vitejs/plugin-vue' 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 }
const sharedAlias = { '@shared': resolve('src/shared') } const sharedAlias = { '@shared': resolve('src/shared') }
@@ -19,6 +23,17 @@ export default defineConfig({
...sharedAlias ...sharedAlias
} }
}, },
plugins: [vue()] define: {
__APP_VERSION__: JSON.stringify(pkg.version)
},
plugins: [
vue(),
// 构建时预编译多语言消息 + 使用 vue-i18n runtime 构建,
// 避免运行时 new Function() 编译消息(会被 CSP script-src 'self' 拦截导致白屏)
VueI18nPlugin({
include: [resolve('src/renderer/src/i18n/locales/**')],
runtimeOnly: true
})
]
} }
}) })
+1054 -74
View File
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -17,12 +17,15 @@
"predist:zip": "node scripts/check-native-dlls.js && node scripts/clean-release-artifacts.js", "predist:zip": "node scripts/check-native-dlls.js && node scripts/clean-release-artifacts.js",
"dist": "electron-vite build && electron-builder", "dist": "electron-vite build && electron-builder",
"dist:dir": "electron-vite build && electron-builder --dir", "dist:dir": "electron-vite build && electron-builder --dir",
"dist:zip": "electron-vite build && electron-builder --win zip" "dist:zip": "electron-vite build && electron-builder --win zip",
"prerelease": "node scripts/check-native-dlls.js && node scripts/clean-release-artifacts.js",
"release": "electron-vite build && electron-builder --win nsis"
}, },
"dependencies": { "dependencies": {
"electron-log": "^5.1.2", "electron-log": "^5.1.2",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"koffi": "^2.9.0" "koffi": "^2.9.0",
"vue-i18n": "^9.14.4"
}, },
"build": { "build": {
"appId": "com.cardsoon.machine", "appId": "com.cardsoon.machine",
@@ -70,6 +73,11 @@
], ],
"signAndEditExecutable": false "signAndEditExecutable": false
}, },
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"perMachine": false
},
"mac": { "mac": {
"target": [ "target": [
"dmg" "dmg"
@@ -82,6 +90,7 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@intlify/unplugin-vue-i18n": "^1.6.0",
"@vitejs/plugin-vue": "^4.6.2", "@vitejs/plugin-vue": "^4.6.2",
"electron": "20.3.12", "electron": "20.3.12",
"electron-builder": "^24.13.3", "electron-builder": "^24.13.3",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9 -3
View File
@@ -47,7 +47,9 @@ eq('collectHosts local only', collectHostsForCopyPaths(['E:\\data'], {}), [])
const stored = new Map([['192.168.1.100', { userName: 'admin', password: 'pass', lastUsed: '' }]]) const stored = new Map([['192.168.1.100', { userName: 'admin', password: 'pass', lastUsed: '' }]])
const r1 = buildNetInfo([{ hostName: '192.168.1.100' }], (h) => stored.get(h) || null) const r1 = buildNetInfo([{ hostName: '192.168.1.100' }], (h) => stored.get(h) || null)
eq('buildNetInfo single', r1, '[{"host_name":"192.168.1.100","user_name":"admin","password":"pass"}]') eq('buildNetInfo single', r1, [
{ host_name: '192.168.1.100', user_name: 'admin', password: 'pass' }
])
const r2 = buildNetInfo( const r2 = buildNetInfo(
[ [
@@ -56,7 +58,7 @@ const r2 = buildNetInfo(
], ],
() => null () => null
) )
eq('buildNetInfo dedup', r2, '[{"host_name":"192.168.1.100","user_name":"a","password":"p"}]') eq('buildNetInfo dedup', r2, [{ host_name: '192.168.1.100', user_name: 'a', password: 'p' }])
let threw = false let threw = false
try { try {
@@ -65,7 +67,11 @@ try {
threw = e.message.includes('缺少网络凭据') threw = e.message.includes('缺少网络凭据')
} }
eq('buildNetInfo missing throws', threw, true) eq('buildNetInfo missing throws', threw, true)
eq('buildNetInfo empty list', buildNetInfo([], () => null), '') eq('buildNetInfo empty list', buildNetInfo([], () => null), [])
const jobBody = { net_info: r1 }
const jobParsed = JSON.parse(JSON.stringify(jobBody))
eq('net_info is array in job json', Array.isArray(jobParsed.net_info), true)
const pass = cases.filter((c) => c.ok).length const pass = cases.filter((c) => c.ok).length
const fail = cases.length - pass const fail = cases.length - pass
+22 -16
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, globalShortcut, screen } from 'electron' import { app, BrowserWindow, dialog, globalShortcut } from 'electron'
import { join } from 'path' import { join } from 'path'
app.commandLine.appendSwitch('disable-gpu-shader-disk-cache') app.commandLine.appendSwitch('disable-gpu-shader-disk-cache')
@@ -14,10 +14,12 @@ if (!gotSingleInstanceLock) {
app.quit() app.quit()
} }
import { ensureConsoleUtf8 } from './utils/ensure-console-utf8'
import { suppressKnownDllStderr } from './utils/suppress-dll-stderr' import { suppressKnownDllStderr } from './utils/suppress-dll-stderr'
import { loadAppFileConfig, applyFileConfigToStore } from './services/app-config' import { loadAppFileConfig, applyFileConfigToStore } from './services/app-config'
import { migrateTraceConfig, setTraceWebContents } from './utils/trace-bridge' import { migrateTraceConfig, setTraceWebContents } from './utils/trace-bridge'
ensureConsoleUtf8()
suppressKnownDllStderr() suppressKnownDllStderr()
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
@@ -41,24 +43,12 @@ function focusMainWindow(): void {
mainWindow.focus() mainWindow.focus()
} }
function getDefaultWindowSize(): { width: number; height: number } {
const { width: sw, height: sh } = screen.getPrimaryDisplay().workAreaSize
let w = Math.max(1280, Math.min(Math.floor(sw * 0.85), 1600))
let h = contentHeightForWidth(w)
const maxH = Math.floor(sh * 0.85)
if (h > maxH) {
h = Math.max(contentHeightForWidth(MIN_CONTENT_WIDTH), maxH)
w = Math.round((h * DESIGN_WIDTH) / DESIGN_HEIGHT)
}
return { width: w, height: h }
}
function createWindow(): void { function createWindow(): void {
const { width, height } = getDefaultWindowSize() // 目标设备屏幕为 720x360,窗口默认 1:1 显示设计稿大小
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
useContentSize: true, useContentSize: true,
width, width: DESIGN_WIDTH,
height, height: DESIGN_HEIGHT,
minWidth: MIN_CONTENT_WIDTH, minWidth: MIN_CONTENT_WIDTH,
minHeight: contentHeightForWidth(MIN_CONTENT_WIDTH), minHeight: contentHeightForWidth(MIN_CONTENT_WIDTH),
show: false, show: false,
@@ -94,6 +84,8 @@ function createWindow(): void {
mainWindow.on('resize', () => { mainWindow.on('resize', () => {
if (!mainWindow) return if (!mainWindow) return
// 最大化时让窗口填满屏幕,不强制 2:1 内容比例
if (mainWindow.isMaximized()) return
const [cw, ch] = mainWindow.getContentSize() const [cw, ch] = mainWindow.getContentSize()
const wantH = contentHeightForWidth(cw) const wantH = contentHeightForWidth(cw)
if (Math.abs(ch - wantH) > 2) { if (Math.abs(ch - wantH) > 2) {
@@ -114,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) { if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else { } else {
+50 -4
View File
@@ -68,7 +68,7 @@ export function registerIpcHandlers(): void {
return ok({ return ok({
...cached, ...cached,
fromCache: true, fromCache: true,
liveError: r.code <= 0 ? '未连接打印机' : `GetPrinterInfoEx code=${r.code}` liveError: r.code <= 0 ? '未连接打印机' : `GetPrinterInfo code=${r.code}`
}) })
} }
return fail(0, '未连接打印机') return fail(0, '未连接打印机')
@@ -107,7 +107,9 @@ export function registerIpcHandlers(): void {
ribbonType: cached?.ribbonType ?? '—', ribbonType: cached?.ribbonType ?? '—',
ribbonAmount: cached?.ribbonAmount ?? '—', ribbonAmount: cached?.ribbonAmount ?? '—',
statusText: parsed.statusText, statusText: parsed.statusText,
serialNo: cached?.serialNo ?? '—' serialNo: cached?.serialNo ?? '—',
printerName: cached?.printerName ?? '—',
isSingleSide: cached?.isSingleSide ?? false
} }
configStore.set('lastPrinterStatus', snapshot) configStore.set('lastPrinterStatus', snapshot)
return ok({ statusText: parsed.statusText, statusCode: code }) return ok({ statusText: parsed.statusText, statusCode: code })
@@ -134,7 +136,12 @@ export function registerIpcHandlers(): void {
assertReady() assertReady()
const dll = await loadDllModule() const dll = await loadDllModule()
const code = dll.dllPrinterReset() 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) { } catch (err) {
return fail(CS_FAIL, String(err)) return fail(CS_FAIL, String(err))
} }
@@ -146,7 +153,46 @@ export function registerIpcHandlers(): void {
const dll = await loadDllModule() const dll = await loadDllModule()
if (!dll.isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE') if (!dll.isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE')
const code = dll.dllPrinterReject() 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))
}
})
// 读卡:移动卡片到读取区(SAPI_PrinterMovetousbreader
tracedHandle('dll:printer-read-card', async () => {
try {
assertReady()
const dll = await loadDllModule()
if (!dll.isUsbReaderApiAvailable()) return fail(CS_FAIL, 'USB_READER_API_UNAVAILABLE')
const code = dll.dllPrinterMoveToUsbReader()
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))
}
})
// 退卡:移动卡片到出卡区(SAPI_PrinterMovetohopper
tracedHandle('dll:printer-eject-card', async () => {
try {
assertReady()
const dll = await loadDllModule()
if (!dll.isHopperApiAvailable()) return fail(CS_FAIL, 'HOPPER_API_UNAVAILABLE')
const code = dll.dllPrinterMoveToHopper()
if (code < 0) {
log.warn('SAPI_PrinterMovetohopper returned non-OK code', code)
return fail(code, '退卡失败')
}
// 卡已物理退出但返回正数状态码时,不再误报失败
return ok({ code })
} catch (err) { } catch (err) {
return fail(CS_FAIL, String(err)) return fail(CS_FAIL, String(err))
} }
+3 -1
View File
@@ -23,7 +23,9 @@ export async function openDesignApp(
const target = path.resolve(exePath.trim()) const target = path.resolve(exePath.trim())
const err = await shell.openPath(target) const err = await shell.openPath(target)
if (err) { if (err) {
return { ok: false, message: err } // 用户在 UAC/系统弹窗中点击"否"或被系统拒绝时,shell.openPath 返回通用错误字符串
// 不把原始 "Failed to Open Path" 直接抛给用户,给出可读说明
return { ok: false, message: '打开设计软件失败,可能已被取消或需要管理员权限' }
} }
return { ok: true } return { ok: true }
} }
+11 -1
View File
@@ -80,6 +80,15 @@ export function startJobPoll(id: string): void {
const cancelled = r.jobState === 6 const cancelled = r.jobState === 6
const finished = r.jobState === 100 const finished = r.jobState === 100
const terminal = failed || cancelled 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 = { const tick = {
jobId, jobId,
queryErrorCode: r.queryErrorCode, queryErrorCode: r.queryErrorCode,
@@ -88,7 +97,8 @@ export function startJobPoll(id: string): void {
terminal, terminal,
failed, failed,
cancelled, cancelled,
finished finished,
errorMessage
} }
emitTrace('[poll] job:poll-tick', tick) emitTrace('[poll] job:poll-tick', tick)
send('job:poll-tick', tick) send('job:poll-tick', tick)
+43 -25
View File
@@ -24,8 +24,6 @@ let SAPI_Init: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterInfo: any = null let SAPI_GetPrinterInfo: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterInfoEx: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_FreePrinterInfo: any = null let SAPI_FreePrinterInfo: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterErrorStr: any = null let SAPI_GetPrinterErrorStr: any = null
@@ -46,6 +44,8 @@ let SAPI_PrinterMovetoreject: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterMovetousbreader: any = null let SAPI_PrinterMovetousbreader: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 let SAPI_GetPrinterCardPosition: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterCheckstatus: any = null let SAPI_PrinterCheckstatus: any = null
@@ -56,8 +56,8 @@ let hasCardPositionApi = false
let hasCheckstatusApi = false let hasCheckstatusApi = false
let hasCancelApi = false let hasCancelApi = false
let hasUploadApi = false let hasUploadApi = false
let hasPrinterInfoEx = false
let hasUsbReaderApi = false let hasUsbReaderApi = false
let hasHopperApi = false
let loggedCancelMissing = false let loggedCancelMissing = false
let loggedRejectMissing = false let loggedRejectMissing = false
@@ -82,6 +82,20 @@ function traceCall<T>(name: string, args: Record<string, unknown> | undefined, f
} }
} }
function freePrinterInfoPtr(ptr: number): void {
if (!ptr) return
// DLL 堆分配的缓冲区必须用 DLL 导出的释放函数;koffi.free 会触发 STATUS_HEAP_CORRUPTION
if (SAPI_FreePrinterInfo) {
try {
SAPI_FreePrinterInfo(ptr)
} catch (e) {
log.warn('SAPI_FreePrinterInfo failed', e)
}
return
}
log.warn('SAPI_FreePrinterInfo unavailable; printer info buffer not freed')
}
function readPrinterJsonFromOutPtr(len: number, outPtr: Buffer): { code: number; json?: Record<string, unknown> } { function readPrinterJsonFromOutPtr(len: number, outPtr: Buffer): { code: number; json?: Record<string, unknown> } {
if (len <= 0) return { code: len } if (len <= 0) return { code: len }
const ptr = koffi.decode(outPtr, 0, 'void *') as number const ptr = koffi.decode(outPtr, 0, 'void *') as number
@@ -95,15 +109,7 @@ function readPrinterJsonFromOutPtr(len: number, outPtr: Buffer): { code: number;
return { code: len } return { code: len }
} }
} finally { } finally {
if (SAPI_FreePrinterInfo) { freePrinterInfoPtr(ptr)
try {
SAPI_FreePrinterInfo(ptr)
} catch (e) {
log.warn('SAPI_FreePrinterInfo', e)
}
} else {
koffi.free(ptr)
}
} }
} }
@@ -124,13 +130,10 @@ function loadLibrary(): void {
SAPI_PrinterResetprinter = lib.func('int __stdcall SAPI_PrinterResetprinter()') SAPI_PrinterResetprinter = lib.func('int __stdcall SAPI_PrinterResetprinter()')
try { try {
SAPI_GetPrinterInfoEx = lib.func('int __stdcall SAPI_GetPrinterInfoEx(_Out_ void **)')
SAPI_FreePrinterInfo = lib.func('void __stdcall SAPI_FreePrinterInfo(void *)') SAPI_FreePrinterInfo = lib.func('void __stdcall SAPI_FreePrinterInfo(void *)')
hasPrinterInfoEx = true
} catch { } catch {
SAPI_GetPrinterInfoEx = null
SAPI_FreePrinterInfo = null SAPI_FreePrinterInfo = null
hasPrinterInfoEx = false log.warn('SAPI_FreePrinterInfo not in workDll')
} }
try { try {
@@ -174,6 +177,15 @@ function loadLibrary(): void {
emitTrace('[dll] SAPI_PrinterMovetousbreader not in workDll (optional)') emitTrace('[dll] SAPI_PrinterMovetousbreader not in workDll (optional)')
} }
try {
SAPI_PrinterMovetohopper = lib.func('int __stdcall SAPI_PrinterMovetohopper()')
hasHopperApi = true
} catch {
SAPI_PrinterMovetohopper = null
hasHopperApi = false
emitTrace('[dll] SAPI_PrinterMovetohopper not in workDll (optional)')
}
try { try {
SAPI_GetPrinterCardPosition = lib.func('int __stdcall SAPI_GetPrinterCardPosition(_Out_ int *)') SAPI_GetPrinterCardPosition = lib.func('int __stdcall SAPI_GetPrinterCardPosition(_Out_ int *)')
hasCardPositionApi = true hasCardPositionApi = true
@@ -194,10 +206,10 @@ function loadLibrary(): void {
log.info('workDll loaded', { log.info('workDll loaded', {
upload: hasUploadApi, upload: hasUploadApi,
printerInfoEx: hasPrinterInfoEx,
cancel: hasCancelApi, cancel: hasCancelApi,
reject: hasRejectApi, reject: hasRejectApi,
usbReader: hasUsbReaderApi, usbReader: hasUsbReaderApi,
hopper: hasHopperApi,
cardPosition: hasCardPositionApi, cardPosition: hasCardPositionApi,
checkstatus: hasCheckstatusApi checkstatus: hasCheckstatusApi
}) })
@@ -223,6 +235,11 @@ export function isUsbReaderApiAvailable(): boolean {
return hasUsbReaderApi return hasUsbReaderApi
} }
export function isHopperApiAvailable(): boolean {
loadLibrary()
return hasHopperApi
}
export function isCardPositionApiAvailable(): boolean { export function isCardPositionApiAvailable(): boolean {
loadLibrary() loadLibrary()
return hasCardPositionApi return hasCardPositionApi
@@ -263,7 +280,7 @@ export function dllInit(params: InitParams): number {
} }
function dllGetPrinterInfoInternal( function dllGetPrinterInfoInternal(
apiName: 'SAPI_GetPrinterInfo' | 'SAPI_GetPrinterInfoEx', apiName: 'SAPI_GetPrinterInfo',
fn: (outPtr: Buffer) => number fn: (outPtr: Buffer) => number
): { code: number; json?: Record<string, unknown> } { ): { code: number; json?: Record<string, unknown> } {
return traceCall(apiName, undefined, () => { return traceCall(apiName, undefined, () => {
@@ -279,13 +296,6 @@ function dllGetPrinterInfoInternal(
} }
export function dllGetPrinterInfo(): { code: number; json?: Record<string, unknown> } { export function dllGetPrinterInfo(): { code: number; json?: Record<string, unknown> } {
loadLibrary()
if (hasPrinterInfoEx && SAPI_GetPrinterInfoEx) {
const ex = dllGetPrinterInfoInternal('SAPI_GetPrinterInfoEx', (p) => SAPI_GetPrinterInfoEx!(p))
if (ex.json && Object.keys(ex.json).length > 0) {
return ex
}
}
return dllGetPrinterInfoInternal('SAPI_GetPrinterInfo', (p) => SAPI_GetPrinterInfo!(p)) return dllGetPrinterInfoInternal('SAPI_GetPrinterInfo', (p) => SAPI_GetPrinterInfo!(p))
} }
@@ -385,6 +395,14 @@ export function dllPrinterMoveToUsbReader(): number {
}) })
} }
export function dllPrinterMoveToHopper(): number {
return traceCall('SAPI_PrinterMovetohopper', undefined, () => {
loadLibrary()
if (!SAPI_PrinterMovetohopper) throw new Error('HOPPER_API_UNAVAILABLE')
return SAPI_PrinterMovetohopper() as number
})
}
export function dllPrinterReject(): number { export function dllPrinterReject(): number {
return traceCall('SAPI_PrinterMovetoreject', undefined, () => { return traceCall('SAPI_PrinterMovetoreject', undefined, () => {
loadLibrary() loadLibrary()
+21
View File
@@ -0,0 +1,21 @@
import koffi from 'koffi'
/** Windows 控制台默认多为 GBKDLL 日志为 UTF-8,会导致中文乱码。 */
export function ensureConsoleUtf8(): void {
if (process.platform !== 'win32') return
try {
const kernel32 = koffi.load('kernel32.dll')
const SetConsoleOutputCP = kernel32.func('bool __stdcall SetConsoleOutputCP(uint32)')
const SetConsoleCP = kernel32.func('bool __stdcall SetConsoleCP(uint32)')
SetConsoleOutputCP(65001)
SetConsoleCP(65001)
} catch {
/* ignore: no console or API unavailable */
}
try {
if (process.stdout.setDefaultEncoding) process.stdout.setDefaultEncoding('utf8')
if (process.stderr.setDefaultEncoding) process.stderr.setDefaultEncoding('utf8')
} catch {
/* ignore */
}
}
+2
View File
@@ -7,6 +7,8 @@ const channels = {
'dll:printer-status', 'dll:printer-status',
'dll:printer-reset', 'dll:printer-reset',
'dll:printer-reject', 'dll:printer-reject',
'dll:printer-read-card',
'dll:printer-eject-card',
'dll:printer-error-str', 'dll:printer-error-str',
'dll:job-create', 'dll:job-create',
'dll:job-cancel', 'dll:job-cancel',
+10
View File
@@ -34,6 +34,16 @@ export async function dllPrinterReject(): Promise<IpcResult> {
return api().invoke('dll:printer-reject') as Promise<IpcResult> return api().invoke('dll:printer-reject') as Promise<IpcResult>
} }
/** 读卡:移动卡片到读取区(SAPI_PrinterMovetousbreader */
export async function dllPrinterReadCard(): Promise<IpcResult> {
return api().invoke('dll:printer-read-card') as Promise<IpcResult>
}
/** 退卡:移动卡片到出卡区(SAPI_PrinterMovetohopper */
export async function dllPrinterEjectCard(): Promise<IpcResult> {
return api().invoke('dll:printer-eject-card') as Promise<IpcResult>
}
export async function dllRejectAvailable(): Promise<IpcResult<{ available: boolean }>> { export async function dllRejectAvailable(): Promise<IpcResult<{ available: boolean }>> {
return api().invoke('dll:reject-available') as Promise<IpcResult<{ available: boolean }>> return api().invoke('dll:reject-available') as Promise<IpcResult<{ available: boolean }>>
} }
+10 -2
View File
@@ -13,7 +13,11 @@ export const ICON_NAMES = [
'plus', 'plus',
'exchange', 'exchange',
'stop', 'stop',
'warning' 'warning',
'id-card',
'eject',
'eye',
'eye-slash'
] as const ] as const
export type IconName = (typeof ICON_NAMES)[number] export type IconName = (typeof ICON_NAMES)[number]
@@ -34,5 +38,9 @@ export const ICON_FA_CLASS: Record<IconName, string> = {
plus: 'fas fa-plus', plus: 'fas fa-plus',
exchange: 'fas fa-exchange-alt', exchange: 'fas fa-exchange-alt',
stop: 'fas fa-stop', stop: 'fas fa-stop',
warning: 'fas fa-exclamation-triangle' warning: 'fas fa-exclamation-triangle',
'id-card': 'fas fa-id-card',
eject: 'fas fa-eject',
eye: 'fas fa-eye',
'eye-slash': 'fas fa-eye-slash'
} }
+11 -3
View File
@@ -1,7 +1,15 @@
<template> <template>
<footer class="c-footer"> <footer class="c-footer">
<div>版本V1.0</div> <div class="c-footer__version">{{ t('footer.version', { version: appVersion }) }}</div>
<div>www.cardsoon.com</div> <div>{{ t('footer.website') }}</div>
<div>版权所有 © 2026 卡树科技</div> <div>{{ t('footer.copyright') }}</div>
</footer> </footer>
</template> </template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// 构建时由 electron.vite.config.ts 从 package.json version 注入
const appVersion = __APP_VERSION__
</script>
+65 -9
View File
@@ -5,16 +5,24 @@
<div class="c-header__center"> <div class="c-header__center">
<div v-if="mode" class="c-mode-badge c-mode-badge--home">{{ mode }}</div> <div v-if="mode" class="c-mode-badge c-mode-badge--home">{{ mode }}</div>
<div class="c-status-capsule"> <div class="c-status-capsule">
<span>色带: <b>{{ status.ribbonType }}</b></span> <span>{{ t('header.ribbon') }}: <b>{{ status.ribbonType }}</b></span>
<span>余量: <b>{{ status.ribbonAmount }}</b></span> <span>{{ t('header.ribbonAmount') }}: <b>{{ status.ribbonAmount }}</b></span>
<span <span>{{ t('header.status') }}: <b :class="statusTone">{{ displayStatusText }}</b></span>
>状态: <b :class="statusTone">{{ status.statusText }}</b></span <span>{{ t('header.serialNo') }}: <b>{{ status.serialNo }}</b></span>
>
<span>序列号: <b>{{ status.serialNo }}</b></span>
</div> </div>
</div> </div>
<div class="c-header__actions-slot"> <div class="c-header__actions-slot">
<div class="c-header-actions"> <div class="c-header-actions">
<select
class="c-lang-select"
:value="currentLocale"
@change="onLocaleChange"
:title="t('common.select')"
>
<option v-for="opt in LOCALE_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<slot /> <slot />
</div> </div>
</div> </div>
@@ -24,16 +32,64 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { LOCALE_OPTIONS, persistLocale, type AppLocale } from '@/i18n'
defineProps<{ mode?: string }>() defineProps<{ mode?: string }>()
const configStore = useConfigStore() const configStore = useConfigStore()
const status = computed(() => configStore.printer) const status = computed(() => configStore.printer)
const { t, locale } = useI18n()
const currentLocale = computed(() => locale.value)
function onLocaleChange(e: Event): void {
const val = (e.target as HTMLSelectElement).value as AppLocale
locale.value = val
persistLocale(val)
}
// 打印机状态文本来自主进程(中文),在前端按已知值做多语言映射
const PRINTER_STATUS_MAP: Record<string, string> = {
空闲: 'printerStatus.idle',
忙碌: 'printerStatus.busy',
正在打印: 'printerStatus.printing',
未连接打印机: 'printerStatus.notConnected',
未初始化: 'printerStatus.notInitialized',
初始化失败: 'printerStatus.initFailed',
就绪: 'printerStatus.ready'
}
const displayStatusText = computed(() => {
const raw = status.value.statusText
const key = PRINTER_STATUS_MAP[raw]
return key ? t(key) : raw
})
const statusTone = computed(() => { const statusTone = computed(() => {
const t = status.value.statusText const toneMap: Record<string, string> = {
if (t.includes('未初始化') || t.includes('未连接') || t.includes('失败')) return 'c-status-warn' [t('printerStatus.notInitialized')]: 'c-status-warn',
return '' [t('printerStatus.notConnected')]: 'c-status-warn',
[t('printerStatus.initFailed')]: 'c-status-warn'
}
return toneMap[displayStatusText.value] || ''
}) })
</script> </script>
<style scoped>
.c-lang-select {
height: 28px;
font-size: 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
color: #303133;
cursor: pointer;
padding: 0 6px;
outline: none;
}
.c-lang-select:hover {
border-color: #409eff;
}
</style>
@@ -4,69 +4,69 @@
<div v-if="visible" class="npd-mask" @mousedown.self="onCancel"> <div v-if="visible" class="npd-mask" @mousedown.self="onCancel">
<div class="npd-dialog" role="dialog" aria-modal="true" aria-labelledby="npd-title"> <div class="npd-dialog" role="dialog" aria-modal="true" aria-labelledby="npd-title">
<div class="npd-header"> <div class="npd-header">
<span id="npd-title" class="npd-title">添加网络位置</span> <span id="npd-title" class="npd-title">{{ t('networkDialog.title') }}</span>
<button type="button" class="npd-close" aria-label="关闭" @click="onCancel">×</button> <button type="button" class="npd-close" :aria-label="t('common.close')" @click="onCancel">×</button>
</div> </div>
<div class="npd-body"> <div class="npd-body">
<p class="npd-hint">凭据用于访问已添加的映射盘或网络共享路径,请确认主机可访问且账号有效</p> <p class="npd-hint">{{ t('networkDialog.hint') }}</p>
<label class="npd-field"> <label class="npd-field">
<span class="npd-label">主机(IP 或主机名)<span class="npd-req">*</span></span> <span class="npd-label">{{ t('networkDialog.host') }}<span class="npd-req">*</span></span>
<input <input
v-model.trim="host" v-model.trim="host"
type="text" type="text"
class="c-input" class="c-input"
placeholder="例如 192.168.1.100 或 nas-server" :placeholder="t('networkDialog.hostPlaceholder')"
:class="{ 'is-invalid': touched && !hostValid }" :class="{ 'is-invalid': touched && !hostValid }"
@blur="touched = true" @blur="touched = true"
/> />
</label> </label>
<label class="npd-field"> <label class="npd-field">
<span class="npd-label">共享名(可选)</span> <span class="npd-label">{{ t('networkDialog.share') }}</span>
<input <input
v-model.trim="share" v-model.trim="share"
type="text" type="text"
class="c-input" class="c-input"
placeholder="例如 share,留空表示只挂载到根" :placeholder="t('networkDialog.sharePlaceholder')"
/> />
</label> </label>
<label class="npd-field"> <label class="npd-field">
<span class="npd-label">用户名<span class="npd-req">*</span></span> <span class="npd-label">{{ t('networkDialog.userName') }}<span class="npd-req">*</span></span>
<input <input
v-model.trim="userName" v-model.trim="userName"
type="text" type="text"
class="c-input" class="c-input"
placeholder="例如 admin" :placeholder="t('networkDialog.userNamePlaceholder')"
:class="{ 'is-invalid': touched && !userNameValid }" :class="{ 'is-invalid': touched && !userNameValid }"
@blur="touched = true" @blur="touched = true"
/> />
</label> </label>
<label class="npd-field"> <label class="npd-field">
<span class="npd-label">密码<span class="npd-req">*</span></span> <span class="npd-label">{{ t('networkDialog.password') }}<span class="npd-req">*</span></span>
<input <input
v-model="password" v-model="password"
type="password" type="password"
class="c-input" class="c-input"
placeholder="请输入密码" :placeholder="t('networkDialog.errPassword')"
:class="{ 'is-invalid': touched && !passwordValid }" :class="{ 'is-invalid': touched && !passwordValid }"
@blur="touched = true" @blur="touched = true"
/> />
</label> </label>
<div v-if="previewUrl" class="npd-preview"> <div v-if="previewUrl" class="npd-preview">
<span class="npd-preview-label">目标 UNC</span> <span class="npd-preview-label">{{ t('networkDialog.targetUNC') }}</span>
<code class="npd-preview-path">{{ previewUrl }}</code> <code class="npd-preview-path">{{ previewUrl }}</code>
</div> </div>
<p v-if="touched && errorText" class="npd-error">{{ errorText }}</p> <p v-if="touched && errorText" class="npd-error">{{ errorText }}</p>
</div> </div>
<div class="npd-footer"> <div class="npd-footer">
<button type="button" class="c-button-cs" @click="onCancel">取消</button> <button type="button" class="c-button-cs" @click="onCancel">{{ t('common.cancel') }}</button>
<button type="button" class="c-button-cs npd-primary" :disabled="!canConfirm" @click="onConfirm"> <button type="button" class="c-button-cs npd-primary" :disabled="!canConfirm" @click="onConfirm">
确定 {{ t('common.confirm') }}
</button> </button>
</div> </div>
</div> </div>
@@ -77,6 +77,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { buildNetworkUrl } from '@shared/network-host' import { buildNetworkUrl } from '@shared/network-host'
import { useNetworkAuthStore } from '@/stores/networkAuth' import { useNetworkAuthStore } from '@/stores/networkAuth'
@@ -92,6 +93,7 @@ const emit = defineEmits<{
}>() }>()
const netStore = useNetworkAuthStore() const netStore = useNetworkAuthStore()
const { t } = useI18n()
const host = ref('') const host = ref('')
const share = ref('') const share = ref('')
@@ -109,9 +111,9 @@ const canConfirm = computed(() => hostValid.value && userNameValid.value && pass
const previewUrl = computed(() => (hostValid.value ? buildNetworkUrl(host.value, share.value) : '')) const previewUrl = computed(() => (hostValid.value ? buildNetworkUrl(host.value, share.value) : ''))
const errorText = computed(() => { const errorText = computed(() => {
if (!hostValid.value) return '主机名/IP 不合法' if (!hostValid.value) return t('networkDialog.errHostInvalid')
if (!userNameValid.value) return '请输入用户名' if (!userNameValid.value) return t('networkDialog.errUserName')
if (!passwordValid.value) return '请输入密码' if (!passwordValid.value) return t('networkDialog.errPassword')
return '' return ''
}) })
@@ -12,6 +12,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -23,20 +24,22 @@ const props = withDefaults(
{ activeStep: 2, mode: 'running', variant: 'distribute' } { activeStep: 2, mode: 'running', variant: 'distribute' }
) )
const distributeSteps = [ const { t } = useI18n()
{ key: 'prep', label: '任务准备' },
{ key: 'copy', label: '拷贝数据' },
{ key: 'print', label: '打印卡片' },
{ key: 'done', label: '完成' }
] as const
const collectSteps = [ const distributeSteps = computed(() => [
{ key: 'prep', label: '任务准备' }, { key: 'prep', label: t('workflow.taskPrep') },
{ key: 'copy', label: '拷贝数据' }, { key: 'copy', label: t('workflow.copyData') },
{ key: 'done', label: '完成' } { key: 'print', label: t('workflow.printCard') },
] as const { key: 'done', label: t('workflow.complete') }
])
const steps = computed(() => (props.variant === 'collect' ? collectSteps : distributeSteps)) const collectSteps = computed(() => [
{ key: 'prep', label: t('workflow.taskPrep') },
{ key: 'copy', label: t('workflow.copyData') },
{ key: 'done', label: t('workflow.complete') }
])
const steps = computed(() => (props.variant === 'collect' ? collectSteps.value : distributeSteps.value))
const failedStep = computed(() => props.failedStep ?? (props.mode === 'failed' ? 3 : -1)) const failedStep = computed(() => props.failedStep ?? (props.mode === 'failed' ? 3 : -1))
@@ -1,10 +1,15 @@
import { onMounted } from 'vue' import { onMounted } from 'vue'
import i18n from '@/i18n'
import { notify } from '@/composables/useNotify' import { notify } from '@/composables/useNotify'
import { refreshPrinterAfterInit } from '@/composables/usePrinterStatus' import { refreshPrinterAfterInit } from '@/composables/usePrinterStatus'
import { configGet, dllInit, dllRejectAvailable } from '@/api/cardsoon' import { configGet, dllInit, dllRejectAvailable } from '@/api/cardsoon'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
function t(key: string): string {
return i18n.global.t(key)
}
let bootstrapped = false let bootstrapped = false
function placeholderStatus(configStore: ReturnType<typeof useConfigStore>, text: string): void { function placeholderStatus(configStore: ReturnType<typeof useConfigStore>, text: string): void {
@@ -41,7 +46,7 @@ export function useAppBootstrap(): void {
const cfg = await hydrateFromConfig() const cfg = await hydrateFromConfig()
if (cfg.ok && cfg.data?.dllInitialized) { if (cfg.ok && cfg.data?.dllInitialized) {
appStore.setInitialized(true) appStore.setInitialized(true)
placeholderStatus(configStore, '就绪') placeholderStatus(configStore, t('printerStatus.ready'))
await syncRejectApi() await syncRejectApi()
window.setTimeout(() => void refreshPrinterAfterInit(), 1500) window.setTimeout(() => void refreshPrinterAfterInit(), 1500)
return return
@@ -51,16 +56,16 @@ export function useAppBootstrap(): void {
configStore.setSharedDir(sharedDir) configStore.setSharedDir(sharedDir)
const init = await dllInit({ sharedDir }) const init = await dllInit({ sharedDir })
if (!init.ok) { if (!init.ok) {
appStore.setInitialized(false, init.message || 'Init 失败') appStore.setInitialized(false, init.message || t('printerStatus.initFailed'))
configStore.setPrinter({ ...configStore.printer, statusText: '初始化失败' }) configStore.setPrinter({ ...configStore.printer, statusText: t('printerStatus.initFailed') })
notify.error(init.message || '初始化失败,请检查任务目录权限') notify.error(init.message || t('notify.initFailedHint'))
return return
} }
appStore.setInitialized(true) appStore.setInitialized(true)
const initMeta = init.data as { warning?: string } | undefined const initMeta = init.data as { warning?: string } | undefined
if (initMeta?.warning) notify.warning(initMeta.warning) if (initMeta?.warning) notify.warning(initMeta.warning)
placeholderStatus(configStore, initMeta?.warning ? '未连接打印机' : '就绪') placeholderStatus(configStore, initMeta?.warning ? t('printerStatus.notConnected') : t('printerStatus.ready'))
await syncRejectApi() await syncRejectApi()
window.setTimeout(() => void refreshPrinterAfterInit(), 1500) window.setTimeout(() => void refreshPrinterAfterInit(), 1500)
} }
+10 -3
View File
@@ -1,5 +1,10 @@
import i18n from '@/i18n'
import { useToastStore, type ToastType } from '@/stores/toast' import { useToastStore, type ToastType } from '@/stores/toast'
function t(key: string, named?: Record<string, unknown>): string {
return i18n.global.t(key, named ?? {})
}
function push(type: ToastType, message: string, durationMs = 4500): void { function push(type: ToastType, message: string, durationMs = 4500): void {
useToastStore().push(type, message, durationMs) useToastStore().push(type, message, durationMs)
} }
@@ -11,9 +16,11 @@ export const notify = {
info: (message: string, durationMs?: number) => push('info', message, durationMs) info: (message: string, durationMs?: number) => push('info', message, durationMs)
} }
const INIT_HINT = '系统未就绪,请重启应用或检查打印机与任务目录'
/** 未初始化等业务拦截时的统一提示 */ /** 未初始化等业务拦截时的统一提示 */
export function notifyRequireInit(action?: string): void { export function notifyRequireInit(action?: string): void {
notify.warning(action ? `系统未就绪,无法${action}` : INIT_HINT) if (action) {
notify.warning(t('notify.notReadyAction', { action }))
} else {
notify.warning(t('notify.notReady'))
}
} }
@@ -1,12 +1,28 @@
import { dllPrinterInfo, dllPrinterStatus, parsePrinterInfo } from '@/api/cardsoon' import { dllPrinterInfo, dllPrinterStatus, parsePrinterInfo } from '@/api/cardsoon'
import { useConfigStore } from '@/stores/config' 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> { export async function refreshLiveStatus(): Promise<void> {
const store = useConfigStore() const store = useConfigStore()
try { try {
const r = await dllPrinterStatus() const r = await dllPrinterStatus()
if (r.ok && r.data?.statusText) { 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 { } catch {
/* ignore */ /* ignore */
@@ -26,7 +42,9 @@ export async function refreshPrinterInfo(): Promise<void> {
...store.printer, ...store.printer,
ribbonType: parsed.ribbonType, ribbonType: parsed.ribbonType,
ribbonAmount: parsed.ribbonAmount, ribbonAmount: parsed.ribbonAmount,
serialNo: parsed.serialNo serialNo: parsed.serialNo,
printerName: parsed.printerName,
isSingleSide: parsed.isSingleSide
}) })
} catch { } catch {
/* ignore */ /* ignore */
+30 -18
View File
@@ -1,28 +1,40 @@
import type { ComposerTranslation } from 'vue-i18n'
export interface SelectOption { export interface SelectOption {
label: string label: string
value: string | number value: string | number
} }
export const COPY_TYPE_OPTIONS: SelectOption[] = [ type T = ComposerTranslation
{ label: '文件拷贝', value: 0 },
{ label: '镜像刻录', value: 1 }
]
export const FORMAT_TYPE_OPTIONS: SelectOption[] = [ export function copyTypeOptions(t: T): SelectOption[] {
{ label: '不格式化', value: 'none' }, return [
{ label: 'Fat32', value: 'fat32' }, { label: t('copyType.fileCopy'), value: 0 },
{ label: 'exFat', value: 'exfat' }, { label: t('copyType.imageBurn'), value: 1 }
{ label: 'NTFS', value: 'ntfs' } ]
] }
export const PRIORITY_OPTIONS: SelectOption[] = [ export function formatTypeOptions(t: T): SelectOption[] {
{ label: '低', value: 'low' }, return [
{ label: '中', value: 'mid' }, { label: t('formatType.none'), value: 'none' },
{ label: '高', value: 'high' } { label: t('formatType.fat32'), value: 'fat32' },
] { label: t('formatType.exfat'), value: 'exfat' },
{ label: t('formatType.ntfs'), value: 'ntfs' }
]
}
export const RIBBON_TYPE_OPTIONS: SelectOption[] = [ export function priorityOptions(t: T): SelectOption[] {
{ label: '任何', value: 'any' }, return [
{ label: t('common.low'), value: 'low' },
{ label: t('common.mid'), value: 'mid' },
{ label: t('common.high'), value: 'high' }
]
}
export function ribbonTypeOptions(t: T): SelectOption[] {
return [
{ label: t('common.any'), value: 'any' },
{ label: 'YMCKO', value: 'YMCKO' }, { label: 'YMCKO', value: 'YMCKO' },
{ label: 'YMCK', value: 'YMCK' } { label: 'YMCK', value: 'YMCK' }
] ]
}
+2
View File
@@ -14,6 +14,8 @@ interface CardsoonApi {
} }
declare global { declare global {
/** 构建时由 electron.vite.config.ts 从 package.json version 注入 */
const __APP_VERSION__: string
interface Window { interface Window {
cardsoonApi: CardsoonApi cardsoonApi: CardsoonApi
} }
+45
View File
@@ -0,0 +1,45 @@
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN'
import enUS from './locales/en-US'
import zhTW from './locales/zh-TW'
export type AppLocale = 'zh-CN' | 'en-US' | 'zh-TW'
export const LOCALE_OPTIONS: { value: AppLocale; label: string }[] = [
{ value: 'zh-CN', label: '中文' },
{ value: 'en-US', label: 'English' },
{ value: 'zh-TW', label: '繁體中文' }
]
const STORAGE_KEY = 'app-locale'
function detectInitialLocale(): AppLocale {
try {
const saved = localStorage.getItem(STORAGE_KEY) as AppLocale | null
if (saved && LOCALE_OPTIONS.some((o) => o.value === saved)) return saved
} catch {
/* localStorage may be unavailable */
}
return 'zh-CN'
}
export function persistLocale(locale: AppLocale): void {
try {
localStorage.setItem(STORAGE_KEY, locale)
} catch {
/* ignore */
}
}
const i18n = createI18n({
legacy: false,
locale: detectInitialLocale(),
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN,
'en-US': enUS,
'zh-TW': zhTW
}
})
export default i18n
+199
View File
@@ -0,0 +1,199 @@
export default {
common: {
cancel: 'Cancel',
confirm: 'OK',
close: 'Close',
addPath: 'Add Path',
clear: 'Clear',
submit: 'Submit',
home: 'Home',
stop: 'Stop',
back: 'Back',
reset: 'Reset',
select: 'Select',
resetPrinter: 'Reset Printer',
discardCard: 'Discard Card',
templateDesign: 'Template Design',
low: 'Low',
mid: 'Medium',
high: 'High',
any: 'Any'
},
header: {
modeHome: 'CardSoon Data Card Printer',
modeDistribute: 'Data Distribution Mode',
modeCollect: 'Data Collection Mode',
ribbon: 'Ribbon',
ribbonAmount: 'Remaining',
status: 'Status',
serialNo: 'Serial No.'
},
footer: {
version: 'Version v{version}',
website: 'www.cardsoon.com',
copyright: 'Copyright © 2026 CardSoon Technology'
},
home: {
toolsTitle: 'Tools',
tasksTitle: 'Tasks',
readCard: 'Read',
ejectCard: 'Eject',
dataDistribute: 'Data Distribution',
dataDistributeDesc: 'Distribute data onto cards',
dataCollect: 'Data Collection',
dataCollectDesc: 'Collect data from cards'
},
distributeConfig: {
pathConfig: 'Path Configuration',
addNetworkLocation: 'Add Network Location',
pathHint: 'The system will copy all items under this directory, excluding the folder itself',
netCredConfigured: 'Network credentials configured: {hosts}',
volumeLabel: 'Volume Label',
copyType: 'Copy Type',
formatType: 'Format Type',
dongle: 'Dongle',
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',
backSide: 'Back',
imageFieldNotSelected: 'No image selected',
templateLoaded: 'Label template loaded',
templateNoPreview: 'Template opened, but no previewable content found',
cleared: 'Cleared, restored to initial state',
netCredSaved: 'Network credentials saved',
loadProgress: 'Loaded: {loaded} GB / {total} GB ({percent}%)',
calculating: 'Calculating…',
sizeUnknown: 'Size unknown',
pathInvalid: 'Invalid path',
pathReady: '{size} · Pending copy',
pathPattern: '{dir}\\*.*'
},
copyType: {
fileCopy: 'File Copy',
imageBurn: 'Image Burn'
},
formatType: {
none: 'No Format',
fat32: 'Fat32',
exfat: 'exFat',
ntfs: 'NTFS'
},
dataCollect: {
importPath: 'Data Import Path',
cardOutput: 'Card Output Direction',
forwardOutput: 'Forward Output',
backwardOutput: 'Backward Output',
noDirSelected: 'No directory selected'
},
running: {
taskFailed: 'Task Failed',
taskCompleted: 'Task Completed',
collectCompleted: 'Collection Completed',
dataCollecting: 'Collecting Data',
taskRunning: 'Task Running',
waitingCard: 'Waiting for Card',
failedSubCollect: 'Please check the reader and card, insert a backup card to retry',
failedSubDistribute: 'Please check device issues, insert a backup card to retry',
completedSubCollect: 'Click Back, or insert a backup card to continue',
completedSubDistribute: 'Click Back, or insert a backup card to start next',
collectingHint: 'Reading from card and writing to import directory',
waitCardHint: 'Please insert a data card, the task will run automatically',
runningHint: 'Task in progress, please wait',
progressCounter: 'Task completed {success} times, with {fail} failures.'
},
networkDialog: {
title: 'Add Network Location',
hint: 'Credentials are used to access mapped drives or network shares. Please confirm the host is accessible and the account is valid.',
host: 'Host (IP or Hostname)',
share: 'Share Name (optional)',
userName: 'Username',
password: 'Password',
targetUNC: 'Target UNC',
hostPlaceholder: 'e.g. 192.168.1.100 or nas-server',
sharePlaceholder: 'e.g. share, leave empty to mount root only',
userNamePlaceholder: 'e.g. admin',
errHostInvalid: 'Invalid hostname/IP',
errUserName: 'Please enter username',
errPassword: 'Please enter password'
},
workflow: {
taskPrep: 'Task Preparation',
copyData: 'Copy Data',
printCard: 'Print Card',
complete: 'Complete'
},
notify: {
resetSent: 'Reset command sent',
resetFailed: 'Reset failed',
rejectUnavailable: 'Discard card API not supported in current environment',
cardRejected: 'Card discarded',
readCardSent: 'Card moved to reader',
readCardFailed: 'Read card failed',
ejectCardSent: 'Card ejected to outlet',
ejectCardFailed: 'Eject card failed',
operationFailed: 'Operation failed',
designStarted: 'Design app started',
designFailed: 'Failed to open design app, please check cardsoon.config.json',
designCancelled: 'Failed to open design app, may have been cancelled or requires admin privileges',
notReady: 'System not ready, please restart the app or check printer and task directory',
notReadyAction: 'System not ready, cannot {action}',
stopDistribute: 'Please stop the data distribution task first',
usbCollecting: 'USB collection in progress, please wait',
usbCollectInProgress: 'Data collection in progress, please stop it on the task page first',
usbCollectComplete: 'USB collection completed',
pathOpenFailed: 'Failed to open directory picker',
templateOpenFailed: 'Failed to open template picker',
templateParseFailed: 'Template parsing failed',
dirInvalid: 'Import directory invalid, cannot continue',
resubmitCollectFailed: 'Failed to resubmit collection task',
resubmitFailed: 'Resubmit failed',
pollStartFailed: 'Failed to start task polling',
pollMonitorFailed: 'Cannot monitor card position, please click Back or resubmit manually',
queryJobFailed: 'Query task failed: {code}',
queryUsbFailed: 'Query USB task failed: {code}',
cancelIssue: 'Issue occurred while cancelling task',
selectDirFirst: 'Please select a data import directory first',
selectDirFirstShort: 'Please select a data import directory first',
startUsbFailed: 'Failed to start USB collection',
usbWaitComplete: 'Please wait for USB collection to complete',
cannotEnterRunning: 'Cannot enter running page, task cancelled',
initFailedHint: 'Initialization failed, please check task directory permissions'
},
validation: {
noTask: 'Please configure a copy path or print template',
invalidTemplate: 'Please select a .soon template',
pathEmpty: 'Path cannot be empty',
pathNotExist: 'Path does not exist: {paths}',
noFilesToCopy: 'No files to copy under the path',
netCredMissing: 'Please configure network location credentials first: {host}',
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',
csvGenFailed: 'Failed to generate print variable CSV'
},
printerStatus: {
idle: 'Idle',
busy: 'Busy',
printing: 'Printing',
notConnected: 'Printer not connected',
notInitialized: 'Not initialized',
initFailed: 'Initialization failed',
ready: 'Ready',
unknown: '—'
}
}
+201
View File
@@ -0,0 +1,201 @@
export default {
common: {
cancel: '取消',
confirm: '确定',
close: '关闭',
addPath: '添加路径',
clear: '清空',
submit: '提交',
home: '首页',
stop: '停止',
back: '返回',
reset: '重置',
select: '选择',
resetPrinter: '重置打印机',
discardCard: '废弃卡片',
templateDesign: '模板设计',
low: '低',
mid: '中',
high: '高',
any: '任何'
},
header: {
modeHome: '卡树数据卡打印机软件',
modeDistribute: '数据分发模式',
modeCollect: '数据收集模式',
ribbon: '色带',
ribbonAmount: '余量',
status: '状态',
serialNo: '序列号'
},
footer: {
version: '版本 v{version}',
website: 'www.cardsoon.com',
copyright: '版权所有 © 2026 卡树科技'
},
home: {
toolsTitle: '工具',
tasksTitle: '任务',
readCard: '读卡',
ejectCard: '退卡',
dataDistribute: '数据分发',
dataDistributeDesc: '分发数据到打印卡片',
dataCollect: '数据收集',
dataCollectDesc: '从卡片收集导入数据'
},
distributeConfig: {
pathConfig: '路径配置',
addNetworkLocation: '添加网络位置',
pathHint: '系统将拷贝该目录下的所有子项,但不包含文件夹本身',
netCredConfigured: '已配置网络凭据:{hosts}',
volumeLabel: '卷标',
copyType: '拷贝类型',
formatType: '格式化类型',
dongle: '加密狗',
dongleHint: '101 为不限次数)',
dongleAuthLabel: '授权码',
donglePassword: '请输入授权码',
showAuth: '显示授权码',
hideAuth: '隐藏授权码',
templatePreview: '标签预览',
addLabel: '添加标签',
removeLabel: '移除标签',
templateRemoved: '已移除标签',
doubleSide: '双面',
singleSideHint: '当前为单面打印机,请选择打印面(正面或背面)',
frontSide: '正面',
backSide: '背面',
imageFieldNotSelected: '未选择图片',
templateLoaded: '已加载标签模板',
templateNoPreview: '模板已打开,但未解析到可预览内容',
cleared: '已清空,已恢复初始状态',
netCredSaved: '网络凭据已保存',
loadProgress: '已加载: {loaded} GB / {total} GB ({percent}%)',
calculating: '计算中…',
sizeUnknown: '大小未知',
pathInvalid: '路径无效',
pathReady: '{size} · 待拷贝',
pathPattern: '{dir}\\*.*'
},
copyType: {
fileCopy: '文件拷贝',
imageBurn: '镜像刻录'
},
formatType: {
none: '不格式化',
fat32: 'Fat32',
exfat: 'exFat',
ntfs: 'NTFS'
},
dataCollect: {
importPath: '数据导入地址',
cardOutput: '出卡方向',
forwardOutput: '向前出卡',
backwardOutput: '向后出卡',
noDirSelected: '未选择目录'
},
running: {
taskFailed: '任务失败',
taskCompleted: '任务已完成',
collectCompleted: '收集已完成',
dataCollecting: '数据收集中',
taskRunning: '任务执行中',
waitingCard: '等待插卡',
failedSubCollect: '请检查读卡器与卡片,插入备卡位可自动重试',
failedSubDistribute: '请检查设备故障,插入备卡位可自动重试',
completedSubCollect: '请点击返回,或插入备卡位继续下一张',
completedSubDistribute: '请点击返回,或插入备卡位自动开始下一张',
collectingHint: '正在从卡片读取并写入导入目录',
waitCardHint: '请插入数据卡,任务将自动执行',
runningHint: '任务执行中,请稍候',
progressCounter: '任务已经完成{success}次,其中失败次数是{fail}。'
},
networkDialog: {
title: '添加网络位置',
hint: '凭据用于访问已添加的映射盘或网络共享路径,请确认主机可访问且账号有效。',
host: '主机(IP 或主机名)',
share: '共享名(可选)',
userName: '用户名',
password: '密码',
targetUNC: '目标 UNC',
hostPlaceholder: '例如 192.168.1.100 或 nas-server',
sharePlaceholder: '例如 share,留空表示只挂载到根',
userNamePlaceholder: '例如 admin',
errHostInvalid: '主机名/IP 不合法',
errUserName: '请输入用户名',
errPassword: '请输入密码'
},
workflow: {
taskPrep: '任务准备',
copyData: '拷贝数据',
printCard: '打印卡片',
complete: '完成'
},
notify: {
resetSent: '已发送重置指令',
resetFailed: '重置失败',
rejectUnavailable: '当前环境不支持废卡接口',
cardRejected: '已废弃卡片',
readCardSent: '已送卡到读卡区',
readCardFailed: '读卡失败',
ejectCardSent: '已退卡到出卡区',
ejectCardFailed: '退卡失败',
operationFailed: '操作失败',
designStarted: '已启动设计软件',
designFailed: '打开设计软件失败,请检查 cardsoon.config.json',
designCancelled: '打开设计软件失败,可能已被取消或需要管理员权限',
notReady: '系统未就绪,请重启应用或检查打印机与任务目录',
notReadyAction: '系统未就绪,无法{action}',
stopDistribute: '请先停止数据分发任务',
usbCollecting: 'USB 收集进行中,请等待完成',
usbCollectInProgress: '数据收集进行中,请先在任务页停止',
usbCollectComplete: 'USB 收集完成',
pathOpenFailed: '打开目录选择失败',
templateOpenFailed: '打开模板选择失败',
templateParseFailed: '模板解析失败',
dirInvalid: '导入目录无效,无法继续',
resubmitCollectFailed: '重新提交收集任务失败',
resubmitFailed: '重新提交失败',
pollStartFailed: '启动任务轮询失败',
pollMonitorFailed: '无法监控卡位,请手动点击返回或重新提交',
queryJobFailed: '查询任务失败: {code}',
queryUsbFailed: '查询 USB 任务失败: {code}',
cancelIssue: '取消任务时出现问题',
selectDirFirst: '请先选择数据导入目录',
selectDirFirstShort: '请先选择数据导入目录',
startUsbFailed: '启动 USB 收集失败',
usbWaitComplete: '请先等待 USB 收集完成',
cannotEnterRunning: '无法进入运行页,已取消任务',
initFailedHint: '初始化失败,请检查任务目录权限',
waitTimeoutCollect: '等待时间较长:请检查 U 盘数据线是否连接、卡片是否插好(可把卡插反后重试)',
waitTimeoutDistribute: '等待进卡时间较长:请检查卡片是否到位、打印机是否夹卡或报错'
},
validation: {
noTask: '请配置拷贝路径或打印模板',
invalidTemplate: '请选择 .soon 模板',
pathEmpty: '路径不能为空',
pathNotExist: '路径不存在: {paths}',
noFilesToCopy: '拷贝路径下没有可拷贝的文件',
netCredMissing: '请先配置网络位置凭据: {host}',
templateNotExist: '模板文件不存在',
printFlagMismatch: '打印面数不匹配,请重新选择',
singleSideMustPick: '当前为单面打印机,请选择正面或背面',
pickSideRequired: '请选择打印面(正面或背面)后再提交',
printerNotConnected: '打印机未连接,请先连接打印机后再提交',
printerNotReady: '打印机未连接或设备异常(如夹卡),请排除故障后再提交',
dongleAuthRequired: '请输入授权码',
dongleCountInvalid: '加密狗次数须为 0 或 1-101',
createJobFailed: '创建任务失败',
csvGenFailed: '生成打印变量 CSV 失败'
},
printerStatus: {
idle: '空闲',
busy: '忙碌',
printing: '正在打印',
notConnected: '未连接打印机',
notInitialized: '未初始化',
initFailed: '初始化失败',
ready: '就绪',
unknown: '—'
}
}
+199
View File
@@ -0,0 +1,199 @@
export default {
common: {
cancel: '取消',
confirm: '確定',
close: '關閉',
addPath: '新增路徑',
clear: '清空',
submit: '提交',
home: '首頁',
stop: '停止',
back: '返回',
reset: '重設',
select: '選擇',
resetPrinter: '重設印表機',
discardCard: '廢棄卡片',
templateDesign: '模板設計',
low: '低',
mid: '中',
high: '高',
any: '任何'
},
header: {
modeHome: '卡樹數據卡印表機軟體',
modeDistribute: '數據分發模式',
modeCollect: '數據收集模式',
ribbon: '色帶',
ribbonAmount: '餘量',
status: '狀態',
serialNo: '序號'
},
footer: {
version: '版本 v{version}',
website: 'www.cardsoon.com',
copyright: '版權所有 © 2026 卡樹科技'
},
home: {
toolsTitle: '工具',
tasksTitle: '任務',
readCard: '讀卡',
ejectCard: '退卡',
dataDistribute: '數據分發',
dataDistributeDesc: '分發數據到列印卡片',
dataCollect: '數據收集',
dataCollectDesc: '從卡片收集匯入數據'
},
distributeConfig: {
pathConfig: '路徑配置',
addNetworkLocation: '新增網路位置',
pathHint: '系統將拷貝該目錄下的所有子項,但不包含資料夾本身',
netCredConfigured: '已配置網路憑證:{hosts}',
volumeLabel: '卷標',
copyType: '拷貝類型',
formatType: '格式化類型',
dongle: '加密狗',
dongleHint: '101 為不限次數)',
dongleAuthLabel: '授權碼',
donglePassword: '請輸入授權碼',
showAuth: '顯示授權碼',
hideAuth: '隱藏授權碼',
templatePreview: '標籤預覽',
addLabel: '新增標籤',
removeLabel: '移除標籤',
templateRemoved: '已移除標籤',
doubleSide: '雙面',
singleSideHint: '當前為單面印表機,請選擇列印面(正面或背面)',
frontSide: '正面',
backSide: '背面',
imageFieldNotSelected: '未選擇圖片',
templateLoaded: '已載入標籤模板',
templateNoPreview: '模板已開啟,但未解析到可預覽內容',
cleared: '已清空,已恢復初始狀態',
netCredSaved: '網路憑證已儲存',
loadProgress: '已載入: {loaded} GB / {total} GB ({percent}%)',
calculating: '計算中…',
sizeUnknown: '大小未知',
pathInvalid: '路徑無效',
pathReady: '{size} · 待拷貝',
pathPattern: '{dir}\\*.*'
},
copyType: {
fileCopy: '檔案拷貝',
imageBurn: '鏡像燒錄'
},
formatType: {
none: '不格式化',
fat32: 'Fat32',
exfat: 'exFat',
ntfs: 'NTFS'
},
dataCollect: {
importPath: '數據匯入地址',
cardOutput: '出卡方向',
forwardOutput: '向前出卡',
backwardOutput: '向後出卡',
noDirSelected: '未選擇目錄'
},
running: {
taskFailed: '任務失敗',
taskCompleted: '任務已完成',
collectCompleted: '收集已完成',
dataCollecting: '數據收集中',
taskRunning: '任務執行中',
waitingCard: '等待插卡',
failedSubCollect: '請檢查讀卡器與卡片,插入備卡位可自動重試',
failedSubDistribute: '請檢查設備故障,插入備卡位可自動重試',
completedSubCollect: '請點擊返回,或插入備卡位繼續下一張',
completedSubDistribute: '請點擊返回,或插入備卡位自動開始下一張',
collectingHint: '正在從卡片讀取並寫入匯入目錄',
waitCardHint: '請插入數據卡,任務將自動執行',
runningHint: '任務執行中,請稍候',
progressCounter: '任務已經完成{success}次,其中失敗次數是{fail}。'
},
networkDialog: {
title: '新增網路位置',
hint: '憑證用於存取已新增的映射盤或網路共享路徑,請確認主機可存取且帳號有效。',
host: '主機(IP 或主機名)',
share: '共享名(可選)',
userName: '使用者名稱',
password: '密碼',
targetUNC: '目標 UNC',
hostPlaceholder: '例如 192.168.1.100 或 nas-server',
sharePlaceholder: '例如 share,留空表示只掛載到根',
userNamePlaceholder: '例如 admin',
errHostInvalid: '主機名/IP 不合法',
errUserName: '請輸入使用者名稱',
errPassword: '請輸入密碼'
},
workflow: {
taskPrep: '任務準備',
copyData: '拷貝數據',
printCard: '列印卡片',
complete: '完成'
},
notify: {
resetSent: '已發送重設指令',
resetFailed: '重設失敗',
rejectUnavailable: '當前環境不支援廢卡介面',
cardRejected: '已廢棄卡片',
readCardSent: '已送卡到讀卡區',
readCardFailed: '讀卡失敗',
ejectCardSent: '已退卡到出卡區',
ejectCardFailed: '退卡失敗',
operationFailed: '操作失敗',
designStarted: '已啟動設計軟體',
designFailed: '開啟設計軟體失敗,請檢查 cardsoon.config.json',
designCancelled: '開啟設計軟體失敗,可能已被取消或需要管理員權限',
notReady: '系統未就緒,請重啟應用或檢查印表機與任務目錄',
notReadyAction: '系統未就緒,無法{action}',
stopDistribute: '請先停止數據分發任務',
usbCollecting: 'USB 收集進行中,請等待完成',
usbCollectInProgress: '數據收集進行中,請先在任務頁停止',
usbCollectComplete: 'USB 收集完成',
pathOpenFailed: '開啟目錄選擇失敗',
templateOpenFailed: '開啟模板選擇失敗',
templateParseFailed: '模板解析失敗',
dirInvalid: '匯入目錄無效,無法繼續',
resubmitCollectFailed: '重新提交收集任務失敗',
resubmitFailed: '重新提交失敗',
pollStartFailed: '啟動任務輪詢失敗',
pollMonitorFailed: '無法監控卡位,請手動點擊返回或重新提交',
queryJobFailed: '查詢任務失敗: {code}',
queryUsbFailed: '查詢 USB 任務失敗: {code}',
cancelIssue: '取消任務時出現問題',
selectDirFirst: '請先選擇數據匯入目錄',
selectDirFirstShort: '請先選擇數據匯入目錄',
startUsbFailed: '啟動 USB 收集失敗',
usbWaitComplete: '請先等待 USB 收集完成',
cannotEnterRunning: '無法進入運行頁,已取消任務',
initFailedHint: '初始化失敗,請檢查任務目錄權限'
},
validation: {
noTask: '請配置拷貝路徑或列印模板',
invalidTemplate: '請選擇 .soon 模板',
pathEmpty: '路徑不能為空',
pathNotExist: '路徑不存在: {paths}',
noFilesToCopy: '拷貝路徑下沒有可拷貝的檔案',
netCredMissing: '請先配置網路位置憑證: {host}',
templateNotExist: '模板檔案不存在',
printFlagMismatch: '列印面數不匹配,請重新選擇',
singleSideMustPick: '當前為單面印表機,請選擇正面或背面',
pickSideRequired: '請選擇列印面(正面或背面)後再提交',
printerNotConnected: '印表機未連接,請先連接印表機後再提交',
printerNotReady: '印表機未連接或設備異常(如夾卡),請排除故障後再提交',
dongleAuthRequired: '請輸入授權碼',
dongleCountInvalid: '加密狗次數須為 0 或 1-101',
createJobFailed: '建立任務失敗',
csvGenFailed: '生成列印變數 CSV 失敗'
},
printerStatus: {
idle: '空閒',
busy: '忙碌',
printing: '正在列印',
notConnected: '未連接印表機',
notInitialized: '未初始化',
initFailed: '初始化失敗',
ready: '就緒',
unknown: '—'
}
}
+60 -37
View File
@@ -2,6 +2,8 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import i18n from '@/i18n'
import { configGet } from '@/api/cardsoon' import { configGet } from '@/api/cardsoon'
import { useNetworkAuthStore } from '@/stores/networkAuth' import { useNetworkAuthStore } from '@/stores/networkAuth'
@@ -22,60 +24,80 @@ import './styles/shell.css'
window.cardsoonApi.on('app:trace', (payload) => { // 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> } const p = payload as { level: string; message: string; data?: Record<string, unknown> }
if (p.level === 'error') console.error(p.message, p.data ?? '') if (p.level === 'error') console.error(p.message, p.data ?? '')
else console.log(p.message, p.data ?? '') else console.log(p.message, p.data ?? '')
})
}) }
async function setTrace(on: boolean): Promise<void> { async function setTrace(on: boolean): Promise<void> {
await window.cardsoonApi.invoke('config:set', { traceEnabled: on }) await window.cardsoonApi.invoke('config:set', { traceEnabled: on })
console.info(`[trace] 控制台日志已${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> } /**
* 渲染进程启动崩溃兜底:把白屏变成可见错误面板,
* 便于在无法打开 DevTools 的现场截图定位问题。
* 仅在 Vue 挂载前/挂载瞬间触发的错误会覆盖界面;
* 挂载完成后的运行时错误只进控制台,不破坏已有界面。
*/
let appMounted = false
w.trace = async (on = true) => setTrace(on) 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>`
}
w.dllTrace = w.trace 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)
}
void (async () => { const pinia = createPinia()
const cfg = await configGet() app.use(pinia)
const on = cfg.ok && cfg.data?.traceEnabled === true app.use(i18n)
console.info(`[trace] 控制台日志: ${on ? '已开启' : '已关闭'},执行 trace(false) 关闭`) app.use(router)
})()
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(router)
void (async () => {
void (async () => {
await useNetworkAuthStore().loadFromStorage() await useNetworkAuthStore().loadFromStorage()
const dongleStore = useDongleAuthStore() const dongleStore = useDongleAuthStore()
@@ -83,10 +105,11 @@ void (async () => {
await dongleStore.loadFromSecrets() await dongleStore.loadFromSecrets()
useDistributeFormStore().dongleAuthCode = dongleStore.authCode useDistributeFormStore().dongleAuthCode = dongleStore.authCode
})()
})() app.mount('#app')
appMounted = true
} catch (err) {
showFatalError('bootstrap', err)
app.mount('#app') }
+5
View File
@@ -18,6 +18,11 @@ export const useJobStore = defineStore('job', {
clearActiveJob() { clearActiveJob() {
this.jobId = '' this.jobId = ''
}, },
/** 重置次数计数器(不影响当前 jobId/lastJobJson */
resetCounts() {
this.successCount = 0
this.failCount = 0
},
reset() { reset() {
this.jobId = '' this.jobId = ''
this.lastJobJson = '' this.lastJobJson = ''
@@ -65,3 +65,15 @@
.fa-exclamation-triangle::before { .fa-exclamation-triangle::before {
content: '\f071'; content: '\f071';
} }
.fa-id-card::before {
content: '\f2c2';
}
.fa-eject::before {
content: '\f052';
}
.fa-eye::before {
content: '\f06e';
}
.fa-eye-slash::before {
content: '\f070';
}
@@ -73,6 +73,101 @@
color: var(--cs-primary); color: var(--cs-primary);
} }
/* 不支持的工具按钮(如当前 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;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.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-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);
}
/* ========== 垂直分隔线 ========== */ /* ========== 垂直分隔线 ========== */
.m-divider { .m-divider {
width: 1px; width: 1px;
@@ -110,6 +110,56 @@
margin-left: 3px; margin-left: 3px;
} }
.m-panel-toolbar .c-checkbox-item .c-input.dog-auth {
width: 140px;
height: 20px;
padding: 0 4px;
font-size: 9px;
border-radius: 3px;
border: 1px solid #ced4da;
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;
color: #495057;
font-weight: 500;
margin-left: 6px;
flex-shrink: 0;
white-space: nowrap;
}
/* 提示文字 */ /* 提示文字 */
.m-panel-toolbar .c-checkbox-item .dog-hint { .m-panel-toolbar .c-checkbox-item .dog-hint {
font-size: 9px; font-size: 9px;
+2
View File
@@ -25,6 +25,8 @@ export interface JobPollPayload {
failed: boolean failed: boolean
cancelled: boolean cancelled: boolean
finished: boolean finished: boolean
/** 任务失败当刻由主进程取到的打印机错误串 */
errorMessage?: string
} }
export interface UsbPollPayload { export interface UsbPollPayload {
+7 -1
View File
@@ -3,11 +3,17 @@ export interface PrinterStatusDisplay {
ribbonAmount: string ribbonAmount: string
statusText: string statusText: string
serialNo: string serialNo: string
/** 打印机型号/名称(如 TH80),用于判断单/双面能力 */
printerName: string
/** 是否为单面打印机(如 TH80),单面打印机不能选"双面"打印 */
isSingleSide: boolean
} }
export const defaultPrinterStatus: PrinterStatusDisplay = { export const defaultPrinterStatus: PrinterStatusDisplay = {
ribbonType: '—', ribbonType: '—',
ribbonAmount: '—', ribbonAmount: '—',
statusText: '—', statusText: '—',
serialNo: '—' serialNo: '—',
printerName: '—',
isSingleSide: false
} }
+3 -3
View File
@@ -1,11 +1,12 @@
import type { DistributeFormState } from '@/stores/distributeForm' import type { DistributeFormState } from '@/stores/distributeForm'
import type { NetInfoCredential } from '@shared/network-host'
import { cleanPathPattern } from '@shared/path-pattern' import { cleanPathPattern } from '@shared/path-pattern'
import { resolveJobTasks } from '@/utils/validateJobConfig' import { resolveJobTasks } from '@/utils/validateJobConfig'
export interface BuildJobOptions { export interface BuildJobOptions {
taskId: string taskId: string
udfFile?: string udfFile?: string
netInfo?: string netInfo?: NetInfoCredential[]
} }
function formatFileForApi(formatType: Exclude<DistributeFormState['formatType'], 'none'>): string { function formatFileForApi(formatType: Exclude<DistributeFormState['formatType'], 'none'>): string {
@@ -62,8 +63,7 @@ export function buildJobConfig(
if (form.generateZip) body.is_generate_zip = true if (form.generateZip) body.is_generate_zip = true
if (form.failPrintLabel) body.is_printer_record_logo = true if (form.failPrintLabel) body.is_printer_record_logo = true
const netInfo = opts.netInfo?.trim() if (opts.netInfo?.length) body.net_info = opts.netInfo
if (netInfo) body.net_info = netInfo
return body return body
} }
@@ -1,5 +1,5 @@
import { cleanPathPattern } from '@shared/path-pattern' import { cleanPathPattern } from '@shared/path-pattern'
import { buildNetInfo, collectHostsForCopyPaths } from '@shared/network-host' import { buildNetInfo, collectHostsForCopyPaths, type NetInfoCredential } from '@shared/network-host'
import type { DistributeFormState } from '@/stores/distributeForm' import type { DistributeFormState } from '@/stores/distributeForm'
import { fsResolveNetworkHosts } from '@/api/cardsoon' import { fsResolveNetworkHosts } from '@/api/cardsoon'
import { useNetworkAuthStore } from '@/stores/networkAuth' import { useNetworkAuthStore } from '@/stores/networkAuth'
@@ -20,9 +20,9 @@ export async function resolveCopyNetworkHosts(form: DistributeFormState): Promis
return collectHostsForCopyPaths(paths, driveHostMap) return collectHostsForCopyPaths(paths, driveHostMap)
} }
export async function buildNetInfoForForm(form: DistributeFormState): Promise<string> { export async function buildNetInfoForForm(form: DistributeFormState): Promise<NetInfoCredential[]> {
const hosts = await resolveCopyNetworkHosts(form) const hosts = await resolveCopyNetworkHosts(form)
if (!hosts.length) return '' if (!hosts.length) return []
const netStore = useNetworkAuthStore() const netStore = useNetworkAuthStore()
return buildNetInfo( return buildNetInfo(
hosts.map((h) => ({ hostName: h })), hosts.map((h) => ({ hostName: h })),
@@ -1,3 +1,4 @@
import i18n from '@/i18n'
import { genTaskId } from '@shared/gen-task-id' import { genTaskId } from '@shared/gen-task-id'
import { buildJobConfig } from '@/utils/buildJobConfig' import { buildJobConfig } from '@/utils/buildJobConfig'
import { resolveJobTasks } from '@/utils/validateJobConfig' import { resolveJobTasks } from '@/utils/validateJobConfig'
@@ -6,6 +7,10 @@ import { dllJobCreate, fsWriteJobCsv } from '@/api/cardsoon'
import { useJobStore } from '@/stores/job' import { useJobStore } from '@/stores/job'
import type { DistributeFormState } from '@/stores/distributeForm' import type { DistributeFormState } from '@/stores/distributeForm'
function t(key: string, named?: Record<string, unknown>): string {
return i18n.global.t(key, named ?? {})
}
function printFieldRows(form: DistributeFormState) { function printFieldRows(form: DistributeFormState) {
return (form.templatePreview?.fields ?? []).map((f) => ({ return (form.templatePreview?.fields ?? []).map((f) => ({
originName: f.originName || f.label.replace(/\[.*\]$/, ''), originName: f.originName || f.label.replace(/\[.*\]$/, ''),
@@ -18,7 +23,7 @@ async function buildJobJson(
): Promise<{ ok: true; json: string } | { ok: false; message: string }> { ): Promise<{ ok: true; json: string } | { ok: false; message: string }> {
const { hasCopy, hasPrint } = resolveJobTasks(form) const { hasCopy, hasPrint } = resolveJobTasks(form)
if (!hasCopy && !hasPrint) { if (!hasCopy && !hasPrint) {
return { ok: false, message: '请配置拷贝路径或打印模板' } return { ok: false, message: t('validation.noTask') }
} }
const taskId = genTaskId() const taskId = genTaskId()
@@ -28,13 +33,13 @@ async function buildJobJson(
if (rows.length > 0) { if (rows.length > 0) {
const csv = await fsWriteJobCsv({ taskId, rows }) const csv = await fsWriteJobCsv({ taskId, rows })
if (!csv.ok || !csv.data?.path) { if (!csv.ok || !csv.data?.path) {
return { ok: false, message: csv.message || '生成打印变量 CSV 失败' } return { ok: false, message: csv.message || t('validation.csvGenFailed') }
} }
udfFile = csv.data.path udfFile = csv.data.path
} }
} }
let netInfo = '' let netInfo: Awaited<ReturnType<typeof buildNetInfoForForm>> = []
if (hasCopy) { if (hasCopy) {
try { try {
netInfo = await buildNetInfoForForm(form) netInfo = await buildNetInfoForForm(form)
@@ -71,7 +76,7 @@ export async function createDistributeJob(
const created = await dllJobCreate(json, opts) const created = await dllJobCreate(json, opts)
if (!created.ok || !created.data?.jobId) { if (!created.ok || !created.data?.jobId) {
return { ok: false, message: created.message || '创建任务失败' } return { ok: false, message: created.message || t('validation.createJobFailed') }
} }
return { ok: true, jobId: created.data.jobId } return { ok: true, jobId: created.data.jobId }
} }
@@ -1,4 +1,9 @@
import type { DistributeFormState } from '@/stores/distributeForm' import type { DistributeFormState } from '@/stores/distributeForm'
import i18n from '@/i18n'
function t(key: string, named?: Record<string, unknown>): string {
return i18n.global.t(key, named ?? {})
}
export function resolveJobTasks(f: DistributeFormState): { hasCopy: boolean; hasPrint: boolean } { export function resolveJobTasks(f: DistributeFormState): { hasCopy: boolean; hasPrint: boolean } {
const hasCopy = f.pathList.some((p) => !!p.path.trim()) const hasCopy = f.pathList.some((p) => !!p.path.trim())
@@ -8,8 +13,8 @@ export function resolveJobTasks(f: DistributeFormState): { hasCopy: boolean; has
export function validateJobConfig(f: DistributeFormState): string | null { export function validateJobConfig(f: DistributeFormState): string | null {
const { hasCopy, hasPrint } = resolveJobTasks(f) const { hasCopy, hasPrint } = resolveJobTasks(f)
if (!hasCopy && !hasPrint) return '请配置拷贝路径或打印模板' if (!hasCopy && !hasPrint) return t('validation.noTask')
if (hasPrint && !/\.soon$/i.test(f.templateFile.trim())) return '请选择 .soon 模板' if (hasPrint && !/\.soon$/i.test(f.templateFile.trim())) return t('validation.invalidTemplate')
if (hasCopy && f.pathList.some((p) => !p.path.trim())) return '路径不能为空' if (hasCopy && f.pathList.some((p) => !p.path.trim())) return t('validation.pathEmpty')
return null return null
} }
@@ -1,8 +1,14 @@
import type { DistributeFormState } from '@/stores/distributeForm' import type { DistributeFormState } from '@/stores/distributeForm'
import { fsPathExists } from '@/api/cardsoon' import i18n from '@/i18n'
import { fsPathExists, dllPrinterStatus } from '@/api/cardsoon'
import { resolveJobTasks, validateJobConfig } from '@/utils/validateJobConfig' import { resolveJobTasks, validateJobConfig } from '@/utils/validateJobConfig'
import { resolveCopyNetworkHosts } from '@/utils/copyNetworkHosts' import { resolveCopyNetworkHosts } from '@/utils/copyNetworkHosts'
import { useNetworkAuthStore } from '@/stores/networkAuth' import { useNetworkAuthStore } from '@/stores/networkAuth'
import { useConfigStore } from '@/stores/config'
function t(key: string, named?: Record<string, unknown>): string {
return i18n.global.t(key, named ?? {})
}
function totalCopyBytes(form: DistributeFormState): number { function totalCopyBytes(form: DistributeFormState): number {
return form.pathList.reduce((sum, item) => sum + (item.sizeBytes || 0), 0) return form.pathList.reduce((sum, item) => sum + (item.sizeBytes || 0), 0)
@@ -16,21 +22,57 @@ 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> { export async function validateJobPreflight(form: DistributeFormState): Promise<string | null> {
const err = validateJobConfig(form) const err = validateJobConfig(form)
if (err) return err if (err) return err
// 打印机硬件预检:打印/拷贝任务都需要设备,未连接或故障码(如夹卡)时拦截提交
const { hasCopy, hasPrint } = resolveJobTasks(form) const { hasCopy, hasPrint } = resolveJobTasks(form)
if (hasPrint || hasCopy) {
const fault = await preflightPrinterStatus()
if (fault) return fault
}
if (hasCopy) { if (hasCopy) {
const paths = form.pathList.map((x) => x.path) const paths = form.pathList.map((x) => x.path)
if (paths.length > 0) { if (paths.length > 0) {
const ex = await fsPathExists(paths) const ex = await fsPathExists(paths)
if (ex.ok && ex.data?.missing.length) { if (ex.ok && ex.data?.missing.length) {
return `路径不存在: ${ex.data.missing.join(', ')}` return t('validation.pathNotExist', { paths: ex.data.missing.join(', ') })
} }
if (totalCopyBytes(form) <= 0) { if (totalCopyBytes(form) <= 0) {
return '拷贝路径下没有可拷贝的文件' return t('validation.noFilesToCopy')
} }
} }
@@ -40,7 +82,7 @@ export async function validateJobPreflight(form: DistributeFormState): Promise<s
for (const host of hosts) { for (const host of hosts) {
const cred = netStore.getCredential(host) const cred = netStore.getCredential(host)
if (!cred?.userName?.trim() || !cred.password) { if (!cred?.userName?.trim() || !cred.password) {
return `请先配置网络位置凭据: ${host}` return t('validation.netCredMissing', { host })
} }
} }
} }
@@ -50,22 +92,33 @@ export async function validateJobPreflight(form: DistributeFormState): Promise<s
const soon = form.templateFile.trim() const soon = form.templateFile.trim()
const ex = await fsPathExists([soon]) const ex = await fsPathExists([soon])
if (ex.ok && ex.data?.missing.length) { if (ex.ok && ex.data?.missing.length) {
return '模板文件不存在' return t('validation.templateNotExist')
} }
const preview = form.templatePreview const preview = form.templatePreview
if (preview && printFlagMismatch(form.printFlag, preview.templateFlag)) { if (preview && printFlagMismatch(form.printFlag, preview.templateFlag)) {
return '打印面数不匹配,请重新选择' return t('validation.printFlagMismatch')
}
// 单面打印机:双面模板必须显式选择正面或背面,且不能选双面
const configStore = useConfigStore()
if (configStore.printer.isSingleSide) {
if (preview && preview.templateFlag === 1 && form.printFlag !== 2 && form.printFlag !== 3) {
return t('validation.pickSideRequired')
}
if (form.printFlag === 1) {
return t('validation.singleSideMustPick')
}
} }
} }
if (form.dongleEnabled) { if (form.dongleEnabled) {
if (!form.dongleAuthCode.trim()) { if (!form.dongleAuthCode.trim()) {
return '请输入授权码' return t('validation.dongleAuthRequired')
} }
const n = form.dongleInstallCount const n = form.dongleInstallCount
if (!Number.isInteger(n) || n < 0 || n > 101) { if (!Number.isInteger(n) || n < 0 || n > 101) {
return '加密狗次数须为 0 或 1-101' return t('validation.dongleCountInvalid')
} }
} }
+25 -16
View File
@@ -1,12 +1,12 @@
<template> <template>
<AppShell> <AppShell>
<AppHeader mode="数据收集模式"> <AppHeader :mode="t('header.modeCollect')">
<div class="c-nav-group"> <div class="c-nav-group">
<NavButton icon="home" label="首页" @click="goHome" /> <NavButton icon="home" :label="t('common.home')" @click="goHome" />
<NavButton icon="trash" label="清空" @click="collectStore.reset()" /> <NavButton icon="trash" :label="t('common.clear')" @click="collectStore.reset()" />
<NavButton <NavButton
icon="check-circle" icon="check-circle"
label="提交" :label="t('common.submit')"
variant="primary" variant="primary"
:active="true" :active="true"
:disabled="!canSubmit" :disabled="!canSubmit"
@@ -18,34 +18,34 @@
<section class="m-config-panel"> <section class="m-config-panel">
<h3 class="m-panel-title"> <h3 class="m-panel-title">
<AppIcon name="folder-open" size="sm" /> <AppIcon name="folder-open" size="sm" />
数据导入地址 {{ t('dataCollect.importPath') }}
</h3> </h3>
<div class="m-path-box"> <div class="m-path-box">
<span class="m-path-text" :title="collectStore.destPath || undefined">{{ <span class="m-path-text" :title="collectStore.destPath || undefined">{{
collectStore.destPath || '未选择目录' collectStore.destPath || t('dataCollect.noDirSelected')
}}</span> }}</span>
</div> </div>
<button type="button" class="m-path-btn" @click="addPath"> <button type="button" class="m-path-btn" @click="addPath">
<AppIcon name="plus" size="sm" /> <AppIcon name="plus" size="sm" />
添加路径 {{ t('common.addPath') }}
</button> </button>
</section> </section>
<div class="m-divider-v" /> <div class="m-divider-v" />
<section class="m-config-panel"> <section class="m-config-panel">
<h3 class="m-panel-title"> <h3 class="m-panel-title">
<AppIcon name="exchange" size="sm" /> <AppIcon name="exchange" size="sm" />
出卡方向 {{ t('dataCollect.cardOutput') }}
</h3> </h3>
<div class="m-radio-group"> <div class="m-radio-group">
<label class="m-radio-item"> <label class="m-radio-item">
<input v-model="collectStore.cardOutput" type="radio" :value="1" /> <input v-model="collectStore.cardOutput" type="radio" :value="1" />
<span class="radio-custom" /> <span class="radio-custom" />
<span>向前出卡</span> <span>{{ t('dataCollect.forwardOutput') }}</span>
</label> </label>
<label class="m-radio-item"> <label class="m-radio-item">
<input v-model="collectStore.cardOutput" type="radio" :value="2" /> <input v-model="collectStore.cardOutput" type="radio" :value="2" />
<span class="radio-custom" /> <span class="radio-custom" />
<span>向后出卡</span> <span>{{ t('dataCollect.backwardOutput') }}</span>
</label> </label>
</div> </div>
</section> </section>
@@ -57,6 +57,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { notify, notifyRequireInit } from '@/composables/useNotify' import { notify, notifyRequireInit } from '@/composables/useNotify'
import AppShell from '@/layouts/AppShell.vue' import AppShell from '@/layouts/AppShell.vue'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
@@ -66,8 +67,10 @@ import AppIcon from '@/components/AppIcon.vue'
import { useCollectStore } from '@/stores/collect' import { useCollectStore } from '@/stores/collect'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { dialogOpenDirectory, dllUsbCopy } from '@/api/cardsoon' import { dialogOpenDirectory, dllUsbCopy } from '@/api/cardsoon'
import { preflightPrinterStatus } from '@/utils/validateJobPreflight'
const router = useRouter() const router = useRouter()
const { t } = useI18n()
const collectStore = useCollectStore() const collectStore = useCollectStore()
const appStore = useAppStore() const appStore = useAppStore()
@@ -79,7 +82,7 @@ const canSubmit = computed(
async function addPath(): Promise<void> { async function addPath(): Promise<void> {
const r = await dialogOpenDirectory() const r = await dialogOpenDirectory()
if (!r.ok) { if (!r.ok) {
notify.error(r.message || '打开目录选择失败') notify.error(r.message || t('notify.pathOpenFailed'))
return return
} }
const picked = r.data?.paths[0] const picked = r.data?.paths[0]
@@ -88,21 +91,27 @@ async function addPath(): Promise<void> {
async function onSubmit(): Promise<void> { async function onSubmit(): Promise<void> {
if (!canUse.value) { if (!canUse.value) {
notifyRequireInit('开始 USB 收集') notifyRequireInit(t('notify.startUsbFailed'))
return return
} }
if (appStore.mode === 'distributing') { if (appStore.mode === 'distributing') {
notify.warning('请先停止数据分发任务') notify.warning(t('notify.stopDistribute'))
return return
} }
const dest = collectStore.destPath.trim() const dest = collectStore.destPath.trim()
if (!dest) { if (!dest) {
notify.warning('请先选择数据导入目录') notify.warning(t('notify.selectDirFirst'))
return
}
// 打印机硬件预检:未连接或故障码(如夹卡)时拦截提交
const fault = await preflightPrinterStatus()
if (fault) {
notify.warning(fault)
return return
} }
const r = await dllUsbCopy(dest, collectStore.cardOutput) const r = await dllUsbCopy(dest, collectStore.cardOutput)
if (!r.ok) { if (!r.ok) {
notify.error(r.message || '启动 USB 收集失败') notify.error(r.message || t('notify.startUsbFailed'))
return return
} }
appStore.setMode('usbCopying') appStore.setMode('usbCopying')
@@ -111,7 +120,7 @@ async function onSubmit(): Promise<void> {
function goHome(): void { function goHome(): void {
if (appStore.mode === 'usbCopying') { if (appStore.mode === 'usbCopying') {
notify.warning('数据收集进行中,请先在任务页停止') notify.warning(t('notify.usbCollectInProgress'))
return return
} }
router.push('/home') router.push('/home')
@@ -1,12 +1,12 @@
<template> <template>
<AppShell> <AppShell>
<AppHeader mode="数据分发模式"> <AppHeader :mode="t('header.modeDistribute')">
<div class="c-nav-group"> <div class="c-nav-group">
<NavButton icon="home" label="首页" @click="router.push('/home')" /> <NavButton icon="home" :label="t('common.home')" @click="router.push('/home')" />
<NavButton icon="trash" label="清空" @click="onClear" /> <NavButton icon="trash" :label="t('common.clear')" @click="onClear" />
<NavButton <NavButton
icon="check-circle" icon="check-circle"
label="提交" :label="t('common.submit')"
variant="primary" variant="primary"
:active="true" :active="true"
:disabled="!canSubmit" :disabled="!canSubmit"
@@ -17,38 +17,38 @@
<main class="app-shell__main l-main-flex"> <main class="app-shell__main l-main-flex">
<section class="c-panel m-panel--left"> <section class="c-panel m-panel--left">
<div class="c-panel__header"> <div class="c-panel__header">
<span class="c-panel__title">路径配置</span> <span class="c-panel__title">{{ t('distributeConfig.pathConfig') }}</span>
<div class="c-nav-group"> <div class="c-nav-group">
<button type="button" class="c-button-cs" @click="addPath"> <button type="button" class="c-button-cs" @click="addPath">
添加路径 {{ t('common.addPath') }}
</button> </button>
<button type="button" class="c-button-cs" @click="openNetworkDialog"> <button type="button" class="c-button-cs" @click="openNetworkDialog">
添加网络位置 {{ t('distributeConfig.addNetworkLocation') }}
</button> </button>
</div> </div>
</div> </div>
<div class="m-path-hint"> <div class="m-path-hint">
<AppIcon name="info-circle" size="sm" /> <AppIcon name="info-circle" size="sm" />
<span>系统将拷贝该目录下的所有子项但不包含文件夹本身</span> <span>{{ t('distributeConfig.pathHint') }}</span>
</div> </div>
<div v-if="configuredHosts.length" class="m-net-cred-hint"> <div v-if="configuredHosts.length" class="m-net-cred-hint">
已配置网络凭据{{ configuredHosts.join('') }} {{ t('distributeConfig.netCredConfigured', { hosts: configuredHosts.join('') }) }}
</div> </div>
<div class="m-panel-toolbar"> <div class="m-panel-toolbar">
<div class="toolbar-row"> <div class="toolbar-row">
<div class="toolbar-item"> <div class="toolbar-item">
<span>卷标</span> <span>{{ t('distributeConfig.volumeLabel') }}</span>
<input v-model="formStore.volumeLabel" type="text" class="c-input" /> <input v-model="formStore.volumeLabel" type="text" class="c-input" />
</div> </div>
<div class="toolbar-item"> <div class="toolbar-item">
<span>拷贝类型</span> <span>{{ t('distributeConfig.copyType') }}</span>
<AppSelect v-model="formStore.copyType" :items="COPY_TYPE_OPTIONS" /> <AppSelect v-model="formStore.copyType" :items="copyTypeItems" />
</div> </div>
</div> </div>
<div class="toolbar-row"> <div class="toolbar-row">
<div class="toolbar-item"> <div class="toolbar-item">
<span>格式化类型</span> <span>{{ t('distributeConfig.formatType') }}</span>
<AppSelect v-model="formStore.formatType" :items="FORMAT_TYPE_OPTIONS" /> <AppSelect v-model="formStore.formatType" :items="formatTypeItems" />
</div> </div>
<label class="c-checkbox-item"> <label class="c-checkbox-item">
<input <input
@@ -56,7 +56,7 @@
type="checkbox" type="checkbox"
@change="onDongleToggle" @change="onDongleToggle"
/> />
<span>加密狗</span> <span>{{ t('distributeConfig.dongle') }}</span>
<input <input
:value="dongleInputValue" :value="dongleInputValue"
type="number" type="number"
@@ -68,18 +68,27 @@
@input="onDongleCountInput" @input="onDongleCountInput"
/> />
<span class="dog-hint">{{ dongleHint }}</span> <span class="dog-hint">{{ dongleHint }}</span>
</label> <template v-if="formStore.dongleEnabled">
</div> <span class="dog-auth-label">{{ t('distributeConfig.dongleAuthLabel') }}</span>
<div v-if="formStore.dongleEnabled" class="toolbar-row"> <div class="dog-auth-wrap">
<div class="toolbar-item toolbar-item--full">
<input <input
v-model="formStore.dongleAuthCode" v-model="formStore.dongleAuthCode"
type="text" :type="showAuthCode ? 'text' : 'password'"
class="c-input dog-auth" class="c-input dog-auth"
placeholder="请输入授权码" :placeholder="t('distributeConfig.donglePassword')"
@input="onDongleAuthInput" @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> </div>
</template>
</label>
</div> </div>
</div> </div>
<div class="c-panel__body"> <div class="c-panel__body">
@@ -102,18 +111,41 @@
</section> </section>
<section class="c-panel m-panel--right"> <section class="c-panel m-panel--right">
<div class="c-panel__header"> <div class="c-panel__header">
<span class="c-panel__title">标签预览</span> <span class="c-panel__title">{{ t('distributeConfig.templatePreview') }}</span>
<div class="c-nav-group"> <div class="c-nav-group">
<button type="button" class="c-button-cs" @click="pickTemplate"> <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> </button>
</div> </div>
</div> </div>
<div v-if="hasTemplatePreview && hasDoubleSide" class="m-print-side-picker"> <div v-if="templatePreviewLoaded && hasDoubleSide" class="m-print-side-picker">
<template v-if="isSingleSidePrinter">
<label class="m-print-side-option">
<input v-model="formStore.printFlag" type="radio" :value="2" />
<span>{{ t('distributeConfig.frontSide') }}</span>
</label>
<label class="m-print-side-option">
<input v-model="formStore.printFlag" type="radio" :value="3" />
<span>{{ t('distributeConfig.backSide') }}</span>
</label>
<span v-if="!sidePicked" class="m-print-side-hint">
{{ t('distributeConfig.singleSideHint') }}
</span>
</template>
<template v-else>
<label class="m-print-side-option"> <label class="m-print-side-option">
<input v-model="formStore.printFlag" type="radio" :value="1" /> <input v-model="formStore.printFlag" type="radio" :value="1" />
<span>双面</span> <span>{{ t('distributeConfig.doubleSide') }}</span>
</label> </label>
</template>
</div> </div>
<div class="c-preview-area"> <div class="c-preview-area">
<div <div
@@ -158,7 +190,7 @@
<span class="c-path-cell__text" :title="row.value">{{ <span class="c-path-cell__text" :title="row.value">{{
imageFieldLabel(row.value) imageFieldLabel(row.value)
}}</span> }}</span>
<button type="button" class="c-field-pick" @click="pickFieldImage(idx)">选择</button> <button type="button" class="c-field-pick" @click="pickFieldImage(idx)">{{ t('common.select') }}</button>
</td> </td>
<td v-else-if="isTextField(row)"> <td v-else-if="isTextField(row)">
<input <input
@@ -188,7 +220,8 @@
</template> </template>
<script setup lang="ts"> <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 { useRouter } from 'vue-router'
import { cleanPathPattern } from '@shared/path-pattern' import { cleanPathPattern } from '@shared/path-pattern'
import { extractDriveLetter } from '@shared/network-host' import { extractDriveLetter } from '@shared/network-host'
@@ -200,11 +233,12 @@ import NavButton from '@/components/NavButton.vue'
import AppIcon from '@/components/AppIcon.vue' import AppIcon from '@/components/AppIcon.vue'
import AppSelect from '@/components/AppSelect.vue' import AppSelect from '@/components/AppSelect.vue'
import NetworkPathDialog from '@/components/NetworkPathDialog.vue' import NetworkPathDialog from '@/components/NetworkPathDialog.vue'
import { COPY_TYPE_OPTIONS, FORMAT_TYPE_OPTIONS } from '@/constants/selectOptions' import { copyTypeOptions, formatTypeOptions } from '@/constants/selectOptions'
import { CARD_CAPACITY_BYTES, CARD_CAPACITY_GB } from '@/constants/cardCapacity' import { CARD_CAPACITY_BYTES, CARD_CAPACITY_GB } from '@/constants/cardCapacity'
import { useDistributeFormStore } from '@/stores/distributeForm' import { useDistributeFormStore } from '@/stores/distributeForm'
import { useJobStore } from '@/stores/job' import { useJobStore } from '@/stores/job'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useConfigStore } from '@/stores/config'
import { useNetworkAuthStore } from '@/stores/networkAuth' import { useNetworkAuthStore } from '@/stores/networkAuth'
import { useDongleAuthStore } from '@/stores/dongleAuth' import { useDongleAuthStore } from '@/stores/dongleAuth'
import { validateJobPreflight } from '@/utils/validateJobPreflight' import { validateJobPreflight } from '@/utils/validateJobPreflight'
@@ -222,15 +256,29 @@ import {
} from '@/api/cardsoon' } from '@/api/cardsoon'
const router = useRouter() const router = useRouter()
const { t } = useI18n()
const formStore = useDistributeFormStore() const formStore = useDistributeFormStore()
const jobStore = useJobStore() const jobStore = useJobStore()
const appStore = useAppStore() const appStore = useAppStore()
const configStore = useConfigStore()
const netStore = useNetworkAuthStore() const netStore = useNetworkAuthStore()
const dongleStore = useDongleAuthStore() const dongleStore = useDongleAuthStore()
const networkDialogVisible = ref(false) const networkDialogVisible = ref(false)
const networkDialogHost = ref('') const networkDialogHost = ref('')
const networkDialogShare = 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 let dongleAuthPersistTimer: ReturnType<typeof setTimeout> | null = null
@@ -259,13 +307,26 @@ const templateFlag = computed(() => formStore.templatePreview?.templateFlag ?? 0
const hasDoubleSide = computed(() => templateFlag.value === 1) const hasDoubleSide = computed(() => templateFlag.value === 1)
const isSingleSidePrinter = computed(() => configStore.printer.isSingleSide)
/** 模板已加载(无论有无可编辑字段),用于选面区显示 */
const templatePreviewLoaded = computed(() => !!formStore.templatePreview)
/** 单面打印机 + 双面模板时,必须显式选择正面或背面 */
const sidePicked = computed(
() => formStore.printFlag === 2 || formStore.printFlag === 3
)
const copyTypeItems = computed(() => copyTypeOptions(t))
const formatTypeItems = computed(() => formatTypeOptions(t))
const canPickFront = computed(() => templateFlag.value !== 3) const canPickFront = computed(() => templateFlag.value !== 3)
const canPickBack = computed(() => templateFlag.value !== 2) const canPickBack = computed(() => templateFlag.value !== 2)
const loadProgressText = computed(() => { const loadProgressText = computed(() => {
const loadedGb = formatBytesAsGb(totalLoadedBytes.value) const loadedGb = formatBytesAsGb(totalLoadedBytes.value)
return `已加载: ${loadedGb} GB / ${CARD_CAPACITY_GB} GB (${loadPercent.value}%)` return t('distributeConfig.loadProgress', { loaded: loadedGb, total: CARD_CAPACITY_GB, percent: loadPercent.value })
}) })
function isTextField(row: TemplateFieldRow): boolean { function isTextField(row: TemplateFieldRow): boolean {
@@ -283,7 +344,9 @@ function imageFieldLabel(value: string): string {
return parts[parts.length - 1] || v return parts[parts.length - 1] || v
} }
function defaultPrintFlagForTemplate(flag: number): number { function defaultPrintFlagForTemplate(flag: number, isSingleSide: boolean): number {
// 单面打印机 + 双面模板:不默认,强制用户选择打印面(0=未选择)
if (isSingleSide && flag === 1) return 0
if (flag === 1 || flag === 2 || flag === 3) return flag if (flag === 1 || flag === 2 || flag === 3) return flag
return 2 return 2
} }
@@ -311,7 +374,14 @@ async function pickFieldImage(idx: number): Promise<void> {
function onClear(): void { function onClear(): void {
formStore.reset() formStore.reset()
notify.info('已清空,已恢复初始状态') notify.info(t('distributeConfig.cleared'))
}
function removeTemplate(): void {
formStore.templateFile = ''
formStore.templatePreview = null
formStore.printFlag = 1
notify.info(t('distributeConfig.templateRemoved'))
} }
const dongleInputValue = computed(() => const dongleInputValue = computed(() =>
@@ -349,7 +419,7 @@ function onDongleAuthInput(): void {
async function addPath(): Promise<void> { async function addPath(): Promise<void> {
const r = await dialogOpenDirectory() const r = await dialogOpenDirectory()
if (!r.ok) { if (!r.ok) {
notify.error(r.message || '打开目录选择失败') notify.error(r.message || t('notify.pathOpenFailed'))
return return
} }
if (!r.data?.paths.length) return if (!r.data?.paths.length) return
@@ -357,7 +427,7 @@ async function addPath(): Promise<void> {
const idx = formStore.pathList.length const idx = formStore.pathList.length
formStore.pathList.push({ formStore.pathList.push({
path: `${dir}\\*.*`, path: `${dir}\\*.*`,
meta: '计算中…', meta: t('distributeConfig.calculating'),
sizeBytes: 0 sizeBytes: 0
}) })
await refreshPathSize(idx, dir) await refreshPathSize(idx, dir)
@@ -431,13 +501,16 @@ async function pickTemplate(): Promise<void> {
} }
formStore.templateFile = soonPath formStore.templateFile = soonPath
formStore.templatePreview = parsed.data formStore.templatePreview = parsed.data
formStore.printFlag = defaultPrintFlagForTemplate(parsed.data.templateFlag) formStore.printFlag = defaultPrintFlagForTemplate(
parsed.data.templateFlag,
isSingleSidePrinter.value
)
const { fields, frontImageUrl, backImageUrl } = parsed.data const { fields, frontImageUrl, backImageUrl } = parsed.data
if (!fields.length && !frontImageUrl && !backImageUrl) { if (!fields.length && !frontImageUrl && !backImageUrl) {
notify.warning('模板已打开,但未解析到可预览内容') notify.warning(t('distributeConfig.templateNoPreview'))
return return
} }
notify.success('已加载标签模板') notify.success(t('distributeConfig.templateLoaded'))
} }
async function onSubmit(): Promise<void> { async function onSubmit(): Promise<void> {
@@ -456,6 +529,8 @@ async function onSubmit(): Promise<void> {
return return
} }
jobStore.submitting = true jobStore.submitting = true
// 新任务开始前重置次数计数器(不重置 jobId/lastJobJson,避免影响并发逻辑)
jobStore.resetCounts()
try { try {
const created = await createDistributeJob(formStore) const created = await createDistributeJob(formStore)
if (!created.ok) { if (!created.ok) {
@@ -471,7 +546,7 @@ async function onSubmit(): Promise<void> {
await dllJobCancel(newJobId) await dllJobCancel(newJobId)
jobStore.clearActiveJob() jobStore.clearActiveJob()
appStore.setMode('ready') appStore.setMode('ready')
notify.error('无法进入运行页,已取消任务') notify.error(t('notify.cannotEnterRunning'))
} }
} finally { } finally {
jobStore.submitting = false jobStore.submitting = false
@@ -488,15 +563,6 @@ async function onSubmit(): Promise<void> {
color: #606266; color: #606266;
} }
.toolbar-item--full {
flex: 1;
}
.dog-auth {
width: 100%;
max-width: 100%;
}
.m-print-side-picker { .m-print-side-picker {
display: flex; display: flex;
gap: 16px; gap: 16px;
@@ -512,6 +578,11 @@ async function onSubmit(): Promise<void> {
cursor: pointer; cursor: pointer;
} }
.m-print-side-hint {
color: #e6a23c;
font-weight: 500;
}
.c-card-small--slot { .c-card-small--slot {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2,29 +2,26 @@
<AppShell> <AppShell>
<AppHeader :mode="headerMode"> <AppHeader :mode="headerMode">
<div v-if="phase === 'failed'" class="c-nav-group"> <div v-if="phase === 'failed'" class="c-nav-group">
<NavButton icon="arrow-left" label="返回" @click="onFailedBack" /> <NavButton icon="arrow-left" :label="t('common.back')" @click="onFailedBack" />
<NavButton icon="redo" label="重置" variant="primary" @click="onFailedReset" /> <NavButton icon="redo" :label="t('common.reset')" variant="primary" @click="onFailedReset" />
</div> </div>
<NavButton <NavButton
v-else-if="phase === 'completed'" v-else-if="phase === 'completed'"
icon="home" icon="home"
label="返回" :label="t('common.back')"
@click="onReturn" @click="onReturn"
/> />
<NavButton v-else icon="stop" label="停止" variant="stop" @click="onStop" /> <NavButton v-else icon="stop" :label="t('common.stop')" variant="stop" @click="onStop" />
</AppHeader> </AppHeader>
<main class="app-shell__main l-main-full"> <main class="app-shell__main l-main-full">
<section class="l-hero-container"> <section class="l-hero-container">
<div class="m-left-panel"> <div class="m-left-panel">
<div class="c-status-panel"> <div class="c-status-panel">
<template v-if="phase === 'failed'"> <template v-if="phase === 'failed'">
<h2 class="c-status-title is-error">任务失败</h2> <h2 class="c-status-title is-error">{{ t('running.taskFailed') }}</h2>
<p class="c-status-sub">{{ failedSub }}</p> <p class="c-status-sub">{{ failedSub }}</p>
<p class="c-status-counter"> <p class="c-status-counter">
任务已经完成<span class="ok">{{ successCount }}</span>其中失败次数是<span {{ t('running.progressCounter', { success: successCount, fail: failCount }) }}
class="err"
>{{ failCount }}</span
>
</p> </p>
<p v-if="failureErrorText" class="m-error-detail">{{ failureErrorText }}</p> <p v-if="failureErrorText" class="m-error-detail">{{ failureErrorText }}</p>
</template> </template>
@@ -34,10 +31,7 @@
</h2> </h2>
<p class="c-status-sub">{{ statusSub }}</p> <p class="c-status-sub">{{ statusSub }}</p>
<p class="c-status-counter"> <p class="c-status-counter">
任务已经完成<span class="ok">{{ successCount }}</span>其中失败次数是<span {{ t('running.progressCounter', { success: successCount, fail: failCount }) }}
class="err"
>{{ failCount }}</span
>
</p> </p>
</template> </template>
</div> </div>
@@ -81,6 +75,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { notify } from '@/composables/useNotify' import { notify } from '@/composables/useNotify'
import { refreshLiveStatus } from '@/composables/usePrinterStatus' import { refreshLiveStatus } from '@/composables/usePrinterStatus'
import AppShell from '@/layouts/AppShell.vue' import AppShell from '@/layouts/AppShell.vue'
@@ -120,15 +115,21 @@ import type { CardPositionPollPayload, JobPollPayload, UsbPollPayload } from '@/
const CIRCLE_LEN = 283 const CIRCLE_LEN = 283
/** 等待进卡/读 U 盘超过该时长仍无进展,给出可操作提示(每个等待回合提示一次) */
const WAIT_WARN_MS = 45000
/** 看门狗检查间隔 */
const WAIT_WATCHDOG_MS = 5000
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { t } = useI18n()
const jobStore = useJobStore() const jobStore = useJobStore()
const collectStore = useCollectStore() const collectStore = useCollectStore()
const formStore = useDistributeFormStore() const formStore = useDistributeFormStore()
const appStore = useAppStore() const appStore = useAppStore()
const isCollect = computed(() => route.name === 'collect-running') const isCollect = computed(() => route.name === 'collect-running')
const headerMode = computed(() => (isCollect.value ? '数据收集模式' : '数据分发模式')) const headerMode = computed(() => (isCollect.value ? t('header.modeCollect') : t('header.modeDistribute')))
const successCount = computed(() => const successCount = computed(() =>
isCollect.value ? collectStore.successCount : jobStore.successCount isCollect.value ? collectStore.successCount : jobStore.successCount
) )
@@ -151,35 +152,38 @@ let lastCardPosition = -1
let usbAwaitNewCycle = false let usbAwaitNewCycle = false
let queryFailStreak = 0 let queryFailStreak = 0
let usbQueryFailStreak = 0 let usbQueryFailStreak = 0
let waitSince = 0
let waitWarned = false
let waitWatchdog: ReturnType<typeof setInterval> | null = null
const displayProgress = computed(() => Math.round(progress.value)) const displayProgress = computed(() => Math.round(progress.value))
const failedSub = computed(() => const failedSub = computed(() =>
isCollect.value isCollect.value
? '请检查读卡器与卡片,插入备卡位可自动重试' ? t('running.failedSubCollect')
: '请检查设备故障,插入备卡位可自动重试' : t('running.failedSubDistribute')
) )
const statusTitle = computed(() => { const statusTitle = computed(() => {
if (phase.value === 'completed') { if (phase.value === 'completed') {
return isCollect.value ? '收集已完成' : '任务已完成' return isCollect.value ? t('running.collectCompleted') : t('running.taskCompleted')
} }
if (isCollect.value) { if (isCollect.value) {
return collectHint.value || '数据收集中' return collectHint.value || t('running.dataCollecting')
} }
return waitCard.value ? '等待插卡' : '任务执行中' return waitCard.value ? t('running.waitingCard') : t('running.taskRunning')
}) })
const statusSub = computed(() => { const statusSub = computed(() => {
if (phase.value === 'completed') { if (phase.value === 'completed') {
return isCollect.value return isCollect.value
? '请点击返回,或插入备卡位继续下一张' ? t('running.completedSubCollect')
: '请点击返回,或插入备卡位自动开始下一张' : t('running.completedSubDistribute')
} }
if (isCollect.value) { if (isCollect.value) {
return collectHint.value || '正在从卡片读取并写入导入目录' return collectHint.value || t('running.collectingHint')
} }
return waitCard.value ? '请插入数据卡,任务将自动执行' : '任务执行中,请稍候' return waitCard.value ? t('running.waitCardHint') : t('running.runningHint')
}) })
function setProgress(value: number): void { function setProgress(value: number): void {
@@ -197,6 +201,41 @@ function clearPollListeners(): void {
unsubCard = null 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> { async function releasePolls(resetMode: boolean): Promise<void> {
clearPollListeners() clearPollListeners()
await pollCardPositionStop() await pollCardPositionStop()
@@ -229,11 +268,12 @@ async function startCardPositionWatch(
) )
const cardStarted = await pollCardPositionStart(sessionMode) const cardStarted = await pollCardPositionStart(sessionMode)
if (!cardStarted.ok) { if (!cardStarted.ok) {
notify.warning(cardStarted.message || '无法监控卡位,请手动点击返回或重新提交') notify.warning(cardStarted.message || t('notify.pollMonitorFailed'))
} }
} }
async function enterWaitPhase(next: 'completed' | 'failed', errorText = ''): Promise<void> { async function enterWaitPhase(next: 'completed' | 'failed', errorText = ''): Promise<void> {
clearWaiting()
if (phase.value !== next) { if (phase.value !== next) {
phase.value = next phase.value = next
if (next === 'completed') { if (next === 'completed') {
@@ -306,7 +346,7 @@ async function resubmitTask(): Promise<void> {
if (isCollect.value) { if (isCollect.value) {
const dest = collectStore.destPath.trim() const dest = collectStore.destPath.trim()
if (!dest) { if (!dest) {
await backToWait('导入目录无效,无法继续') await backToWait(t('notify.dirInvalid'))
return return
} }
phase.value = 'running' phase.value = 'running'
@@ -315,13 +355,15 @@ async function resubmitTask(): Promise<void> {
collectHint.value = '' collectHint.value = ''
queryFailStreak = 0 queryFailStreak = 0
usbQueryFailStreak = 0 usbQueryFailStreak = 0
clearWaiting()
const r = await dllUsbCopy(dest, collectStore.cardOutput, { resubmit: true }) const r = await dllUsbCopy(dest, collectStore.cardOutput, { resubmit: true })
if (!r.ok) { if (!r.ok) {
await backToWait(r.message || '重新提交收集任务失败') await backToWait(r.message || t('notify.resubmitCollectFailed'))
return return
} }
usbAwaitNewCycle = true usbAwaitNewCycle = true
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING) collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
markWaiting()
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload)) unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
void refreshLiveStatus() void refreshLiveStatus()
return return
@@ -340,6 +382,7 @@ async function resubmitTask(): Promise<void> {
workflowStep.value = 1 workflowStep.value = 1
waitCard.value = false waitCard.value = false
queryFailStreak = 0 queryFailStreak = 0
clearWaiting()
const created = await createDistributeJob(formStore, { resubmit: true }) const created = await createDistributeJob(formStore, { resubmit: true })
if (!created.ok) { if (!created.ok) {
await backToWait(created.message) await backToWait(created.message)
@@ -348,7 +391,7 @@ async function resubmitTask(): Promise<void> {
jobStore.setActiveJob(created.jobId) jobStore.setActiveJob(created.jobId)
const started = await pollJobStart(created.jobId) const started = await pollJobStart(created.jobId)
if (!started.ok) { if (!started.ok) {
await backToWait(started.message || '启动任务轮询失败') await backToWait(started.message || t('notify.pollStartFailed'))
return return
} }
unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload)) unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
@@ -366,7 +409,7 @@ function applyJobProgress(p: JobPollPayload): void {
queryFailStreak += 1 queryFailStreak += 1
if (queryFailStreak < 3) return if (queryFailStreak < 3) return
jobStore.failCount += 1 jobStore.failCount += 1
void enterFailedPhase(`查询任务失败: ${p.queryErrorCode}`) void enterFailedPhase(t('notify.queryJobFailed', { code: p.queryErrorCode }))
return return
} }
queryFailStreak = 0 queryFailStreak = 0
@@ -375,9 +418,12 @@ function applyJobProgress(p: JobPollPayload): void {
const ui = mapJobStateToUi(p.jobState) const ui = mapJobStateToUi(p.jobState)
workflowStep.value = ui.workflowStep workflowStep.value = ui.workflowStep
waitCard.value = ui.hint === 'waitCard' waitCard.value = ui.hint === 'waitCard'
if (ui.hint === 'waitCard') markWaiting()
else clearWaiting()
if (p.failed) { if (p.failed) {
jobStore.failCount += 1 jobStore.failCount += 1
void enterFailedPhase() // 优先使用主进程在失败当刻取到的错误串,避免事后取到通用文案
void enterFailedPhase(p.errorMessage || '')
return return
} }
if (p.cancelled) { if (p.cancelled) {
@@ -397,7 +443,7 @@ function applyUsbProgress(p: UsbPollPayload): void {
usbQueryFailStreak += 1 usbQueryFailStreak += 1
if (usbQueryFailStreak < 3) return if (usbQueryFailStreak < 3) return
collectStore.failCount += 1 collectStore.failCount += 1
void enterFailedPhase(`查询 USB 任务失败: ${p.queryCode}`) void enterFailedPhase(t('notify.queryUsbFailed', { code: p.queryCode }))
return return
} }
usbQueryFailStreak = 0 usbQueryFailStreak = 0
@@ -415,7 +461,7 @@ function applyUsbProgress(p: UsbPollPayload): void {
collectStore.successCount += 1 collectStore.successCount += 1
workflowStep.value = 3 workflowStep.value = 3
collectHint.value = usbTaskStatusHint(p.taskStatus) collectHint.value = usbTaskStatusHint(p.taskStatus)
notify.success('USB 收集完成') notify.success(t('notify.usbCollectComplete'))
void enterCompletedPhase() void enterCompletedPhase()
return return
} }
@@ -423,6 +469,9 @@ function applyUsbProgress(p: UsbPollPayload): void {
const copyProgress = clampUsbCopyProgress(p.progress) const copyProgress = clampUsbCopyProgress(p.progress)
workflowStep.value = p.taskStatus === USB_TASK_COPYING ? 2 : 1 workflowStep.value = p.taskStatus === USB_TASK_COPYING ? 2 : 1
collectHint.value = usbTaskStatusHint(p.taskStatus) collectHint.value = usbTaskStatusHint(p.taskStatus)
// PREPARING(等待插卡/读 U 盘)开始计时;进入 COPYING 等后续阶段后清除
if (p.taskStatus === USB_TASK_PREPARING) markWaiting()
else clearWaiting()
setProgress(copyProgress) setProgress(copyProgress)
} }
@@ -438,7 +487,7 @@ async function finishDistribute(
if (id && !skipCancel) { if (id && !skipCancel) {
const r = await dllJobCancel(id) const r = await dllJobCancel(id)
if (!r.ok) { if (!r.ok) {
notify.warning(r.message || '取消任务时出现问题') notify.warning(r.message || t('notify.cancelIssue'))
} }
} }
jobStore.clearActiveJob() jobStore.clearActiveJob()
@@ -463,6 +512,8 @@ onMounted(async () => {
workflowStep.value = 1 workflowStep.value = 1
setProgress(0) setProgress(0)
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING) collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
startWaitWatchdog()
markWaiting()
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload)) unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
void refreshLiveStatus() void refreshLiveStatus()
return return
@@ -474,9 +525,11 @@ onMounted(async () => {
} }
appStore.setMode('distributing') appStore.setMode('distributing')
setProgress(0) setProgress(0)
clearWaiting()
startWaitWatchdog()
const started = await pollJobStart(jobStore.jobId) const started = await pollJobStart(jobStore.jobId)
if (!started.ok) { if (!started.ok) {
notify.error(started.message || '启动任务轮询失败') notify.error(started.message || t('notify.pollStartFailed'))
await finishDistribute(false) await finishDistribute(false)
return return
} }
@@ -485,6 +538,7 @@ onMounted(async () => {
}) })
onUnmounted(() => { onUnmounted(() => {
stopWaitWatchdog()
if (finishing || phase.value !== 'running') return if (finishing || phase.value !== 'running') return
void releasePolls(true) void releasePolls(true)
if (isCollect.value && appStore.mode === 'usbCopying') { if (isCollect.value && appStore.mode === 'usbCopying') {
@@ -541,11 +595,12 @@ async function onStop(): Promise<void> {
<style scoped> <style scoped>
.m-error-detail { .m-error-detail {
margin-top: 6px; margin-top: 8px;
font-size: 10px; font-size: 12px;
line-height: 1.5;
color: #dc3545; color: #dc3545;
font-weight: 600; font-weight: 600;
max-width: 320px; max-width: 340px;
word-break: break-all; word-break: break-all;
} }
+69 -23
View File
@@ -1,35 +1,61 @@
<template> <template>
<AppShell> <AppShell>
<AppHeader mode="卡树数据卡打印机软件" /> <AppHeader :mode="t('header.modeHome')" />
<main class="app-shell__main l-dashboard"> <main class="app-shell__main l-dashboard">
<section class="m-tool-section"> <section class="m-tool-section">
<h3 class="m-section-title">工具</h3> <h3 class="m-section-title">{{ t('home.toolsTitle') }}</h3>
<div class="m-tool-grid"> <div class="m-tool-grid">
<button type="button" class="m-tool-btn" @click="onReset"> <button type="button" class="m-tool-btn" @click="onReset">
<AppIcon name="redo" /> <AppIcon name="redo" />
<span>重置打印机</span> <span>{{ t('common.resetPrinter') }}</span>
</button> </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" /> <AppIcon name="trash" />
<span>废弃卡片</span> <span>{{ t('common.discardCard') }}</span>
</button> </button>
<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-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"> <button type="button" class="m-tool-btn" @click="onTemplate">
<AppIcon name="paint-brush" /> <AppIcon name="paint-brush" />
<span>模板设计</span> <span>{{ t('common.templateDesign') }}</span>
</button> </button>
</div> </div>
</section> </section>
<div class="m-divider" /> <div class="m-divider" />
<section class="m-task-section"> <section class="m-task-section">
<h3 class="m-section-title">任务</h3> <h3 class="m-section-title">{{ t('home.tasksTitle') }}</h3>
<div class="m-task-grid"> <div class="m-task-grid">
<button type="button" class="m-task-card" @click="goDistribute"> <button type="button" class="m-task-card" @click="goDistribute">
<div class="m-task-icon"> <div class="m-task-icon">
<AppIcon name="share" /> <AppIcon name="share" />
</div> </div>
<div class="m-task-info"> <div class="m-task-info">
<h4>数据分发</h4> <h4>{{ t('home.dataDistribute') }}</h4>
<p>分发数据到打印卡片</p> <p>{{ t('home.dataDistributeDesc') }}</p>
</div> </div>
</button> </button>
<button type="button" class="m-task-card" @click="goCollect"> <button type="button" class="m-task-card" @click="goCollect">
@@ -37,8 +63,8 @@
<AppIcon name="download" /> <AppIcon name="download" />
</div> </div>
<div class="m-task-info"> <div class="m-task-info">
<h4>数据收集</h4> <h4>{{ t('home.dataCollect') }}</h4>
<p>从卡片收集导入数据</p> <p>{{ t('home.dataCollectDesc') }}</p>
</div> </div>
</button> </button>
</div> </div>
@@ -50,6 +76,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { notify, notifyRequireInit } from '@/composables/useNotify' import { notify, notifyRequireInit } from '@/composables/useNotify'
import { refreshLiveStatus } from '@/composables/usePrinterStatus' import { refreshLiveStatus } from '@/composables/usePrinterStatus'
@@ -59,8 +86,9 @@ import AppFooter from '@/components/AppFooter.vue'
import AppIcon from '@/components/AppIcon.vue' import AppIcon from '@/components/AppIcon.vue'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { dllPrinterReject, dllPrinterReset, openDesignApp } from '@/api/cardsoon' import { dllPrinterEjectCard, dllPrinterReadCard, dllPrinterReject, dllPrinterReset, openDesignApp } from '@/api/cardsoon'
const { t } = useI18n()
const router = useRouter() const router = useRouter()
const appStore = useAppStore() const appStore = useAppStore()
const configStore = useConfigStore() const configStore = useConfigStore()
@@ -73,43 +101,61 @@ function guardInit(action?: string): boolean {
} }
async function onReset(): Promise<void> { async function onReset(): Promise<void> {
if (!guardInit('重置打印机')) return if (!guardInit(t('common.resetPrinter'))) return
const r = await dllPrinterReset() const r = await dllPrinterReset()
if (r.ok) { if (r.ok) {
notify.success('已发送重置指令') notify.success(t('notify.resetSent'))
await refreshLiveStatus() await refreshLiveStatus()
} else notify.error(r.message || '重置失败') } else notify.error(r.message || t('notify.resetFailed'))
} }
async function onReject(): Promise<void> { async function onReject(): Promise<void> {
if (!guardInit('废弃卡片')) return if (!guardInit(t('common.discardCard'))) return
if (!configStore.rejectApiAvailable) { if (!configStore.rejectApiAvailable) {
notify.warning('当前环境不支持废卡接口') notify.warning(t('notify.rejectUnavailable'))
return return
} }
const r = await dllPrinterReject() const r = await dllPrinterReject()
if (r.ok) { if (r.ok) {
notify.success('已废弃卡片') notify.success(t('notify.cardRejected'))
await refreshLiveStatus() await refreshLiveStatus()
} else notify.error(r.message || '操作失败') } else notify.error(r.message || t('notify.operationFailed'))
}
async function onReadCard(): Promise<void> {
if (!guardInit(t('home.readCard'))) return
const r = await dllPrinterReadCard()
if (r.ok) {
notify.success(t('notify.readCardSent'))
await refreshLiveStatus()
} else notify.error(r.message || t('notify.readCardFailed'))
}
async function onEjectCard(): Promise<void> {
if (!guardInit(t('home.ejectCard'))) return
const r = await dllPrinterEjectCard()
if (r.ok) {
notify.success(t('notify.ejectCardSent'))
await refreshLiveStatus()
} else notify.error(r.message || t('notify.ejectCardFailed'))
} }
async function onTemplate(): Promise<void> { async function onTemplate(): Promise<void> {
const r = await openDesignApp() const r = await openDesignApp()
if (!r.ok) { if (!r.ok) {
notify.error(r.message || '打开设计软件失败,请检查 cardsoon.config.json') notify.warning(r.message || t('notify.designCancelled'))
return return
} }
notify.success('已启动设计软件') notify.success(t('notify.designStarted'))
} }
function guardBusy(): boolean { function guardBusy(): boolean {
if (appStore.mode === 'distributing') { if (appStore.mode === 'distributing') {
notify.warning('请先停止数据分发任务') notify.warning(t('notify.stopDistribute'))
return false return false
} }
if (appStore.mode === 'usbCopying') { if (appStore.mode === 'usbCopying') {
notify.warning('USB 收集进行中,请等待完成') notify.warning(t('notify.usbCollecting'))
return false return false
} }
return true return true
+9 -3
View File
@@ -22,6 +22,12 @@ export function extractDriveLetter(p: string): string {
return m ? m[1].toUpperCase() : '' return m ? m[1].toUpperCase() : ''
} }
export interface NetInfoCredential {
host_name: string
user_name: string
password: string
}
export function buildNetworkUrl(host: string, share: string): string { export function buildNetworkUrl(host: string, share: string): string {
const h = host.trim() const h = host.trim()
const s = share.trim().replace(/^[\\/]+/, '').replace(/[\\/]+$/, '') const s = share.trim().replace(/^[\\/]+/, '').replace(/[\\/]+$/, '')
@@ -52,8 +58,8 @@ export function collectHostsForCopyPaths(
export function buildNetInfo( export function buildNetInfo(
networkItems: { hostName?: string; userName?: string; password?: string }[], networkItems: { hostName?: string; userName?: string; password?: string }[],
getStored: (host: string) => { userName?: string; password?: string } | null getStored: (host: string) => { userName?: string; password?: string } | null
): string { ): NetInfoCredential[] {
const creds: { host_name: string; user_name: string; password: string }[] = [] const creds: NetInfoCredential[] = []
const seen = new Set<string>() const seen = new Set<string>()
for (const it of networkItems) { for (const it of networkItems) {
const host = (it.hostName || '').trim() const host = (it.hostName || '').trim()
@@ -71,5 +77,5 @@ export function buildNetInfo(
password password
}) })
} }
return creds.length > 0 ? JSON.stringify(creds) : '' return creds
} }
+26 -3
View File
@@ -3,6 +3,19 @@ export interface PrinterStatusSnapshot {
ribbonAmount: string ribbonAmount: string
statusText: string statusText: string
serialNo: string serialNo: string
/** 打印机型号/名称(如 TH80),用于判断单/双面能力 */
printerName: string
/** 是否为单面打印机(如 TH80),单面打印机不能选"双面"打印 */
isSingleSide: boolean
}
/** 已知单面打印机型号关键字(命中即视为单面) */
const SINGLE_SIDE_PRINTER_PATTERNS = ['TH80']
function detectSingleSide(printerName: string): boolean {
const s = (printerName || '').trim().toUpperCase()
if (!s) return false
return SINGLE_SIDE_PRINTER_PATTERNS.some((p) => s.includes(p.toUpperCase()))
} }
const PRINTER_STATUS_MAP: Record<string, string> = { const PRINTER_STATUS_MAP: Record<string, string> = {
@@ -57,17 +70,27 @@ function snapshotFromRecord(row: Record<string, unknown>): PrinterStatusSnapshot
'SerialNo', 'SerialNo',
'serialNo', 'serialNo',
'szPrinterSerial', 'szPrinterSerial',
'PrinterSerial', 'PrinterSerial'
'PrinterName' ])
const printerName = pickFirst(row, [
'PrinterName',
'printer_name',
'PrinterModel',
'model',
'szPrinterName',
'szPrinterModel'
]) ])
const ribbonType = pickFirst(row, ['ribbon_type', 'RibbonType']) const ribbonType = pickFirst(row, ['ribbon_type', 'RibbonType'])
const ribbonAmount = pickFirst(row, ['RibbonAmount', 'ribbon_amount']) const ribbonAmount = pickFirst(row, ['RibbonAmount', 'ribbon_amount'])
const nameStr = String(printerName ?? '—')
return { return {
ribbonType: String(ribbonType ?? '—'), ribbonType: String(ribbonType ?? '—'),
ribbonAmount: String(ribbonAmount ?? '—'), ribbonAmount: String(ribbonAmount ?? '—'),
statusText: '—', statusText: '—',
serialNo: String(serial ?? '—') serialNo: String(serial ?? printerName ?? '—'),
printerName: nameStr,
isSingleSide: detectSingleSide(nameStr)
} }
} }
File diff suppressed because one or more lines are too long