完善分发与收集任务全流程

- RestJobEx 对齐 task_id,path_file 提交目录路径
- 运行页进度仅跟接口轮询,失败态停留 page3 UI
- 完成态卡位续做,USB/任务轮询生命周期优化
- 拆分 DLL 加载、任务 staging 与提交前校验
- 移除 mock 与冗余样式,补充 native 依赖

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24kycj
2026-06-01 09:43:24 +08:00
parent 505e72ce28
commit 574cb353e8
56 changed files with 1617 additions and 2350 deletions
-9
View File
@@ -6,7 +6,6 @@ import { getProcessExecDir } from './native-path'
export const APP_CONFIG_FILENAME = 'cardsoon.config.json'
/** 与 cardsoon.config.json 键名一致,后续配置在此扩展 */
export interface AppFileConfig {
designAppPath: string
}
@@ -16,7 +15,6 @@ const defaults: AppFileConfig = {
}
let cached: AppFileConfig | null = null
let loadedFrom = ''
function bundledConfigPath(): string {
if (app.isPackaged) {
@@ -46,7 +44,6 @@ export function loadAppFileConfig(): AppFileConfig {
if (!fs.existsSync(filePath)) continue
try {
cached = parseConfigFile(filePath)
loadedFrom = filePath
log.info(`Loaded ${APP_CONFIG_FILENAME} from ${filePath}`)
return cached
} catch (e) {
@@ -55,18 +52,12 @@ export function loadAppFileConfig(): AppFileConfig {
}
cached = { ...defaults }
loadedFrom = ''
log.warn(
`${APP_CONFIG_FILENAME} not found (checked: ${configSearchPaths().join(', ')}), using defaults`
)
return cached
}
export function getAppConfigLoadedPath(): string {
loadAppFileConfig()
return loadedFrom
}
export function getDesignAppPath(): string {
return loadAppFileConfig().designAppPath
}
+1 -9
View File
@@ -6,23 +6,15 @@ import type { PrinterStatusSnapshot } from '@shared/printer-info'
interface AppConfig {
sharedDir: string
templateDir: string
/** G2 门禁 false:启动即 SAPI_Init;仅调试可改 true */
skipDllInit: boolean
/** trueIPC/DLL 等调用输出到 DevTools 控制台 */
traceEnabled: boolean
/** 上次成功的 GetPrinterInfo 解析结果,供离线/失败时展示 */
lastPrinterStatus?: PrinterStatusSnapshot
}
const defaultShared = path.join('C:', 'PrintTasks')
export const configStore = new Store<AppConfig>({
name: 'cardsoon-config',
defaults: {
sharedDir: defaultShared,
sharedDir: path.join('C:', 'PrintTasks'),
templateDir: path.join(app.getPath('userData'), 'Cardsoon', 'templates'),
// 正式版始终 Init;仅开发时可通过 --skip-dll-init 临时跳过
skipDllInit: false,
traceEnabled: true
}
})
+51
View File
@@ -0,0 +1,51 @@
import fs from 'fs'
import log from 'electron-log'
import { CS_OK } from '../constants'
import { mainAppState } from './app-state'
import { configStore } from './config-store'
import { loadDllModule } from './dll-loader'
import type { InitParams } from './work-dll.service'
let dllInitAttempted = false
export function isDllInitAttempted(): boolean {
return dllInitAttempted
}
export async function ensureDllInitialized(
params?: Partial<InitParams>
): Promise<{ code: number; warning?: string }> {
if (dllInitAttempted) {
return { code: CS_OK }
}
const sharedDir =
params?.sharedDir || (configStore.get('sharedDir') as string) || 'C:\\PrintTasks'
try {
const dll = await loadDllModule()
fs.mkdirSync(sharedDir, { recursive: true })
const code = dll.dllInit({
sharedDir,
keepCombinedImage: params?.keepCombinedImage,
stopOnFailure: params?.stopOnFailure,
cleanTaskFile: params?.cleanTaskFile,
autoRetryTimes: params?.autoRetryTimes,
rejectConfig: params?.rejectConfig,
logLevel: params?.logLevel,
outBack: params?.outBack
})
dllInitAttempted = true
mainAppState.initialized = true
configStore.set('sharedDir', sharedDir)
log.info('DLL initialized', { sharedDir, code })
if (code === CS_OK) return { code }
return {
code,
warning: '打印机未连接或驱动未就绪,可继续配置任务,接好设备后重启应用'
}
} catch (err) {
mainAppState.initialized = false
dllInitAttempted = false
log.error('DLL init failed', err)
throw err
}
}
+20
View File
@@ -0,0 +1,20 @@
import { setupNativeWorkingDir } from './native-path'
type DllModule = typeof import('./work-dll.service')
let dllMod: DllModule | null = null
let nativeReady = false
function ensureNativeEnv(): void {
if (nativeReady) return
setupNativeWorkingDir()
nativeReady = true
}
export async function loadDllModule(): Promise<DllModule> {
ensureNativeEnv()
if (!dllMod) {
dllMod = await import('./work-dll.service')
}
return dllMod
}
+6 -1
View File
@@ -88,5 +88,10 @@ export function setupNativeWorkingDir(): void {
if (!process.env.PATH?.toLowerCase().includes(nativeDir.toLowerCase())) {
process.env.PATH = `${pathHead}${path.delimiter}${process.env.PATH || ''}`
}
log.debug(`Native DLL search path: ${nativeDir}; cwd kept at ${process.cwd()}`)
try {
process.chdir(execDir)
} catch (e) {
log.warn(`chdir to ${execDir} failed`, e)
}
log.debug(`Native DLL search path: ${nativeDir}; cwd=${process.cwd()}`)
}
+1 -2
View File
@@ -2,7 +2,7 @@ import { shell } from 'electron'
import fs from 'fs'
import path from 'path'
export function validateDesignAppPath(exePath: string): { ok: true } | { ok: false; message: string } {
function validateDesignAppPath(exePath: string): { ok: true } | { ok: false; message: string } {
const p = exePath.trim()
if (!p) {
return { ok: false, message: '请在 cardsoon.config.json 中配置 designAppPath' }
@@ -14,7 +14,6 @@ export function validateDesignAppPath(exePath: string): { ok: true } | { ok: fal
return { ok: true }
}
/** 由系统启动外部程序;空字符串表示成功,非空为失败原因 */
export async function openDesignApp(
exePath: string
): Promise<{ ok: true } | { ok: false; message: string }> {
+124 -55
View File
@@ -1,12 +1,20 @@
import { BrowserWindow } from 'electron'
import log from 'electron-log'
import { POLL_INTERVAL_MS } from '../constants'
import { POLL_INTERVAL_MS, CS_OK } from '../constants'
import {
USB_TASK_COMPLETED,
USB_TASK_FAILED,
clampUsbCopyProgress,
usbTaskStatusHint
} from '@shared/usb-copy-state'
import { mainAppState } from './app-state'
import { emitTrace } from '../utils/trace-bridge'
import { dllGetJobStateById, dllGetUsbCopyState } from './work-dll.service'
import { loadDllModule } from './dll-loader'
let jobTimer: ReturnType<typeof setInterval> | null = null
let usbTimer: ReturnType<typeof setInterval> | null = null
let cardTimer: ReturnType<typeof setInterval> | null = null
let usbPollGen = 0
let jobId = ''
let mainWindow: BrowserWindow | null = null
@@ -35,84 +43,145 @@ export function stopJobPoll(resetMode = false): void {
}
}
export function stopUsbPoll(): void {
export function stopUsbPoll(resetMode = false): void {
usbPollGen += 1
if (usbTimer) {
clearInterval(usbTimer)
usbTimer = null
}
if (resetMode && mainAppState.mode === 'usbCopying') {
mainAppState.mode = 'ready'
}
}
export function stopCardPositionPoll(): void {
if (cardTimer) {
clearInterval(cardTimer)
cardTimer = null
}
}
export function stopAllPolls(): void {
stopJobPoll(true)
stopUsbPoll()
stopUsbPoll(true)
stopCardPositionPoll()
}
export function startJobPoll(id: string): void {
stopJobPoll(false)
jobId = id
jobTimer = setInterval(() => {
try {
const r = dllGetJobStateById(jobId)
const failed = r.jobState === 4
const cancelled = r.jobState === 6
const finished = r.jobState === 100
const terminal = failed || cancelled
const tick = {
jobId,
queryErrorCode: r.queryErrorCode,
jobState: r.jobState,
progress: r.progress,
terminal,
failed,
cancelled,
finished
}
emitTrace('[poll] job:poll-tick', tick)
send('job:poll-tick', tick)
if (r.queryErrorCode !== 0) {
log.warn('GetJobStateById query failed', r.queryErrorCode)
stopJobPoll(true)
return
}
if (failed || cancelled) {
void (async () => {
try {
const dll = await loadDllModule()
const r = dll.dllGetJobStateById(jobId)
const failed = r.jobState === 4
const cancelled = r.jobState === 6
const finished = r.jobState === 100
const terminal = failed || cancelled
const tick = {
jobId,
queryErrorCode: r.queryErrorCode,
jobState: r.jobState,
progress: r.progress,
terminal,
failed,
cancelled,
finished
}
emitTrace('[poll] job:poll-tick', tick)
send('job:poll-tick', tick)
if (r.queryErrorCode !== 0) {
log.warn('GetJobStateById query failed', r.queryErrorCode)
return
}
if (failed || cancelled) {
stopJobPoll(true)
} else if (finished) {
stopJobPoll(false)
}
} catch (e) {
log.error('job poll error', e)
stopJobPoll(true)
}
} catch (e) {
log.error('job poll error', e)
stopJobPoll(true)
}
})()
}, POLL_INTERVAL_MS)
}
export function startUsbPoll(): void {
stopUsbPoll()
stopUsbPoll(false)
const gen = usbPollGen
void pollUsbOnce(gen).catch((e) => log.error('usb poll error', e))
usbTimer = setInterval(() => {
try {
const r = dllGetUsbCopyState()
const failed = r.taskStatus === 3
const success = r.taskStatus === 2
const terminal = failed || success
const tick = {
taskStatus: r.taskStatus,
progress: r.progress,
terminal,
failed,
success
}
emitTrace('[poll] usb:poll-tick', tick)
send('usb:poll-tick', tick)
if (terminal) {
stopUsbPoll()
mainAppState.mode = 'ready'
}
} catch (e) {
void pollUsbOnce(gen).catch((e) => {
log.error('usb poll error', e)
stopUsbPoll()
mainAppState.mode = 'ready'
}
stopUsbPoll(true)
})
}, POLL_INTERVAL_MS)
}
async function pollUsbOnce(gen: number): Promise<void> {
if (gen !== usbPollGen) return
const dll = await loadDllModule()
if (gen !== usbPollGen) return
const r = dll.dllGetUsbCopyState()
if (gen !== usbPollGen) return
const copyProgress = clampUsbCopyProgress(r.progress)
const failed = r.taskStatus === USB_TASK_FAILED
const success = r.taskStatus === USB_TASK_COMPLETED
const terminal = failed || success
let errorMessage = ''
if (failed) {
const errText = dll.dllGetPrinterErrorStr(-1)
errorMessage = errText || usbTaskStatusHint(USB_TASK_FAILED)
}
const tick = {
queryCode: r.queryCode,
taskStatus: r.taskStatus,
progress: copyProgress,
terminal,
failed,
success,
errorMessage
}
emitTrace('[poll] usb:poll-tick', tick)
if (gen !== usbPollGen) return
send('usb:poll-tick', tick)
if (r.queryCode !== CS_OK) {
log.warn('GetUsbCopyState query failed', r.queryCode)
return
}
if (terminal) {
stopUsbPoll(false)
}
}
export function startCardPositionPoll(): void {
stopCardPositionPoll()
cardTimer = setInterval(() => {
void (async () => {
try {
const dll = await loadDllModule()
if (!dll.isCardPositionApiAvailable()) return
const r = dll.dllGetPrinterCardPosition()
const tick = { queryCode: r.queryCode, position: r.position }
emitTrace('[poll] card:position-tick', tick)
send('card:position-tick', tick)
} catch (e) {
log.error('card position poll error', e)
}
})()
}, POLL_INTERVAL_MS)
}
export function getActiveJobId(): string {
return jobId
}
export function isJobPollActive(): boolean {
return jobTimer !== null
}
export function isUsbPollActive(): boolean {
return usbTimer !== null
}
+162 -18
View File
@@ -1,5 +1,6 @@
import path from 'path'
import koffi from 'koffi'
import log from 'electron-log'
import { CS_OK, JOB_ID_BUF_SIZE, LOG_FATAL_FLAG } from '../constants'
import { emitTrace, isTraceEnabled } from '../utils/trace-bridge'
import { getNativeDir } from './native-path'
@@ -22,6 +23,10 @@ let SAPI_Init: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterInfo: any = null
// 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
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterErrorStr: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_RestJobEx: any = null
@@ -37,8 +42,18 @@ let SAPI_GetUsbCopyState: any = null
let SAPI_PrinterResetprinter: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterMovetoreject: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterMovetousbreader: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterCardPosition: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_UploadFile: any = null
let hasRejectApi = false
let hasCardPositionApi = false
let hasCancelApi = false
let hasUploadApi = false
let hasPrinterInfoEx = false
let hasUsbReaderApi = false
let loggedCancelMissing = false
let loggedRejectMissing = false
@@ -63,6 +78,31 @@ function traceCall<T>(name: string, args: Record<string, unknown> | undefined, f
}
}
function readPrinterJsonFromOutPtr(len: number, outPtr: Buffer): { code: number; json?: Record<string, unknown> } {
if (len <= 0) return { code: len }
const ptr = koffi.decode(outPtr, 0, 'void *') as number
if (!ptr) return { code: len }
try {
const jsonStr = koffi.decode(ptr, 'char', len) as string
if (!jsonStr?.trim()) return { code: len }
try {
return { code: len, json: JSON.parse(jsonStr) as Record<string, unknown> }
} catch {
return { code: len }
}
} finally {
if (SAPI_FreePrinterInfo) {
try {
SAPI_FreePrinterInfo(ptr)
} catch (e) {
log.warn('SAPI_FreePrinterInfo', e)
}
} else {
koffi.free(ptr)
}
}
}
function loadLibrary(): void {
if (lib) return
const dllPath = path.join(getNativeDir(), 'workDll.dll')
@@ -79,6 +119,25 @@ function loadLibrary(): void {
SAPI_GetUsbCopyState = lib.func('int __stdcall SAPI_GetUsbCopyState(_Out_ int *, _Out_ int *)')
SAPI_PrinterResetprinter = lib.func('int __stdcall SAPI_PrinterResetprinter()')
try {
SAPI_GetPrinterInfoEx = lib.func('int __stdcall SAPI_GetPrinterInfoEx(_Out_ void **)')
SAPI_FreePrinterInfo = lib.func('void __stdcall SAPI_FreePrinterInfo(void *)')
hasPrinterInfoEx = true
} catch {
SAPI_GetPrinterInfoEx = null
SAPI_FreePrinterInfo = null
hasPrinterInfoEx = false
}
try {
SAPI_UploadFile = lib.func('int __stdcall SAPI_UploadFile(str, str, str)')
hasUploadApi = true
} catch {
SAPI_UploadFile = null
hasUploadApi = false
log.warn('SAPI_UploadFile not in workDll')
}
try {
SAPI_AdminJobCancel = lib.func('int __stdcall SAPI_AdminJobCancel(str)')
hasCancelApi = true
@@ -101,6 +160,33 @@ function loadLibrary(): void {
emitTrace('[dll] SAPI_PrinterMovetoreject not in workDll (optional)')
}
}
try {
SAPI_PrinterMovetousbreader = lib.func('int __stdcall SAPI_PrinterMovetousbreader()')
hasUsbReaderApi = true
} catch {
SAPI_PrinterMovetousbreader = null
hasUsbReaderApi = false
emitTrace('[dll] SAPI_PrinterMovetousbreader not in workDll (optional)')
}
try {
SAPI_GetPrinterCardPosition = lib.func('int __stdcall SAPI_GetPrinterCardPosition(_Out_ int *)')
hasCardPositionApi = true
} catch {
SAPI_GetPrinterCardPosition = null
hasCardPositionApi = false
emitTrace('[dll] SAPI_GetPrinterCardPosition not in workDll (optional)')
}
log.info('workDll loaded', {
upload: hasUploadApi,
printerInfoEx: hasPrinterInfoEx,
cancel: hasCancelApi,
reject: hasRejectApi,
usbReader: hasUsbReaderApi,
cardPosition: hasCardPositionApi
})
}
export function isRejectApiAvailable(): boolean {
@@ -113,6 +199,21 @@ export function isCancelApiAvailable(): boolean {
return hasCancelApi
}
export function isUploadApiAvailable(): boolean {
loadLibrary()
return hasUploadApi
}
export function isUsbReaderApiAvailable(): boolean {
loadLibrary()
return hasUsbReaderApi
}
export function isCardPositionApiAvailable(): boolean {
loadLibrary()
return hasCardPositionApi
}
export function dllInit(params: InitParams): number {
return traceCall(
'SAPI_Init',
@@ -142,29 +243,33 @@ export function dllInit(params: InitParams): number {
)
}
export function dllGetPrinterInfo(): { code: number; json?: Record<string, unknown> } {
return traceCall('SAPI_GetPrinterInfo', undefined, () => {
function dllGetPrinterInfoInternal(
apiName: 'SAPI_GetPrinterInfo' | 'SAPI_GetPrinterInfoEx',
fn: (outPtr: Buffer) => number
): { code: number; json?: Record<string, unknown> } {
return traceCall(apiName, undefined, () => {
loadLibrary()
const outPtr = koffi.alloc('void *', 8)
try {
const len = SAPI_GetPrinterInfo!(outPtr) as number
if (len <= 0) return { code: len }
const ptr = koffi.decode(outPtr, 0, 'void *') as number
if (!ptr) return { code: len }
const jsonStr = koffi.decode(ptr, 'char', len) as string
koffi.free(ptr)
if (!jsonStr?.trim()) return { code: len }
try {
return { code: len, json: JSON.parse(jsonStr) as Record<string, unknown> }
} catch {
return { code: len }
}
const len = fn(outPtr) as number
return readPrinterJsonFromOutPtr(len, outPtr)
} finally {
koffi.free(outPtr)
}
})
}
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))
}
export function dllGetPrinterErrorStr(errorNo = -1): string {
return traceCall('SAPI_GetPrinterErrorStr', { errorNo }, () => {
loadLibrary()
@@ -173,6 +278,18 @@ export function dllGetPrinterErrorStr(errorNo = -1): string {
})
}
export function dllUploadFile(userDir: string, fileName: string, fileText: string): number {
return traceCall(
'SAPI_UploadFile',
{ userDir, fileName, bytes: Buffer.byteLength(fileText ?? '', 'utf8') },
() => {
loadLibrary()
if (!SAPI_UploadFile) throw new Error('UPLOAD_API_UNAVAILABLE')
return SAPI_UploadFile(userDir, fileName, fileText) as number
}
)
}
export function dllRestJobEx(json: string): { code: number; jobId: string } {
return traceCall('SAPI_RestJobEx', { jsonBytes: Buffer.byteLength(json ?? '', 'utf8') }, () => {
loadLibrary()
@@ -216,13 +333,22 @@ export function dllCopyFromUsb(destFolder: string, cardOutput: number): number {
})
}
export function dllGetUsbCopyState(): { taskStatus: number; progress: number } {
export function dllGetUsbCopyState(): {
queryCode: number
taskStatus: number
/** copy_progress 0-100 */
progress: number
} {
return traceCall('SAPI_GetUsbCopyState', undefined, () => {
loadLibrary()
const taskStatus = [0]
const progress = [0]
SAPI_GetUsbCopyState!(taskStatus, progress)
return { taskStatus: taskStatus[0], progress: progress[0] }
const copyProgress = [0]
const queryCode = SAPI_GetUsbCopyState!(taskStatus, copyProgress) as number
return {
queryCode,
taskStatus: taskStatus[0],
progress: copyProgress[0]
}
})
}
@@ -233,6 +359,14 @@ export function dllPrinterReset(): number {
})
}
export function dllPrinterMoveToUsbReader(): number {
return traceCall('SAPI_PrinterMovetousbreader', undefined, () => {
loadLibrary()
if (!SAPI_PrinterMovetousbreader) throw new Error('USB_READER_API_UNAVAILABLE')
return SAPI_PrinterMovetousbreader() as number
})
}
export function dllPrinterReject(): number {
return traceCall('SAPI_PrinterMovetoreject', undefined, () => {
loadLibrary()
@@ -240,3 +374,13 @@ export function dllPrinterReject(): number {
return SAPI_PrinterMovetoreject() as number
})
}
export function dllGetPrinterCardPosition(): { queryCode: number; position: number } {
return traceCall('SAPI_GetPrinterCardPosition', undefined, () => {
loadLibrary()
if (!SAPI_GetPrinterCardPosition) throw new Error('CARD_POSITION_API_UNAVAILABLE')
const position = [0]
const queryCode = SAPI_GetPrinterCardPosition!(position) as number
return { queryCode, position: position[0] }
})
}