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

- 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
+48 -18
View File
@@ -4,14 +4,29 @@ import { join } from 'path'
app.commandLine.appendSwitch('disable-gpu-shader-disk-cache')
import log from 'electron-log'
if (app.isPackaged) {
app.disableHardwareAcceleration()
}
const gotSingleInstanceLock = app.requestSingleInstanceLock()
if (!gotSingleInstanceLock) {
app.quit()
}
import { suppressKnownDllStderr } from './utils/suppress-dll-stderr'
import { loadAppFileConfig } from './services/app-config'
import { migrateTraceConfig, setTraceWebContents } from './utils/trace-bridge'
import { setupNativeWorkingDir } from './services/native-path'
import { configStore } from './services/config-store'
suppressKnownDllStderr()
process.on('uncaughtException', (err) => {
log.error('uncaughtException', err)
dialog.showErrorBox('程序异常', err instanceof Error ? err.message : String(err))
})
import { registerIpcHandlers, handleBeforeQuit } from './ipc/register-handlers'
import { ensureDllInitialized } from './services/dll-bootstrap'
import { setPollMainWindow } from './services/poll-manager'
import { DESIGN_WIDTH, DESIGN_HEIGHT, contentHeightForWidth } from '@shared/viewport'
@@ -19,7 +34,13 @@ let mainWindow: BrowserWindow | null = null
const MIN_CONTENT_WIDTH = 960
/** 默认内容区:约 85% 工作区宽,高 720:360 */
function focusMainWindow(): void {
if (!mainWindow) return
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.show()
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))
@@ -71,7 +92,6 @@ function createWindow(): void {
}
})
// 内容区 720:360,与 useScale 一致
mainWindow.on('resize', () => {
if (!mainWindow) return
const [cw, ch] = mainWindow.getContentSize()
@@ -86,6 +106,14 @@ function createWindow(): void {
mainWindow = null
})
mainWindow.webContents.on('render-process-gone', (_event, details) => {
log.error('render-process-gone', details)
dialog.showErrorBox(
'界面进程异常退出',
`reason=${details.reason} exitCode=${details.exitCode}\n请查看 %APPDATA%\\cardsoon-machine\\logs\\main.log`
)
})
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
@@ -93,24 +121,26 @@ function createWindow(): void {
}
}
app.whenReady().then(() => {
try {
if (app.isPackaged) {
configStore.set('skipDllInit', false)
} else {
const withDll =
process.argv.includes('--with-dll') ||
process.argv.includes('--no-skip-dll-init')
configStore.set('skipDllInit', !withDll)
if (!withDll) {
log.info('skipDllInit enabled (dev default); use npm run dev:dll to load workDll')
}
}
if (gotSingleInstanceLock) {
app.on('second-instance', () => {
focusMainWindow()
})
}
app.whenReady().then(async () => {
if (!gotSingleInstanceLock) return
try {
migrateTraceConfig()
loadAppFileConfig()
setupNativeWorkingDir()
log.info('app startup', { packaged: app.isPackaged, execPath: process.execPath })
registerIpcHandlers()
try {
const r = await ensureDllInitialized()
if (r.warning) log.warn(r.warning)
} catch (e) {
log.error('startup DLL init failed', e)
}
createWindow()
if (!app.isPackaged) {
globalShortcut.register('CommandOrControl+Shift+I', () => {
+168 -106
View File
@@ -1,34 +1,19 @@
import { app, dialog, shell } from 'electron'
import { dialog, shell } from 'electron'
import fs from 'fs'
import log from 'electron-log'
import { CS_FAIL, CS_OK } from '../constants'
import { assertNotBusy, assertReady, mainAppState } from '../services/app-state'
import { configStore } from '../services/config-store'
import {
startJobPoll,
startUsbPoll,
stopAllPolls,
stopJobPoll,
stopUsbPoll,
getPollMainWindow
} from '../services/poll-manager'
import { startUsbPoll, startJobPoll, stopAllPolls, stopJobPoll, stopUsbPoll, stopCardPositionPoll, startCardPositionPoll, getPollMainWindow, isJobPollActive } from '../services/poll-manager'
import { parsePrinterInfoFromDll, type PrinterStatusSnapshot } from '@shared/printer-info'
import { cleanPathPattern, getDirectorySizeBytes } from '../utils/dir-size'
import { getDesignAppPath } from '../services/app-config'
import { openDesignApp } from '../services/open-design-app'
import { writeJobCsv, type JobCsvRow } from '../utils/job-csv'
import { parseSoonTemplate } from '../utils/parse-soon'
import {
dllAdminJobCancel,
dllCopyFromUsb,
dllGetPrinterErrorStr,
dllGetPrinterInfo,
dllInit,
dllPrinterReject,
dllPrinterReset,
dllRestJobEx,
isCancelApiAvailable,
isRejectApiAvailable
} from '../services/work-dll.service'
import { stageJobPayloadJson } from '../utils/stage-job-payload'
import { ensureDllInitialized, isDllInitAttempted } from '../services/dll-bootstrap'
import { loadDllModule } from '../services/dll-loader'
import { tracedHandle } from './traced-handler'
function ok<T>(data?: T) {
@@ -39,63 +24,49 @@ function fail(code: number, message: string) {
return { ok: false as const, code, message }
}
let dllInitAttempted = false
function summarizeStagedPayload(json: string): Record<string, unknown> {
try {
const p = JSON.parse(json) as Record<string, unknown>
return {
task_id: p.task_id,
has_copy_task: p.has_copy_task,
has_print_task: p.has_print_task,
path_file_count: Array.isArray(p.path_file) ? p.path_file.length : 0,
json_file: p.json_file,
udf_file: p.udf_file
}
} catch {
return { parseError: true }
}
}
function parseBool(v: unknown): boolean {
return v === true || v === 'true' || v === 1 || v === '1' || String(v).toLowerCase() === 'true'
}
export function registerIpcHandlers(): void {
tracedHandle('dll:init', (_e, params) => {
if (dllInitAttempted) {
return ok({
skipped: true,
printerReady: false,
warning: '已初始化,跳过重复 Init'
})
}
tracedHandle('dll:init', async (_e, params) => {
stopAllPolls()
try {
const sharedDir = params?.sharedDir || (configStore.get('sharedDir') as string)
fs.mkdirSync(sharedDir, { recursive: true })
const code = 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)
if (code === CS_OK) {
return ok({ code, printerReady: true })
}
return ok({
code,
printerReady: false,
warning: '打印机未连接或驱动未就绪,界面可浏览,接好设备后可在设置中重试 Init'
})
const r = await ensureDllInitialized(params)
return ok({ code: r.code, warning: r.warning })
} catch (err) {
mainAppState.initialized = false
return fail(CS_FAIL, String(err))
}
})
tracedHandle('dll:printer-info', () => {
tracedHandle('dll:printer-info', async () => {
try {
assertReady()
const r = dllGetPrinterInfo()
const dll = await loadDllModule()
const r = dll.dllGetPrinterInfo()
if (!r.json) {
const cached = configStore.get('lastPrinterStatus')
if (cached) {
return ok({
...cached,
fromCache: true,
liveError: r.code <= 0 ? '未连接打印机' : `GetPrinterInfo code=${r.code}`
liveError: r.code <= 0 ? '未连接打印机' : `GetPrinterInfoEx code=${r.code}`
})
}
return fail(0, '未连接打印机')
@@ -112,77 +83,139 @@ export function registerIpcHandlers(): void {
}
})
tracedHandle('dll:printer-reset', () => {
tracedHandle('dll:printer-reset', async () => {
try {
assertReady()
const code = dllPrinterReset()
const dll = await loadDllModule()
const code = dll.dllPrinterReset()
return code === CS_OK ? ok() : fail(code, '重置失败')
} catch (err) {
return fail(CS_FAIL, String(err))
}
})
tracedHandle('dll:printer-reject', () => {
tracedHandle('dll:printer-reject', async () => {
try {
assertReady()
if (!isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE')
const code = dllPrinterReject()
const dll = await loadDllModule()
if (!dll.isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE')
const code = dll.dllPrinterReject()
return code === CS_OK ? ok() : fail(code, '废卡失败')
} catch (err) {
return fail(CS_FAIL, String(err))
}
})
tracedHandle('dll:printer-error-str', (_e, errorNo?: number) => {
tracedHandle('dll:printer-error-str', async (_e, errorNo?: number) => {
if (!mainAppState.initialized) return ok({ text: '' })
try {
assertReady()
return ok({ text: dllGetPrinterErrorStr(errorNo ?? -1) })
const dll = await loadDllModule()
return ok({ text: dll.dllGetPrinterErrorStr(errorNo ?? -1) })
} catch (err) {
return fail(CS_FAIL, String(err))
log.warn('dll:printer-error-str', err)
return ok({ text: '' })
}
})
tracedHandle('dll:job-create', (_e, json: string) => {
tracedHandle('dll:job-create', async (_e, json: string, opts?: { resubmit?: boolean }) => {
try {
assertReady()
assertNotBusy()
const r = dllRestJobEx(json)
if (r.code !== CS_OK) return fail(r.code, 'RestJobEx 失败')
if (opts?.resubmit) {
if (mainAppState.mode !== 'distributing') {
return fail(CS_FAIL, '当前不在分发任务会话中')
}
stopCardPositionPoll()
} else {
assertNotBusy()
}
const dll = await loadDllModule()
const sharedDir = configStore.get('sharedDir') as string
let staged: { json: string; taskDir: string }
try {
staged = stageJobPayloadJson(json, sharedDir, dll)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return fail(CS_FAIL, msg)
}
log.info('RestJobEx staging', {
taskDir: staged.taskDir,
summary: summarizeStagedPayload(staged.json)
})
const r = dll.dllRestJobEx(staged.json)
if (r.code !== CS_OK) {
log.warn('RestJobEx rejected', { code: r.code, json: staged.json.slice(0, 800) })
const errText = dll.dllGetPrinterErrorStr(r.code)
let detail = errText ? `${errText} (code=${r.code})` : `RestJobEx 失败 (code=${r.code})`
if (r.code === -1 && !errText) {
detail +=
':请确认模板路径、变量 CSV(udf_file)及拷贝路径有效,且打印机/任务目录已就绪'
}
return fail(r.code, detail)
}
if (!r.jobId?.trim()) {
return fail(CS_FAIL, 'RestJobEx 未返回 jobId')
}
mainAppState.mode = 'distributing'
mainAppState.activeJobId = r.jobId
return ok({ jobId: r.jobId })
} catch (err) {
if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
return fail(CS_FAIL, String(err))
const msg = String(err)
if (msg.includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
if (msg.includes('NOT_INITIALIZED')) {
return fail(CS_FAIL, '系统未初始化,请重启应用')
}
return fail(CS_FAIL, msg)
}
})
tracedHandle('dll:job-cancel', (_e, jobId: string) => {
tracedHandle('dll:job-cancel', async (_e, jobId: string) => {
const id = String(jobId || mainAppState.activeJobId || '').trim()
stopJobPoll(true)
mainAppState.mode = 'ready'
mainAppState.activeJobId = ''
if (!id) return ok()
if (!mainAppState.initialized) return ok()
try {
assertReady()
const id = jobId || mainAppState.activeJobId
stopJobPoll(true)
let code = CS_OK
if (isCancelApiAvailable()) {
code = dllAdminJobCancel(id)
}
mainAppState.mode = 'ready'
mainAppState.activeJobId = ''
const dll = await loadDllModule()
if (!dll.isCancelApiAvailable()) return ok()
const code = dll.dllAdminJobCancel(id)
return code === CS_OK ? ok() : fail(code, '取消失败')
} catch (err) {
return fail(CS_FAIL, String(err))
log.warn('dll:job-cancel', err)
return ok()
}
})
tracedHandle('dll:usb-copy', (_e, req: { destFolder: string; cardOutput: number }) => {
tracedHandle('dll:usb-copy', async (_e, req: { destFolder: string; cardOutput: number; resubmit?: boolean }) => {
try {
assertReady()
assertNotBusy()
const code = dllCopyFromUsb(req.destFolder, req.cardOutput)
if (req.resubmit) {
if (mainAppState.mode !== 'usbCopying') {
return fail(CS_FAIL, '当前不在数据收集会话中')
}
stopCardPositionPoll()
} else {
assertNotBusy()
}
const destFolder = String(req.destFolder || '').trim()
if (!destFolder) return fail(CS_FAIL, '请先选择数据导入目录')
fs.mkdirSync(destFolder, { recursive: true })
const dll = await loadDllModule()
if (dll.isUsbReaderApiAvailable()) {
const moveCode = dll.dllPrinterMoveToUsbReader()
if (moveCode !== CS_OK) {
log.warn('MoveToUsbReader before copy', { moveCode })
}
}
const code = dll.dllCopyFromUsb(destFolder, req.cardOutput)
if (code !== CS_OK) {
return fail(code, '可能已有任务在执行')
const errText = dll.dllGetPrinterErrorStr(code)
return fail(code, errText || '启动 USB 收集失败')
}
mainAppState.mode = 'usbCopying'
startUsbPoll()
return ok()
} catch (err) {
if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
@@ -195,8 +228,8 @@ export function registerIpcHandlers(): void {
return ok()
})
tracedHandle('poll:job-stop', () => {
stopJobPoll(true)
tracedHandle('poll:job-stop', (_e, opts?: { resetMode?: boolean }) => {
stopJobPoll(opts?.resetMode !== false)
return ok()
})
@@ -205,9 +238,27 @@ export function registerIpcHandlers(): void {
return ok()
})
tracedHandle('poll:usb-stop', () => {
stopUsbPoll()
mainAppState.mode = 'ready'
tracedHandle('poll:usb-stop', (_e, opts?: { resetMode?: boolean }) => {
stopUsbPoll(opts?.resetMode !== false)
return ok()
})
tracedHandle('poll:card-position-start', async () => {
try {
assertReady()
const dll = await loadDllModule()
if (!dll.isCardPositionApiAvailable()) {
return fail(CS_FAIL, '当前 DLL 不支持卡位查询,无法自动续做')
}
startCardPositionPoll()
return ok()
} catch (err) {
return fail(CS_FAIL, String(err))
}
})
tracedHandle('poll:card-position-stop', () => {
stopCardPositionPoll()
return ok()
})
@@ -253,6 +304,19 @@ export function registerIpcHandlers(): void {
return ok({ items })
})
tracedHandle(
'fs:write-job-csv',
(_e, payload: { taskId: string; rows: JobCsvRow[] }) => {
try {
const sharedDir = configStore.get('sharedDir') as string
const csvPath = writeJobCsv(sharedDir, payload.taskId, payload.rows || [])
return ok({ path: csvPath })
} catch (err) {
return fail(CS_FAIL, err instanceof Error ? err.message : String(err))
}
}
)
tracedHandle('fs:parse-soon', (_e, filePath: string) => {
try {
const soonPath = String(filePath || '').trim()
@@ -272,26 +336,19 @@ export function registerIpcHandlers(): void {
templateDir: string
traceEnabled: boolean
lastPrinterStatus?: PrinterStatusSnapshot
skipDllInit?: boolean
dllInitialized: boolean
} = {
sharedDir: configStore.get('sharedDir'),
templateDir: configStore.get('templateDir'),
traceEnabled: configStore.get('traceEnabled', true),
lastPrinterStatus: configStore.get('lastPrinterStatus')
}
if (!app.isPackaged) {
payload.skipDllInit = configStore.get('skipDllInit', false)
lastPrinterStatus: configStore.get('lastPrinterStatus'),
dllInitialized: mainAppState.initialized
}
return ok(payload)
})
tracedHandle('config:set', (_e, patch: Record<string, unknown>) => {
Object.entries(patch).forEach(([k, v]) => {
if (k === 'skipDllInit') {
if (app.isPackaged) return
configStore.set(k, parseBool(v))
return
}
if (k === 'traceEnabled' || k === 'dllTraceEnabled') {
configStore.set('traceEnabled', parseBool(v))
return
@@ -314,19 +371,24 @@ export function registerIpcHandlers(): void {
return ok()
})
tracedHandle('dll:reject-available', () => ok({ available: isRejectApiAvailable() }))
tracedHandle('dll:reject-available', async () => {
const dll = await loadDllModule()
return ok({ available: dll.isRejectApiAvailable() })
})
}
export async function handleBeforeQuit(): Promise<void> {
const dll = isDllInitAttempted() ? await loadDllModule().catch(() => null) : null
const shouldCancel =
mainAppState.mode === 'distributing' &&
!!mainAppState.activeJobId &&
isCancelApiAvailable()
isJobPollActive() &&
!!dll?.isCancelApiAvailable()
const cancelJobId = mainAppState.activeJobId
stopAllPolls()
if (shouldCancel && cancelJobId) {
if (shouldCancel && cancelJobId && dll) {
try {
dllAdminJobCancel(cancelJobId)
dll.dllAdminJobCancel(cancelJobId)
} catch (e) {
log.warn('before-quit cancel', e)
}
-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] }
})
}
+21
View File
@@ -0,0 +1,21 @@
import fs from 'fs'
import path from 'path'
export interface JobCsvRow {
originName: string
value: string
}
export function buildSoonCsvText(rows: JobCsvRow[]): string {
const line1 = rows.map((r) => r.originName).join(',')
const line2 = rows.map((r) => r.value).join(',')
return `${line1}\n${line2}`
}
export function writeJobCsv(sharedDir: string, taskId: string, rows: JobCsvRow[]): string {
const dir = path.join(sharedDir, taskId)
fs.mkdirSync(dir, { recursive: true })
const csvPath = path.join(dir, 'temp.csv')
fs.writeFileSync(csvPath, buildSoonCsvText(rows), 'utf8')
return csvPath
}
+56 -6
View File
@@ -4,6 +4,7 @@ import { pathToFileURL } from 'url'
export interface TemplateFieldRow {
label: string
value: string
originName: string
}
export interface ParsedSoonTemplate {
@@ -12,6 +13,8 @@ export interface ParsedSoonTemplate {
fields: TemplateFieldRow[]
}
const SOON_FIELD_TYPES = new Set([1, 3, 4, 5])
function pickArray(obj: Record<string, unknown>, key: string): Record<string, unknown>[] {
const entry = Object.entries(obj).find(([k]) => k.toLowerCase() === key.toLowerCase())
if (!Array.isArray(entry?.[1])) return []
@@ -38,18 +41,53 @@ function sideLabel(side: 'front' | 'back'): string {
return side === 'front' ? '正面' : '背面'
}
function resolveAssetPath(soonPath: string, ref: string): string {
function toImageUrl(soonPath: string, ref: string): string {
if (!ref) return ''
if (/^(data:|https?:|file:)/i.test(ref)) return ref
const clean = ref.replace(/^file:\/\//i, '')
const abs = path.isAbsolute(clean) ? clean : path.join(path.dirname(soonPath), clean)
return pathToFileURL(abs).href
}
function toFieldLabel(name: string, side: 'front' | 'back'): string {
return `${name} [${sideLabel(side)}]`
function resolveAssetPath(soonPath: string, ref: string): string {
return toImageUrl(soonPath, ref)
}
export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
function toFieldLabel(name: string, side: 'front' | 'back'): string {
return `${name}[${sideLabel(side)}]`
}
function parseSoonWorkerDisk(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
const fields: TemplateFieldRow[] = []
const appendSide = (arr: unknown, side: '正面' | '背面') => {
if (!Array.isArray(arr)) return
for (const item of arr) {
if (!item || typeof item !== 'object') continue
const o = item as Record<string, unknown>
const type = Number(o.type)
if (!SOON_FIELD_TYPES.has(type)) continue
const name = String(o.name ?? '').trim()
if (!name) continue
const value = o.DefaultText == null ? '' : String(o.DefaultText)
fields.push({ label: `${name}[${side}]`, value, originName: name })
}
}
appendSide(raw.frontData, '正面')
appendSide(raw.backData, '背面')
const frontPic = String(raw.frontDisplayPic ?? '').trim()
const backPic = String(raw.backDisplayPic ?? '').trim()
return {
frontImageUrl: toImageUrl(soonPath, frontPic),
backImageUrl: toImageUrl(soonPath, backPic),
fields
}
}
function parseSoonLegacy(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
const imgs = pickArray(raw, 'Img')
const texts = pickArray(raw, 'Text')
@@ -66,7 +104,7 @@ export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>
if (side === 'front') {
if (!frontImageUrl) frontImageUrl = url
const name = pickStr(item, ['name', 'field', 'key']) || 'IMAGE'
fields.push({ label: toFieldLabel(name, 'front'), value: fileRef })
fields.push({ label: toFieldLabel(name, 'front'), value: fileRef, originName: name })
} else if (!backImageUrl) {
backImageUrl = url
}
@@ -78,8 +116,20 @@ export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>
const value = pickStr(item, ['value', 'text', 'default', 'content', 'data'])
let side = sideOf(item)
if (!side) side = /image|img|front/i.test(name) ? 'front' : 'back'
fields.push({ label: toFieldLabel(name, side), value })
fields.push({ label: toFieldLabel(name, side), value, originName: name })
})
return { frontImageUrl, backImageUrl, fields }
}
export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
if (
Array.isArray(raw.frontData) ||
Array.isArray(raw.backData) ||
raw.frontDisplayPic != null ||
raw.backDisplayPic != null
) {
return parseSoonWorkerDisk(soonPath, raw)
}
return parseSoonLegacy(soonPath, raw)
}
+163
View File
@@ -0,0 +1,163 @@
import fs from 'fs'
import path from 'path'
import log from 'electron-log'
import { CS_OK } from '../constants'
import { cleanPathPattern } from '@shared/path-pattern'
import { getDirectorySizeBytes } from './dir-size'
export type JobStageDll = {
dllUploadFile: (userDir: string, fileName: string, fileText: string) => number
isUploadApiAvailable: () => boolean
}
function copyIfExists(src: string, dest: string): void {
if (!fs.existsSync(src)) return
fs.mkdirSync(path.dirname(dest), { recursive: true })
fs.copyFileSync(src, dest)
}
function uploadText(
dll: JobStageDll,
userDir: string,
fileName: string,
text: string
): boolean {
if (!dll.isUploadApiAvailable()) return false
const code = dll.dllUploadFile(userDir, fileName, fileText)
if (code !== CS_OK) {
log.warn('SAPI_UploadFile failed', { userDir, fileName, code })
return false
}
return true
}
function stageSoonAssets(taskDir: string, soonSrc: string, soonDest: string): void {
copyIfExists(soonSrc, soonDest)
let raw: Record<string, unknown>
try {
raw = JSON.parse(fs.readFileSync(soonDest, 'utf8')) as Record<string, unknown>
} catch (e) {
log.warn('stageSoonAssets: parse soon failed', e)
return
}
const soonDir = path.dirname(soonSrc)
for (const key of ['frontDisplayPic', 'backDisplayPic']) {
const ref = raw[key]
if (typeof ref !== 'string' || !ref.trim()) continue
const clean = ref.replace(/^file:\/\//i, '').trim()
const assetSrc = path.isAbsolute(clean) ? clean : path.join(soonDir, clean)
const assetDest = path.join(taskDir, path.basename(clean))
copyIfExists(assetSrc, assetDest)
}
}
/** 保留目录级 path_file,不展开为单文件列表 */
function normalizeCopyPaths(payload: Record<string, unknown>): void {
if (!Array.isArray(payload.path_file)) {
throw new Error('拷贝任务缺少 path_file')
}
const normalized: string[] = []
for (const entry of payload.path_file) {
if (typeof entry !== 'string' || !entry.trim()) continue
const target = cleanPathPattern(entry)
if (!target || !fs.existsSync(target)) {
throw new Error(`拷贝路径不存在: ${entry}`)
}
let st: fs.Stats
try {
st = fs.statSync(target)
} catch {
throw new Error(`拷贝路径不可访问: ${target}`)
}
if (st.isFile()) {
normalized.push(target)
continue
}
if (!st.isDirectory()) {
throw new Error(`拷贝路径无效: ${target}`)
}
if (getDirectorySizeBytes(target) <= 0) {
throw new Error(`拷贝路径下没有可拷贝的文件: ${target}`)
}
normalized.push(target)
}
if (normalized.length === 0) {
throw new Error('拷贝路径下没有可拷贝的文件')
}
payload.path_file = normalized
}
/** staging 后按 has_* 裁剪字段,并校验 RestJobEx 必填项 */
function finalizeRestJobPayload(payload: Record<string, unknown>): void {
const hasCopy = payload.has_copy_task === true
const hasPrint = payload.has_print_task === true
if (!hasCopy && !hasPrint) {
throw new Error('任务需包含拷贝或打印')
}
if (hasPrint) {
if (typeof payload.json_file !== 'string' || !payload.json_file.trim()) {
throw new Error('打印任务缺少 json_file')
}
if (!payload.udf_file) delete payload.udf_file
} else {
delete payload.json_file
delete payload.udf_file
}
if (hasCopy) {
const paths = payload.path_file
if (
!Array.isArray(paths) ||
paths.length === 0 ||
!paths.every((p) => typeof p === 'string' && p.trim())
) {
throw new Error('拷贝任务缺少 path_file')
}
} else {
delete payload.path_file
}
}
/** 通过 SAPI_UploadFile + 本地落盘,准备 RestJobEx 所需路径 */
export function stageJobPayloadJson(
json: string,
sharedDir: string,
dll: JobStageDll
): { json: string; taskDir: string } {
const payload = JSON.parse(json) as Record<string, unknown>
const taskId = String(payload.task_id || '').trim()
if (!taskId) throw new Error('task_id 缺失')
const taskDir = path.join(sharedDir, taskId)
fs.mkdirSync(taskDir, { recursive: true })
const userDir = taskId
const udfFile = payload.udf_file
if (typeof udfFile === 'string' && udfFile.trim() && fs.existsSync(udfFile.trim())) {
const csvText = fs.readFileSync(udfFile.trim(), 'utf8')
if (!uploadText(dll, userDir, 'temp.csv', csvText)) {
copyIfExists(udfFile.trim(), path.join(taskDir, 'temp.csv'))
}
payload.udf_file = path.join(taskDir, 'temp.csv')
}
const jsonFile = payload.json_file
if (typeof jsonFile === 'string' && jsonFile.trim()) {
const src = jsonFile.trim()
const base = path.basename(src)
const dest = path.join(taskDir, base)
const soonText = fs.readFileSync(src, 'utf8')
uploadText(dll, userDir, base, soonText)
stageSoonAssets(taskDir, src, dest)
payload.json_file = dest
}
if (payload.has_copy_task === true) {
normalizeCopyPaths(payload)
}
finalizeRestJobPayload(payload)
return { json: JSON.stringify(payload), taskDir }
}