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

- 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
+1
View File
@@ -2,6 +2,7 @@ node_modules/
dist/ dist/
out/ out/
release/ release/
*.tsbuildinfo
*.log *.log
.env .env
.env.* .env.*
+4 -1
View File
@@ -9,7 +9,6 @@
}, },
"scripts": { "scripts": {
"dev": "electron-vite dev", "dev": "electron-vite dev",
"dev:dll": "electron-vite dev -- --with-dll",
"build": "electron-vite build", "build": "electron-vite build",
"preview": "electron-vite preview", "preview": "electron-vite preview",
"typecheck": "vue-tsc --noEmit -p tsconfig.web.json", "typecheck": "vue-tsc --noEmit -p tsconfig.web.json",
@@ -33,6 +32,10 @@
"build": { "build": {
"appId": "com.cardsoon.machine", "appId": "com.cardsoon.machine",
"productName": "卡树数据卡打印系统", "productName": "卡树数据卡打印系统",
"asar": true,
"asarUnpack": [
"**/node_modules/koffi/**"
],
"directories": { "directories": {
"output": "release" "output": "release"
}, },
@@ -1,3 +0,0 @@
{
"designAppPath": "C:\\myData\\projects\\sideline\\shanghaikashu\\SoonMachine\\app\\release\\卡树数据卡打印系统-0.0.1-win\\卡树数据卡打印系统.exe"
}
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"designAppPath": "C:\\myData\\projects\\sideline\\shanghaikashu\\SoonMachine\\app\\release\\卡树数据卡打印系统-0.0.1-win\\卡树数据卡打印系统.exe" "designAppPath": "D:\\SoonProject\\SoonDesign\\build\\win-unpacked\\SoonDesign.exe"
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5 -3
View File
@@ -9,13 +9,15 @@ const required = [
'dcrf32.dll', 'dcrf32.dll',
'Entry.dll', 'Entry.dll',
'libpng16.dll', 'libpng16.dll',
'zint.dll' 'zint.dll',
'freetype.dll',
'opencv_world490d.dll'
] ]
const missing = required.filter((name) => !fs.existsSync(path.join(nativeDir, name))) const missing = required.filter((name) => !fs.existsSync(path.join(nativeDir, name)))
if (missing.length) { if (missing.length) {
console.error(`resources/native 缺少: ${missing.join(', ')}`) console.error(`resources/native 缺少: ${missing.join(', ')}`)
console.error('请从 docs/API/lib 复制 7 个 dll(不含 .lib') console.error('请从 docs/API/lib 复制完整 native 依赖(不含 .lib')
process.exit(1) process.exit(1)
} }
console.log('resources/native: 7 dll 齐全') console.log(`resources/native: ${required.length} dll 齐全`)
+48 -18
View File
@@ -4,14 +4,29 @@ import { join } from 'path'
app.commandLine.appendSwitch('disable-gpu-shader-disk-cache') app.commandLine.appendSwitch('disable-gpu-shader-disk-cache')
import log from 'electron-log' 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 { suppressKnownDllStderr } from './utils/suppress-dll-stderr'
import { loadAppFileConfig } from './services/app-config' import { loadAppFileConfig } from './services/app-config'
import { migrateTraceConfig, setTraceWebContents } from './utils/trace-bridge' import { migrateTraceConfig, setTraceWebContents } from './utils/trace-bridge'
import { setupNativeWorkingDir } from './services/native-path'
import { configStore } from './services/config-store'
suppressKnownDllStderr() 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 { registerIpcHandlers, handleBeforeQuit } from './ipc/register-handlers'
import { ensureDllInitialized } from './services/dll-bootstrap'
import { setPollMainWindow } from './services/poll-manager' import { setPollMainWindow } from './services/poll-manager'
import { DESIGN_WIDTH, DESIGN_HEIGHT, contentHeightForWidth } from '@shared/viewport' import { DESIGN_WIDTH, DESIGN_HEIGHT, contentHeightForWidth } from '@shared/viewport'
@@ -19,7 +34,13 @@ let mainWindow: BrowserWindow | null = null
const MIN_CONTENT_WIDTH = 960 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 } { function getDefaultWindowSize(): { width: number; height: number } {
const { width: sw, height: sh } = screen.getPrimaryDisplay().workAreaSize const { width: sw, height: sh } = screen.getPrimaryDisplay().workAreaSize
let w = Math.max(1280, Math.min(Math.floor(sw * 0.85), 1600)) 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', () => { mainWindow.on('resize', () => {
if (!mainWindow) return if (!mainWindow) return
const [cw, ch] = mainWindow.getContentSize() const [cw, ch] = mainWindow.getContentSize()
@@ -86,6 +106,14 @@ function createWindow(): void {
mainWindow = null 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) { if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else { } else {
@@ -93,24 +121,26 @@ function createWindow(): void {
} }
} }
app.whenReady().then(() => { if (gotSingleInstanceLock) {
try { app.on('second-instance', () => {
if (app.isPackaged) { focusMainWindow()
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')
}
}
app.whenReady().then(async () => {
if (!gotSingleInstanceLock) return
try {
migrateTraceConfig() migrateTraceConfig()
loadAppFileConfig() loadAppFileConfig()
setupNativeWorkingDir() log.info('app startup', { packaged: app.isPackaged, execPath: process.execPath })
registerIpcHandlers() registerIpcHandlers()
try {
const r = await ensureDllInitialized()
if (r.warning) log.warn(r.warning)
} catch (e) {
log.error('startup DLL init failed', e)
}
createWindow() createWindow()
if (!app.isPackaged) { if (!app.isPackaged) {
globalShortcut.register('CommandOrControl+Shift+I', () => { globalShortcut.register('CommandOrControl+Shift+I', () => {
+164 -102
View File
@@ -1,34 +1,19 @@
import { app, dialog, shell } from 'electron' import { dialog, shell } from 'electron'
import fs from 'fs' import fs from 'fs'
import log from 'electron-log' import log from 'electron-log'
import { CS_FAIL, CS_OK } from '../constants' import { CS_FAIL, CS_OK } from '../constants'
import { assertNotBusy, assertReady, mainAppState } from '../services/app-state' import { assertNotBusy, assertReady, mainAppState } from '../services/app-state'
import { configStore } from '../services/config-store' import { configStore } from '../services/config-store'
import { import { startUsbPoll, startJobPoll, stopAllPolls, stopJobPoll, stopUsbPoll, stopCardPositionPoll, startCardPositionPoll, getPollMainWindow, isJobPollActive } from '../services/poll-manager'
startJobPoll,
startUsbPoll,
stopAllPolls,
stopJobPoll,
stopUsbPoll,
getPollMainWindow
} from '../services/poll-manager'
import { parsePrinterInfoFromDll, type PrinterStatusSnapshot } from '@shared/printer-info' import { parsePrinterInfoFromDll, type PrinterStatusSnapshot } from '@shared/printer-info'
import { cleanPathPattern, getDirectorySizeBytes } from '../utils/dir-size' import { cleanPathPattern, getDirectorySizeBytes } from '../utils/dir-size'
import { getDesignAppPath } from '../services/app-config' import { getDesignAppPath } from '../services/app-config'
import { openDesignApp } from '../services/open-design-app' import { openDesignApp } from '../services/open-design-app'
import { writeJobCsv, type JobCsvRow } from '../utils/job-csv'
import { parseSoonTemplate } from '../utils/parse-soon' import { parseSoonTemplate } from '../utils/parse-soon'
import { import { stageJobPayloadJson } from '../utils/stage-job-payload'
dllAdminJobCancel, import { ensureDllInitialized, isDllInitAttempted } from '../services/dll-bootstrap'
dllCopyFromUsb, import { loadDllModule } from '../services/dll-loader'
dllGetPrinterErrorStr,
dllGetPrinterInfo,
dllInit,
dllPrinterReject,
dllPrinterReset,
dllRestJobEx,
isCancelApiAvailable,
isRejectApiAvailable
} from '../services/work-dll.service'
import { tracedHandle } from './traced-handler' import { tracedHandle } from './traced-handler'
function ok<T>(data?: T) { function ok<T>(data?: T) {
@@ -39,63 +24,49 @@ function fail(code: number, message: string) {
return { ok: false as const, code, message } 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 { function parseBool(v: unknown): boolean {
return v === true || v === 'true' || v === 1 || v === '1' || String(v).toLowerCase() === 'true' return v === true || v === 'true' || v === 1 || v === '1' || String(v).toLowerCase() === 'true'
} }
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
tracedHandle('dll:init', (_e, params) => { tracedHandle('dll:init', async (_e, params) => {
if (dllInitAttempted) {
return ok({
skipped: true,
printerReady: false,
warning: '已初始化,跳过重复 Init'
})
}
stopAllPolls() stopAllPolls()
try { try {
const sharedDir = params?.sharedDir || (configStore.get('sharedDir') as string) const r = await ensureDllInitialized(params)
fs.mkdirSync(sharedDir, { recursive: true }) return ok({ code: r.code, warning: r.warning })
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'
})
} catch (err) { } catch (err) {
mainAppState.initialized = false
return fail(CS_FAIL, String(err)) return fail(CS_FAIL, String(err))
} }
}) })
tracedHandle('dll:printer-info', () => { tracedHandle('dll:printer-info', async () => {
try { try {
assertReady() assertReady()
const r = dllGetPrinterInfo() const dll = await loadDllModule()
const r = dll.dllGetPrinterInfo()
if (!r.json) { if (!r.json) {
const cached = configStore.get('lastPrinterStatus') const cached = configStore.get('lastPrinterStatus')
if (cached) { if (cached) {
return ok({ return ok({
...cached, ...cached,
fromCache: true, fromCache: true,
liveError: r.code <= 0 ? '未连接打印机' : `GetPrinterInfo code=${r.code}` liveError: r.code <= 0 ? '未连接打印机' : `GetPrinterInfoEx code=${r.code}`
}) })
} }
return fail(0, '未连接打印机') return fail(0, '未连接打印机')
@@ -112,77 +83,139 @@ export function registerIpcHandlers(): void {
} }
}) })
tracedHandle('dll:printer-reset', () => { tracedHandle('dll:printer-reset', async () => {
try { try {
assertReady() assertReady()
const code = dllPrinterReset() const dll = await loadDllModule()
const code = dll.dllPrinterReset()
return code === CS_OK ? ok() : fail(code, '重置失败') return code === CS_OK ? ok() : fail(code, '重置失败')
} catch (err) { } catch (err) {
return fail(CS_FAIL, String(err)) return fail(CS_FAIL, String(err))
} }
}) })
tracedHandle('dll:printer-reject', () => { tracedHandle('dll:printer-reject', async () => {
try { try {
assertReady() assertReady()
if (!isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE') const dll = await loadDllModule()
const code = dllPrinterReject() if (!dll.isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE')
const code = dll.dllPrinterReject()
return code === CS_OK ? ok() : fail(code, '废卡失败') return code === CS_OK ? ok() : fail(code, '废卡失败')
} catch (err) { } catch (err) {
return fail(CS_FAIL, String(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 { try {
assertReady() const dll = await loadDllModule()
return ok({ text: dllGetPrinterErrorStr(errorNo ?? -1) }) return ok({ text: dll.dllGetPrinterErrorStr(errorNo ?? -1) })
} catch (err) { } 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 { try {
assertReady() assertReady()
if (opts?.resubmit) {
if (mainAppState.mode !== 'distributing') {
return fail(CS_FAIL, '当前不在分发任务会话中')
}
stopCardPositionPoll()
} else {
assertNotBusy() assertNotBusy()
const r = dllRestJobEx(json) }
if (r.code !== CS_OK) return fail(r.code, 'RestJobEx 失败') 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.mode = 'distributing'
mainAppState.activeJobId = r.jobId mainAppState.activeJobId = r.jobId
return ok({ jobId: r.jobId }) return ok({ jobId: r.jobId })
} catch (err) { } catch (err) {
if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行') const msg = String(err)
return fail(CS_FAIL, 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) => {
try { const id = String(jobId || mainAppState.activeJobId || '').trim()
assertReady()
const id = jobId || mainAppState.activeJobId
stopJobPoll(true) stopJobPoll(true)
let code = CS_OK
if (isCancelApiAvailable()) {
code = dllAdminJobCancel(id)
}
mainAppState.mode = 'ready' mainAppState.mode = 'ready'
mainAppState.activeJobId = '' mainAppState.activeJobId = ''
if (!id) return ok()
if (!mainAppState.initialized) return ok()
try {
const dll = await loadDllModule()
if (!dll.isCancelApiAvailable()) return ok()
const code = dll.dllAdminJobCancel(id)
return code === CS_OK ? ok() : fail(code, '取消失败') return code === CS_OK ? ok() : fail(code, '取消失败')
} catch (err) { } 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 { try {
assertReady() assertReady()
if (req.resubmit) {
if (mainAppState.mode !== 'usbCopying') {
return fail(CS_FAIL, '当前不在数据收集会话中')
}
stopCardPositionPoll()
} else {
assertNotBusy() assertNotBusy()
const code = dllCopyFromUsb(req.destFolder, req.cardOutput) }
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) { if (code !== CS_OK) {
return fail(code, '可能已有任务在执行') const errText = dll.dllGetPrinterErrorStr(code)
return fail(code, errText || '启动 USB 收集失败')
} }
mainAppState.mode = 'usbCopying' mainAppState.mode = 'usbCopying'
startUsbPoll()
return ok() return ok()
} catch (err) { } catch (err) {
if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行') if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
@@ -195,8 +228,8 @@ export function registerIpcHandlers(): void {
return ok() return ok()
}) })
tracedHandle('poll:job-stop', () => { tracedHandle('poll:job-stop', (_e, opts?: { resetMode?: boolean }) => {
stopJobPoll(true) stopJobPoll(opts?.resetMode !== false)
return ok() return ok()
}) })
@@ -205,9 +238,27 @@ export function registerIpcHandlers(): void {
return ok() return ok()
}) })
tracedHandle('poll:usb-stop', () => { tracedHandle('poll:usb-stop', (_e, opts?: { resetMode?: boolean }) => {
stopUsbPoll() stopUsbPoll(opts?.resetMode !== false)
mainAppState.mode = 'ready' 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() return ok()
}) })
@@ -253,6 +304,19 @@ export function registerIpcHandlers(): void {
return ok({ items }) 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) => { tracedHandle('fs:parse-soon', (_e, filePath: string) => {
try { try {
const soonPath = String(filePath || '').trim() const soonPath = String(filePath || '').trim()
@@ -272,26 +336,19 @@ export function registerIpcHandlers(): void {
templateDir: string templateDir: string
traceEnabled: boolean traceEnabled: boolean
lastPrinterStatus?: PrinterStatusSnapshot lastPrinterStatus?: PrinterStatusSnapshot
skipDllInit?: boolean dllInitialized: boolean
} = { } = {
sharedDir: configStore.get('sharedDir'), sharedDir: configStore.get('sharedDir'),
templateDir: configStore.get('templateDir'), templateDir: configStore.get('templateDir'),
traceEnabled: configStore.get('traceEnabled', true), traceEnabled: configStore.get('traceEnabled', true),
lastPrinterStatus: configStore.get('lastPrinterStatus') lastPrinterStatus: configStore.get('lastPrinterStatus'),
} dllInitialized: mainAppState.initialized
if (!app.isPackaged) {
payload.skipDllInit = configStore.get('skipDllInit', false)
} }
return ok(payload) return ok(payload)
}) })
tracedHandle('config:set', (_e, patch: Record<string, unknown>) => { tracedHandle('config:set', (_e, patch: Record<string, unknown>) => {
Object.entries(patch).forEach(([k, v]) => { Object.entries(patch).forEach(([k, v]) => {
if (k === 'skipDllInit') {
if (app.isPackaged) return
configStore.set(k, parseBool(v))
return
}
if (k === 'traceEnabled' || k === 'dllTraceEnabled') { if (k === 'traceEnabled' || k === 'dllTraceEnabled') {
configStore.set('traceEnabled', parseBool(v)) configStore.set('traceEnabled', parseBool(v))
return return
@@ -314,19 +371,24 @@ export function registerIpcHandlers(): void {
return ok() 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> { export async function handleBeforeQuit(): Promise<void> {
const dll = isDllInitAttempted() ? await loadDllModule().catch(() => null) : null
const shouldCancel = const shouldCancel =
mainAppState.mode === 'distributing' && mainAppState.mode === 'distributing' &&
!!mainAppState.activeJobId && !!mainAppState.activeJobId &&
isCancelApiAvailable() isJobPollActive() &&
!!dll?.isCancelApiAvailable()
const cancelJobId = mainAppState.activeJobId const cancelJobId = mainAppState.activeJobId
stopAllPolls() stopAllPolls()
if (shouldCancel && cancelJobId) { if (shouldCancel && cancelJobId && dll) {
try { try {
dllAdminJobCancel(cancelJobId) dll.dllAdminJobCancel(cancelJobId)
} catch (e) { } catch (e) {
log.warn('before-quit cancel', 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' export const APP_CONFIG_FILENAME = 'cardsoon.config.json'
/** 与 cardsoon.config.json 键名一致,后续配置在此扩展 */
export interface AppFileConfig { export interface AppFileConfig {
designAppPath: string designAppPath: string
} }
@@ -16,7 +15,6 @@ const defaults: AppFileConfig = {
} }
let cached: AppFileConfig | null = null let cached: AppFileConfig | null = null
let loadedFrom = ''
function bundledConfigPath(): string { function bundledConfigPath(): string {
if (app.isPackaged) { if (app.isPackaged) {
@@ -46,7 +44,6 @@ export function loadAppFileConfig(): AppFileConfig {
if (!fs.existsSync(filePath)) continue if (!fs.existsSync(filePath)) continue
try { try {
cached = parseConfigFile(filePath) cached = parseConfigFile(filePath)
loadedFrom = filePath
log.info(`Loaded ${APP_CONFIG_FILENAME} from ${filePath}`) log.info(`Loaded ${APP_CONFIG_FILENAME} from ${filePath}`)
return cached return cached
} catch (e) { } catch (e) {
@@ -55,18 +52,12 @@ export function loadAppFileConfig(): AppFileConfig {
} }
cached = { ...defaults } cached = { ...defaults }
loadedFrom = ''
log.warn( log.warn(
`${APP_CONFIG_FILENAME} not found (checked: ${configSearchPaths().join(', ')}), using defaults` `${APP_CONFIG_FILENAME} not found (checked: ${configSearchPaths().join(', ')}), using defaults`
) )
return cached return cached
} }
export function getAppConfigLoadedPath(): string {
loadAppFileConfig()
return loadedFrom
}
export function getDesignAppPath(): string { export function getDesignAppPath(): string {
return loadAppFileConfig().designAppPath return loadAppFileConfig().designAppPath
} }
+1 -9
View File
@@ -6,23 +6,15 @@ import type { PrinterStatusSnapshot } from '@shared/printer-info'
interface AppConfig { interface AppConfig {
sharedDir: string sharedDir: string
templateDir: string templateDir: string
/** G2 门禁 false:启动即 SAPI_Init;仅调试可改 true */
skipDllInit: boolean
/** trueIPC/DLL 等调用输出到 DevTools 控制台 */
traceEnabled: boolean traceEnabled: boolean
/** 上次成功的 GetPrinterInfo 解析结果,供离线/失败时展示 */
lastPrinterStatus?: PrinterStatusSnapshot lastPrinterStatus?: PrinterStatusSnapshot
} }
const defaultShared = path.join('C:', 'PrintTasks')
export const configStore = new Store<AppConfig>({ export const configStore = new Store<AppConfig>({
name: 'cardsoon-config', name: 'cardsoon-config',
defaults: { defaults: {
sharedDir: defaultShared, sharedDir: path.join('C:', 'PrintTasks'),
templateDir: path.join(app.getPath('userData'), 'Cardsoon', 'templates'), templateDir: path.join(app.getPath('userData'), 'Cardsoon', 'templates'),
// 正式版始终 Init;仅开发时可通过 --skip-dll-init 临时跳过
skipDllInit: false,
traceEnabled: true 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())) { if (!process.env.PATH?.toLowerCase().includes(nativeDir.toLowerCase())) {
process.env.PATH = `${pathHead}${path.delimiter}${process.env.PATH || ''}` 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 fs from 'fs'
import path from 'path' 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() const p = exePath.trim()
if (!p) { if (!p) {
return { ok: false, message: '请在 cardsoon.config.json 中配置 designAppPath' } return { ok: false, message: '请在 cardsoon.config.json 中配置 designAppPath' }
@@ -14,7 +14,6 @@ export function validateDesignAppPath(exePath: string): { ok: true } | { ok: fal
return { ok: true } return { ok: true }
} }
/** 由系统启动外部程序;空字符串表示成功,非空为失败原因 */
export async function openDesignApp( export async function openDesignApp(
exePath: string exePath: string
): Promise<{ ok: true } | { ok: false; message: string }> { ): Promise<{ ok: true } | { ok: false; message: string }> {
+87 -18
View File
@@ -1,12 +1,20 @@
import { BrowserWindow } from 'electron' import { BrowserWindow } from 'electron'
import log from 'electron-log' 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 { mainAppState } from './app-state'
import { emitTrace } from '../utils/trace-bridge' 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 jobTimer: ReturnType<typeof setInterval> | null = null
let usbTimer: 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 jobId = ''
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
@@ -35,24 +43,38 @@ export function stopJobPoll(resetMode = false): void {
} }
} }
export function stopUsbPoll(): void { export function stopUsbPoll(resetMode = false): void {
usbPollGen += 1
if (usbTimer) { if (usbTimer) {
clearInterval(usbTimer) clearInterval(usbTimer)
usbTimer = null usbTimer = null
} }
if (resetMode && mainAppState.mode === 'usbCopying') {
mainAppState.mode = 'ready'
}
}
export function stopCardPositionPoll(): void {
if (cardTimer) {
clearInterval(cardTimer)
cardTimer = null
}
} }
export function stopAllPolls(): void { export function stopAllPolls(): void {
stopJobPoll(true) stopJobPoll(true)
stopUsbPoll() stopUsbPoll(true)
stopCardPositionPoll()
} }
export function startJobPoll(id: string): void { export function startJobPoll(id: string): void {
stopJobPoll(false) stopJobPoll(false)
jobId = id jobId = id
jobTimer = setInterval(() => { jobTimer = setInterval(() => {
void (async () => {
try { try {
const r = dllGetJobStateById(jobId) const dll = await loadDllModule()
const r = dll.dllGetJobStateById(jobId)
const failed = r.jobState === 4 const failed = r.jobState === 4
const cancelled = r.jobState === 6 const cancelled = r.jobState === 6
const finished = r.jobState === 100 const finished = r.jobState === 100
@@ -71,48 +93,95 @@ export function startJobPoll(id: string): void {
send('job:poll-tick', tick) send('job:poll-tick', tick)
if (r.queryErrorCode !== 0) { if (r.queryErrorCode !== 0) {
log.warn('GetJobStateById query failed', r.queryErrorCode) log.warn('GetJobStateById query failed', r.queryErrorCode)
stopJobPoll(true)
return return
} }
if (failed || cancelled) { if (failed || cancelled) {
stopJobPoll(true) stopJobPoll(true)
} else if (finished) {
stopJobPoll(false)
} }
} catch (e) { } catch (e) {
log.error('job poll error', e) log.error('job poll error', e)
stopJobPoll(true) stopJobPoll(true)
} }
})()
}, POLL_INTERVAL_MS) }, POLL_INTERVAL_MS)
} }
export function startUsbPoll(): void { export function startUsbPoll(): void {
stopUsbPoll() stopUsbPoll(false)
const gen = usbPollGen
void pollUsbOnce(gen).catch((e) => log.error('usb poll error', e))
usbTimer = setInterval(() => { usbTimer = setInterval(() => {
try { void pollUsbOnce(gen).catch((e) => {
const r = dllGetUsbCopyState() log.error('usb poll error', e)
const failed = r.taskStatus === 3 stopUsbPoll(true)
const success = r.taskStatus === 2 })
}, 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 const terminal = failed || success
let errorMessage = ''
if (failed) {
const errText = dll.dllGetPrinterErrorStr(-1)
errorMessage = errText || usbTaskStatusHint(USB_TASK_FAILED)
}
const tick = { const tick = {
queryCode: r.queryCode,
taskStatus: r.taskStatus, taskStatus: r.taskStatus,
progress: r.progress, progress: copyProgress,
terminal, terminal,
failed, failed,
success success,
errorMessage
} }
emitTrace('[poll] usb:poll-tick', tick) emitTrace('[poll] usb:poll-tick', tick)
if (gen !== usbPollGen) return
send('usb:poll-tick', tick) send('usb:poll-tick', tick)
if (r.queryCode !== CS_OK) {
log.warn('GetUsbCopyState query failed', r.queryCode)
return
}
if (terminal) { if (terminal) {
stopUsbPoll() stopUsbPoll(false)
mainAppState.mode = 'ready'
} }
}
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) { } catch (e) {
log.error('usb poll error', e) log.error('card position poll error', e)
stopUsbPoll()
mainAppState.mode = 'ready'
} }
})()
}, POLL_INTERVAL_MS) }, POLL_INTERVAL_MS)
} }
export function getActiveJobId(): string { export function getActiveJobId(): string {
return jobId 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 path from 'path'
import koffi from 'koffi' import koffi from 'koffi'
import log from 'electron-log'
import { CS_OK, JOB_ID_BUF_SIZE, LOG_FATAL_FLAG } from '../constants' import { CS_OK, JOB_ID_BUF_SIZE, LOG_FATAL_FLAG } from '../constants'
import { emitTrace, isTraceEnabled } from '../utils/trace-bridge' import { emitTrace, isTraceEnabled } from '../utils/trace-bridge'
import { getNativeDir } from './native-path' import { getNativeDir } from './native-path'
@@ -22,6 +23,10 @@ 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
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterErrorStr: any = null let SAPI_GetPrinterErrorStr: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_RestJobEx: any = null let SAPI_RestJobEx: any = null
@@ -37,8 +42,18 @@ let SAPI_GetUsbCopyState: any = null
let SAPI_PrinterResetprinter: any = null let SAPI_PrinterResetprinter: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterMovetoreject: any = null 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 hasRejectApi = false
let hasCardPositionApi = false
let hasCancelApi = false let hasCancelApi = false
let hasUploadApi = false
let hasPrinterInfoEx = false
let hasUsbReaderApi = false
let loggedCancelMissing = false let loggedCancelMissing = false
let loggedRejectMissing = 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 { function loadLibrary(): void {
if (lib) return if (lib) return
const dllPath = path.join(getNativeDir(), 'workDll.dll') 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_GetUsbCopyState = lib.func('int __stdcall SAPI_GetUsbCopyState(_Out_ int *, _Out_ int *)')
SAPI_PrinterResetprinter = lib.func('int __stdcall SAPI_PrinterResetprinter()') 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 { try {
SAPI_AdminJobCancel = lib.func('int __stdcall SAPI_AdminJobCancel(str)') SAPI_AdminJobCancel = lib.func('int __stdcall SAPI_AdminJobCancel(str)')
hasCancelApi = true hasCancelApi = true
@@ -101,6 +160,33 @@ function loadLibrary(): void {
emitTrace('[dll] SAPI_PrinterMovetoreject not in workDll (optional)') 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 { export function isRejectApiAvailable(): boolean {
@@ -113,6 +199,21 @@ export function isCancelApiAvailable(): boolean {
return hasCancelApi 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 { export function dllInit(params: InitParams): number {
return traceCall( return traceCall(
'SAPI_Init', 'SAPI_Init',
@@ -142,29 +243,33 @@ export function dllInit(params: InitParams): number {
) )
} }
export function dllGetPrinterInfo(): { code: number; json?: Record<string, unknown> } { function dllGetPrinterInfoInternal(
return traceCall('SAPI_GetPrinterInfo', undefined, () => { apiName: 'SAPI_GetPrinterInfo' | 'SAPI_GetPrinterInfoEx',
fn: (outPtr: Buffer) => number
): { code: number; json?: Record<string, unknown> } {
return traceCall(apiName, undefined, () => {
loadLibrary() loadLibrary()
const outPtr = koffi.alloc('void *', 8) const outPtr = koffi.alloc('void *', 8)
try { try {
const len = SAPI_GetPrinterInfo!(outPtr) as number const len = fn(outPtr) as number
if (len <= 0) return { code: len } return readPrinterJsonFromOutPtr(len, outPtr)
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 }
}
} finally { } finally {
koffi.free(outPtr) 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 { export function dllGetPrinterErrorStr(errorNo = -1): string {
return traceCall('SAPI_GetPrinterErrorStr', { errorNo }, () => { return traceCall('SAPI_GetPrinterErrorStr', { errorNo }, () => {
loadLibrary() 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 } { export function dllRestJobEx(json: string): { code: number; jobId: string } {
return traceCall('SAPI_RestJobEx', { jsonBytes: Buffer.byteLength(json ?? '', 'utf8') }, () => { return traceCall('SAPI_RestJobEx', { jsonBytes: Buffer.byteLength(json ?? '', 'utf8') }, () => {
loadLibrary() 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, () => { return traceCall('SAPI_GetUsbCopyState', undefined, () => {
loadLibrary() loadLibrary()
const taskStatus = [0] const taskStatus = [0]
const progress = [0] const copyProgress = [0]
SAPI_GetUsbCopyState!(taskStatus, progress) const queryCode = SAPI_GetUsbCopyState!(taskStatus, copyProgress) as number
return { taskStatus: taskStatus[0], progress: progress[0] } 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 { export function dllPrinterReject(): number {
return traceCall('SAPI_PrinterMovetoreject', undefined, () => { return traceCall('SAPI_PrinterMovetoreject', undefined, () => {
loadLibrary() loadLibrary()
@@ -240,3 +374,13 @@ export function dllPrinterReject(): number {
return SAPI_PrinterMovetoreject() as 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 { export interface TemplateFieldRow {
label: string label: string
value: string value: string
originName: string
} }
export interface ParsedSoonTemplate { export interface ParsedSoonTemplate {
@@ -12,6 +13,8 @@ export interface ParsedSoonTemplate {
fields: TemplateFieldRow[] fields: TemplateFieldRow[]
} }
const SOON_FIELD_TYPES = new Set([1, 3, 4, 5])
function pickArray(obj: Record<string, unknown>, key: string): Record<string, unknown>[] { function pickArray(obj: Record<string, unknown>, key: string): Record<string, unknown>[] {
const entry = Object.entries(obj).find(([k]) => k.toLowerCase() === key.toLowerCase()) const entry = Object.entries(obj).find(([k]) => k.toLowerCase() === key.toLowerCase())
if (!Array.isArray(entry?.[1])) return [] if (!Array.isArray(entry?.[1])) return []
@@ -38,18 +41,53 @@ function sideLabel(side: 'front' | 'back'): string {
return side === 'front' ? '正面' : '背面' return side === 'front' ? '正面' : '背面'
} }
function resolveAssetPath(soonPath: string, ref: string): string { function toImageUrl(soonPath: string, ref: string): string {
if (!ref) return '' if (!ref) return ''
if (/^(data:|https?:|file:)/i.test(ref)) return ref
const clean = ref.replace(/^file:\/\//i, '') const clean = ref.replace(/^file:\/\//i, '')
const abs = path.isAbsolute(clean) ? clean : path.join(path.dirname(soonPath), clean) const abs = path.isAbsolute(clean) ? clean : path.join(path.dirname(soonPath), clean)
return pathToFileURL(abs).href return pathToFileURL(abs).href
} }
function toFieldLabel(name: string, side: 'front' | 'back'): string { function resolveAssetPath(soonPath: string, ref: string): string {
return `${name} [${sideLabel(side)}]` 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 imgs = pickArray(raw, 'Img')
const texts = pickArray(raw, 'Text') const texts = pickArray(raw, 'Text')
@@ -66,7 +104,7 @@ export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>
if (side === 'front') { if (side === 'front') {
if (!frontImageUrl) frontImageUrl = url if (!frontImageUrl) frontImageUrl = url
const name = pickStr(item, ['name', 'field', 'key']) || 'IMAGE' 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) { } else if (!backImageUrl) {
backImageUrl = url backImageUrl = url
} }
@@ -78,8 +116,20 @@ export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>
const value = pickStr(item, ['value', 'text', 'default', 'content', 'data']) const value = pickStr(item, ['value', 'text', 'default', 'content', 'data'])
let side = sideOf(item) let side = sideOf(item)
if (!side) side = /image|img|front/i.test(name) ? 'front' : 'back' 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 } 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 }
}
+4 -1
View File
@@ -14,18 +14,21 @@ const channels = {
'poll:job-stop', 'poll:job-stop',
'poll:usb-start', 'poll:usb-start',
'poll:usb-stop', 'poll:usb-stop',
'poll:card-position-start',
'poll:card-position-stop',
'dialog:open-directory', 'dialog:open-directory',
'dialog:open-file', 'dialog:open-file',
'fs:path-exists', 'fs:path-exists',
'fs:dir-size', 'fs:dir-size',
'fs:parse-soon', 'fs:parse-soon',
'fs:write-job-csv',
'config:get', 'config:get',
'config:set', 'config:set',
'shell:open-path', 'shell:open-path',
'design:open', 'design:open',
'dll:reject-available' 'dll:reject-available'
] as const, ] as const,
on: ['job:poll-tick', 'usb:poll-tick', 'app:trace'] as const on: ['job:poll-tick', 'usb:poll-tick', 'card:position-tick', 'app:trace'] as const
} }
const cardsoonApi = { const cardsoonApi = {
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: file: blob:"
/> />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>卡树数据卡打印系统</title> <title>卡树数据卡打印系统</title>
+45 -15
View File
@@ -1,5 +1,5 @@
import { parsePrinterInfoFromDll } from '@shared/printer-info' import { parsePrinterInfoFromDll } from '@shared/printer-info'
import type { InitParamsDTO, IpcResult, JobPollPayload, UsbPollPayload } from '@/types/ipc' import type { InitParamsDTO, IpcResult, JobPollPayload, UsbPollPayload, CardPositionPollPayload } from '@/types/ipc'
import type { PrinterStatusDisplay } from '@/types/printer' import type { PrinterStatusDisplay } from '@/types/printer'
function api() { function api() {
@@ -34,32 +34,43 @@ export async function dllPrinterErrorStr(errorNo = -1): Promise<IpcResult<{ text
return api().invoke('dll:printer-error-str', errorNo) as Promise<IpcResult<{ text: string }>> return api().invoke('dll:printer-error-str', errorNo) as Promise<IpcResult<{ text: string }>>
} }
export async function dllJobCreate(json: string): Promise<IpcResult<{ jobId: string }>> { export async function dllJobCreate(
return api().invoke('dll:job-create', json) as Promise<IpcResult<{ jobId: string }>> json: string,
opts?: { resubmit?: boolean }
): Promise<IpcResult<{ jobId: string }>> {
return api().invoke('dll:job-create', json, opts) as Promise<IpcResult<{ jobId: string }>>
} }
export async function dllJobCancel(jobId: string): Promise<IpcResult> { export async function dllJobCancel(jobId: string): Promise<IpcResult> {
return api().invoke('dll:job-cancel', jobId) as Promise<IpcResult> return api().invoke('dll:job-cancel', jobId) as Promise<IpcResult>
} }
export async function dllUsbCopy(destFolder: string, cardOutput: number): Promise<IpcResult> { export async function dllUsbCopy(
return api().invoke('dll:usb-copy', { destFolder, cardOutput }) as Promise<IpcResult> destFolder: string,
cardOutput: number,
opts?: { resubmit?: boolean }
): Promise<IpcResult> {
return api().invoke('dll:usb-copy', { destFolder, cardOutput, resubmit: opts?.resubmit }) as Promise<IpcResult>
} }
export async function pollJobStart(jobId: string): Promise<IpcResult> { export async function pollJobStart(jobId: string): Promise<IpcResult> {
return api().invoke('poll:job-start', jobId) as Promise<IpcResult> return api().invoke('poll:job-start', jobId) as Promise<IpcResult>
} }
export async function pollJobStop(): Promise<IpcResult> { export async function pollJobStop(opts?: { resetMode?: boolean }): Promise<IpcResult> {
return api().invoke('poll:job-stop') as Promise<IpcResult> return api().invoke('poll:job-stop', opts) as Promise<IpcResult>
} }
export async function pollUsbStart(): Promise<IpcResult> { export async function pollUsbStop(opts?: { resetMode?: boolean }): Promise<IpcResult> {
return api().invoke('poll:usb-start') as Promise<IpcResult> return api().invoke('poll:usb-stop', opts) as Promise<IpcResult>
} }
export async function pollUsbStop(): Promise<IpcResult> { export async function pollCardPositionStart(): Promise<IpcResult> {
return api().invoke('poll:usb-stop') as Promise<IpcResult> return api().invoke('poll:card-position-start') as Promise<IpcResult>
}
export async function pollCardPositionStop(): Promise<IpcResult> {
return api().invoke('poll:card-position-stop') as Promise<IpcResult>
} }
export function onJobPollTick(cb: (p: JobPollPayload) => void): () => void { export function onJobPollTick(cb: (p: JobPollPayload) => void): () => void {
@@ -70,6 +81,10 @@ export function onUsbPollTick(cb: (p: UsbPollPayload) => void): () => void {
return api().on('usb:poll-tick', cb as (...args: unknown[]) => void) return api().on('usb:poll-tick', cb as (...args: unknown[]) => void)
} }
export function onCardPositionTick(cb: (p: CardPositionPollPayload) => void): () => void {
return api().on('card:position-tick', cb as (...args: unknown[]) => void)
}
export async function dialogOpenDirectory(): Promise<IpcResult<{ paths: string[] }>> { export async function dialogOpenDirectory(): Promise<IpcResult<{ paths: string[] }>> {
return api().invoke('dialog:open-directory') as Promise<IpcResult<{ paths: string[] }>> return api().invoke('dialog:open-directory') as Promise<IpcResult<{ paths: string[] }>>
} }
@@ -92,11 +107,26 @@ export async function fsDirSize(
> >
} }
export async function fsWriteJobCsv(payload: {
taskId: string
rows: { originName: string; value: string }[]
}): Promise<IpcResult<{ path: string }>> {
return api().invoke('fs:write-job-csv', payload) as Promise<IpcResult<{ path: string }>>
}
export async function fsParseSoon(filePath: string): Promise< export async function fsParseSoon(filePath: string): Promise<
IpcResult<{ frontImageUrl: string; backImageUrl: string; fields: { label: string; value: string }[] }> IpcResult<{
frontImageUrl: string
backImageUrl: string
fields: { label: string; value: string; originName: string }[]
}>
> { > {
return api().invoke('fs:parse-soon', filePath) as Promise< return api().invoke('fs:parse-soon', filePath) as Promise<
IpcResult<{ frontImageUrl: string; backImageUrl: string; fields: { label: string; value: string }[] }> IpcResult<{
frontImageUrl: string
backImageUrl: string
fields: { label: string; value: string; originName: string }[]
}>
> >
} }
@@ -106,7 +136,7 @@ export async function configGet(): Promise<
templateDir: string templateDir: string
traceEnabled: boolean traceEnabled: boolean
lastPrinterStatus?: PrinterStatusDisplay lastPrinterStatus?: PrinterStatusDisplay
skipDllInit?: boolean dllInitialized: boolean
}> }>
> { > {
return api().invoke('config:get') as Promise< return api().invoke('config:get') as Promise<
@@ -115,7 +145,7 @@ export async function configGet(): Promise<
templateDir: string templateDir: string
traceEnabled: boolean traceEnabled: boolean
lastPrinterStatus?: PrinterStatusDisplay lastPrinterStatus?: PrinterStatusDisplay
skipDllInit?: boolean dllInitialized: boolean
}> }>
> >
} }
@@ -1,246 +0,0 @@
<template>
<div class="m-settings-modal" :class="{ 'is-open': modelValue }" :aria-hidden="!modelValue">
<div class="m-settings-modal__backdrop" />
<section
class="m-settings-modal__dialog"
role="dialog"
aria-modal="true"
aria-labelledby="settingsModalTitle"
@click.stop
>
<header class="m-settings-modal__header">
<h2 id="settingsModalTitle">设置</h2>
<button
type="button"
class="m-settings-modal__close"
aria-label="关闭"
@click="close"
>
<AppIcon name="times" size="sm" />
</button>
</header>
<div class="m-settings-modal__body">
<section class="m-settings-group">
<h3 class="m-settings-group__title">系统初始化</h3>
<div class="m-settings-group__panel m-settings-init">
<div class="m-settings-init__field">
<label for="settingSharedDir">任务目录 (shared_dir)</label>
<input
id="settingSharedDir"
v-model="sharedDir"
type="text"
class="c-input m-settings-shared-dir"
/>
</div>
<p v-if="appStore.initError" class="m-settings-init-error">{{ appStore.initError }}</p>
<button type="button" class="c-button-cs m-settings-init__btn" @click="onRetryInit">
重试 Init
</button>
<p class="m-settings-init-hint">每台进程仅可 Init 一次更换目录或重连打印机请重启应用</p>
</div>
</section>
<section class="m-settings-group">
<h3 class="m-settings-group__title">基础配置</h3>
<div class="m-settings-group__panel">
<div class="m-settings-row">
<label for="settingPriority">优先级</label>
<AppSelect
id="settingPriority"
v-model="form.priority"
block
:items="PRIORITY_OPTIONS"
/>
<label for="settingRibbonType">色带类型</label>
<AppSelect
id="settingRibbonType"
v-model="form.ribbonType"
block
:items="RIBBON_TYPE_OPTIONS"
/>
</div>
<div class="m-settings-row m-settings-row--format">
<label for="settingCopyFormat">拷贝前格式化类型</label>
<AppSelect
id="settingCopyFormat"
v-model="form.formatType"
block
:items="FORMAT_TYPE_OPTIONS"
/>
</div>
</div>
</section>
<section class="m-settings-group m-settings-group--advanced">
<h3 class="m-settings-group__title">高级选项</h3>
<div class="m-settings-group__panel">
<div class="m-settings-options">
<label class="m-settings-check">
<input v-model="form.generateIso" type="checkbox" />
<span>拷贝前生成 iso</span>
</label>
<label class="m-settings-check">
<input v-model="form.printCmdToHasi" type="checkbox" />
<span>打印 cmd hASI 字段</span>
</label>
<label class="m-settings-check">
<input v-model="form.generateZip" type="checkbox" />
<span>生成 zip</span>
</label>
<label class="m-settings-check">
<input v-model="form.presetCopy" type="checkbox" />
<span>预设内容拷贝</span>
</label>
<label class="m-settings-check">
<input v-model="form.generateHasi" type="checkbox" />
<span>生成 hASI 文件</span>
</label>
<label class="m-settings-check">
<input v-model="form.dongleCountCheck" type="checkbox" />
<span>加密狗计数</span>
</label>
<label class="m-settings-check">
<input v-model="form.failPrintLabel" type="checkbox" />
<span>失败打印标签</span>
</label>
</div>
</div>
</section>
</div>
<footer class="m-settings-modal__footer">
<button type="button" class="m-settings-modal__cancel" @click="close">
<AppIcon name="times" size="sm" />
<span>取消</span>
</button>
<button type="button" class="c-button-cs m-settings-modal__confirm" @click="onConfirm">
确定
</button>
</footer>
</section>
</div>
</template>
<script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import AppSelect from '@/components/AppSelect.vue'
import {
FORMAT_TYPE_OPTIONS,
PRIORITY_OPTIONS,
RIBBON_TYPE_OPTIONS
} from '@/constants/selectOptions'
import { notify } from '@/composables/useNotify'
import { useAppBootstrap } from '@/composables/useAppBootstrap'
import { configGet, configSet } from '@/api/cardsoon'
import { useAppStore } from '@/stores/app'
import { useConfigStore } from '@/stores/config'
import { useDistributeFormStore } from '@/stores/distributeForm'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
const form = useDistributeFormStore()
const appStore = useAppStore()
const configStore = useConfigStore()
const { retryInit } = useAppBootstrap()
const sharedDir = ref(configStore.sharedDir)
function close(): void {
emit('update:modelValue', false)
}
async function saveSettings(): Promise<void> {
const dir = sharedDir.value.trim()
if (!dir) return
configStore.setSharedDir(dir)
await configSet({ sharedDir: dir })
}
async function onConfirm(): Promise<void> {
await saveSettings()
close()
}
async function onRetryInit(): Promise<void> {
await saveSettings()
if (appStore.initialized) {
notify.info('系统已初始化,无需重复 Init')
return
}
await retryInit()
if (appStore.initialized) notify.success('Init 成功,系统已就绪')
else notify.error(appStore.initError || 'Init 失败')
}
function onKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') close()
}
watch(
() => props.modelValue,
async (open) => {
if (open) {
sharedDir.value = configStore.sharedDir
const cfg = await configGet()
if (cfg.data?.sharedDir) sharedDir.value = cfg.data.sharedDir
window.addEventListener('keydown', onKeydown)
} else {
window.removeEventListener('keydown', onKeydown)
}
}
)
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</script>
<style src="@/styles/pages/page4.css"></style>
<style scoped>
.m-settings-init {
display: flex;
flex-direction: column;
gap: 6px;
}
.m-settings-init__field {
display: flex;
flex-direction: column;
gap: 3px;
}
.m-settings-init__field label {
font-size: 10px;
color: #333;
}
.m-settings-shared-dir {
width: 100%;
min-width: 0;
height: 20px;
font-size: 10px;
}
.m-settings-init__btn {
align-self: flex-start;
height: 24px;
font-size: 12px;
padding: 0 10px;
}
.m-settings-row--format {
grid-template-columns: 120px 1fr;
}
.m-settings-init-error {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: #dc3545;
font-weight: 600;
}
.m-settings-init-hint {
margin: 0;
font-size: 10px;
color: #6c757d;
line-height: 1.3;
}
</style>
@@ -1,131 +1,75 @@
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { notify } from '@/composables/useNotify' import { notify } from '@/composables/useNotify'
import { import { applyPrinterPayload, refreshPrinterHeader } from '@/composables/usePrinterStatus'
configGet, import { configGet, dllInit, dllRejectAvailable } from '@/api/cardsoon'
dllInit,
dllPrinterInfo,
dllRejectAvailable,
parsePrinterInfo
} from '@/api/cardsoon'
import type { PrinterStatusDisplay } from '@/types/printer'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
let bootstrapped = false let bootstrapped = false
function applyPrinterPayload( function placeholderStatus(configStore: ReturnType<typeof useConfigStore>, text: string): void {
configStore: ReturnType<typeof useConfigStore>, if (configStore.printer.statusText === '—') {
data: Record<string, unknown> configStore.setPrinter({ ...configStore.printer, statusText: text })
): void {
const snapshot = data.snapshot as PrinterStatusDisplay | undefined
if (snapshot) {
configStore.setPrinter(snapshot)
return
} }
if (data.fromCache && !data.printerList) {
const { fromCache: _f, liveError: _e, ribbonType, statusText, serialNo, printedCount } = data
configStore.setPrinter({
ribbonType: String(ribbonType ?? '—'),
statusText: String(statusText ?? '—'),
serialNo: String(serialNo ?? '—'),
printedCount: Number(printedCount ?? 0)
})
return
}
configStore.setPrinter(parsePrinterInfo(data))
} }
export function useAppBootstrap(): { export function useAppBootstrap(): void {
retryInit: () => Promise<void>
refreshHeader: () => Promise<void>
} {
const appStore = useAppStore() const appStore = useAppStore()
const configStore = useConfigStore() const configStore = useConfigStore()
async function hydratePrinterFromLocal(): Promise<void> { async function hydrateFromConfig() {
const cfg = await configGet() const cfg = await configGet()
if (cfg.ok && cfg.data?.lastPrinterStatus) { if (cfg.ok && cfg.data?.lastPrinterStatus) {
configStore.setPrinter(cfg.data.lastPrinterStatus) configStore.setPrinter(cfg.data.lastPrinterStatus)
} }
if (cfg.ok && cfg.data?.sharedDir) {
configStore.setSharedDir(cfg.data.sharedDir)
}
return cfg
} }
async function refreshHeader(): Promise<void> { async function syncRejectApi(): Promise<void> {
const info = await dllPrinterInfo()
if (info.ok && info.data) {
applyPrinterPayload(configStore, info.data)
if (info.data.fromCache) {
const msg = String(info.data.liveError || '未连接打印机')
configStore.setPrinter({
...configStore.printer,
statusText: msg
})
}
return
}
const cfg = await configGet()
if (cfg.ok && cfg.data?.lastPrinterStatus) {
configStore.setPrinter({
...cfg.data.lastPrinterStatus,
statusText: info.message || '未连接打印机'
})
return
}
configStore.setPrinter({
...configStore.printer,
statusText: info.message || '未连接打印机'
})
}
async function doInit(): Promise<void> {
await hydratePrinterFromLocal()
const cfg = await configGet()
const sharedDir = cfg.data?.sharedDir || ''
configStore.setSharedDir(sharedDir)
if (import.meta.env.DEV && cfg.data?.skipDllInit === true) {
appStore.setInitialized(false, '开发模式已跳过 DLL 初始化')
configStore.setPrinter({ ...configStore.printer, statusText: '未初始化(开发)' })
return
}
const init = await dllInit({ sharedDir })
if (!init.ok) {
appStore.setInitialized(false, init.message || 'Init 失败')
configStore.setPrinter({ ...configStore.printer, statusText: '未初始化' })
notify.error(init.message || '初始化失败,请检查任务目录权限')
return
}
appStore.setInitialized(true)
const initMeta = init.data as
| { warning?: string; skipped?: boolean; printerReady?: boolean }
| undefined
if (initMeta?.warning) notify.warning(initMeta.warning)
// Init 未就绪时 GetPrinterInfo 可能触发原生 DLL 崩溃,仅用本地缓存
if (initMeta?.skipped) {
await hydratePrinterFromLocal()
return
}
if (initMeta?.printerReady === true) {
await refreshHeader()
try { try {
const rej = await dllRejectAvailable() const rej = await dllRejectAvailable()
if (rej.ok && rej.data) configStore.rejectApiAvailable = rej.data.available if (rej.ok && rej.data) configStore.rejectApiAvailable = rej.data.available
} catch { } catch {
/* optional API */ /* optional API */
} }
}
async function bootstrap(): Promise<void> {
const cfg = await hydrateFromConfig()
if (cfg.ok && cfg.data?.dllInitialized) {
appStore.setInitialized(true)
placeholderStatus(configStore, '就绪')
await syncRejectApi()
window.setTimeout(() => void refreshPrinterHeader(configStore), 1500)
return return
} }
await hydratePrinterFromLocal()
const sharedDir = cfg.data?.sharedDir || ''
configStore.setSharedDir(sharedDir)
const init = await dllInit({ sharedDir })
if (!init.ok) {
appStore.setInitialized(false, init.message || 'Init 失败')
configStore.setPrinter({ ...configStore.printer, statusText: '初始化失败' })
notify.error(init.message || '初始化失败,请检查任务目录权限')
return
}
appStore.setInitialized(true)
const initMeta = init.data as { warning?: string } | undefined
if (initMeta?.warning) notify.warning(initMeta.warning)
placeholderStatus(configStore, initMeta?.warning ? '未连接打印机' : '就绪')
await syncRejectApi()
window.setTimeout(() => void refreshPrinterHeader(configStore), 1500)
} }
onMounted(() => { onMounted(() => {
if (bootstrapped) return if (bootstrapped) return
bootstrapped = true bootstrapped = true
window.setTimeout(() => { window.setTimeout(() => {
void doInit() void bootstrap()
}, 300) }, 100)
}) })
return { retryInit: doInit, refreshHeader }
} }
@@ -11,9 +11,9 @@ export const notify = {
info: (message: string, durationMs?: number) => push('info', message, durationMs) info: (message: string, durationMs?: number) => push('info', message, durationMs)
} }
const INIT_HINT = '系统未初始化,请进入「数据分发 → 设置」重试 Init' const INIT_HINT = '系统未就绪,请重启应用或检查打印机与任务目录'
/** 未初始化等业务拦截时的统一提示 */ /** 未初始化等业务拦截时的统一提示 */
export function notifyRequireInit(action?: string): void { export function notifyRequireInit(action?: string): void {
notify.warning(action ? `系统未初始化,无法${action}` : INIT_HINT) notify.warning(action ? `系统未就绪,无法${action}` : INIT_HINT)
} }
@@ -0,0 +1,39 @@
import { dllPrinterInfo, parsePrinterInfo } from '@/api/cardsoon'
import { useConfigStore } from '@/stores/config'
import type { PrinterStatusDisplay } from '@/types/printer'
export function applyPrinterPayload(
configStore: ReturnType<typeof useConfigStore>,
data: Record<string, unknown>
): void {
const snapshot = data.snapshot as PrinterStatusDisplay | undefined
if (snapshot) {
configStore.setPrinter(snapshot)
return
}
if (data.printerList != null || data.serial_no != null || data.SerialNo != null) {
configStore.setPrinter(parsePrinterInfo(data))
return
}
if (data.fromCache) {
configStore.setPrinter({
ribbonType: String(data.ribbonType ?? configStore.printer.ribbonType),
statusText: String(data.statusText ?? configStore.printer.statusText),
serialNo: String(data.serialNo ?? configStore.printer.serialNo),
printedCount: Number(data.printedCount ?? configStore.printer.printedCount)
})
}
}
export async function refreshPrinterHeader(
configStore: ReturnType<typeof useConfigStore>
): Promise<void> {
try {
const info = await dllPrinterInfo()
if (info.ok && info.data) {
applyPrinterPayload(configStore, info.data)
}
} catch {
/* 无打印机时不阻塞 */
}
}
-32
View File
@@ -1,32 +0,0 @@
import { onMounted, onUnmounted, ref } from 'vue'
import { useJobStore } from '@/stores/job'
const CIRCLE_LEN = 283
export function useMockJobPoll() {
const progress = ref(0)
const jobStore = useJobStore()
let timer: ReturnType<typeof setInterval> | null = null
const strokeOffset = ref(CIRCLE_LEN)
function tick() {
progress.value = Math.min(100, progress.value + 8)
strokeOffset.value = CIRCLE_LEN - (CIRCLE_LEN * progress.value) / 100
if (progress.value >= 100) {
jobStore.successCount += 1
progress.value = 0
strokeOffset.value = CIRCLE_LEN
}
}
onMounted(() => {
timer = setInterval(tick, 1000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
return { progress, strokeOffset }
}
-9
View File
@@ -1,9 +0,0 @@
import type { PrinterStatusDisplay } from '@/types/printer'
/** 阶段一 Header 展示;阶段二由 GetPrinterInfo 替换 */
export const mockPrinterStatus: PrinterStatusDisplay = {
ribbonType: 'YMCKO',
statusText: '50/300',
serialNo: 'S103B29035',
printedCount: 190
}
+12 -12
View File
@@ -7,15 +7,15 @@ export function setupRouterGuards(router: Router): void {
export function setupRouterGuards(router: Router): void { export function setupRouterGuards(router: Router): void {
if (to.path === '/distribute/running' && !job.jobId) {
router.beforeEach((to, from) => { router.beforeEach((to, from) => {
const job = useJobStore() const job = useJobStore()
if (to.path === '/distribute/failed' && job.failCount === 0) {
const app = useAppStore() const app = useAppStore()
if (to.path === '/collect/running' && app.mode !== 'usbCopying') {
if (to.path === '/distribute/running' && !job.jobId && app.mode !== 'distributing') { if (to.path === '/distribute/running' && !job.jobId && app.mode !== 'distributing') {
return { path: '/distribute/config' } return { path: '/distribute/config' }
@@ -23,21 +23,21 @@ export function setupRouterGuards(router: Router): void {
} }
if (app.mode === 'usbCopying') {
if (to.path.startsWith('/distribute')) return { path: '/collect/running' } if (to.path === '/distribute/failed') {
if (from.path === '/collect/running') {
const allowed = ['/collect/running', '/collect', '/home']
if (!allowed.includes(to.path)) return false
}
return { path: '/distribute/config' } return { path: '/distribute/config' }
if (from.path === '/distribute/running' && to.path !== '/distribute/failed') {
if (to.path !== '/distribute/config') { }
app.setMode('ready')
if (to.path === '/collect/running' && app.mode !== 'usbCopying' && app.mode !== 'ready') {
return { path: '/collect' }
} }
if (to.path === '/collect' && app.mode === 'distributing') {
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
export interface TemplateFieldRow { export interface TemplateFieldRow {
label: string label: string
value: string value: string
originName: string
} }
export interface TemplatePreview { export interface TemplatePreview {
+14 -1
View File
@@ -22,7 +22,8 @@
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 12px;
padding: 20px 30px; padding: 20px 30px;
min-width: 220px; min-width: 0;
max-width: 50%;
} }
/* 面板标题 */ /* 面板标题 */
@@ -51,6 +52,8 @@
/* ========== 路径选择 - 大按钮设计 ========== */ /* ========== 路径选择 - 大按钮设计 ========== */
.m-path-box { .m-path-box {
width: 100%; width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 12px 16px; padding: 12px 16px;
background: #f8f9fa; background: #f8f9fa;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
@@ -59,6 +62,16 @@
font-weight: 600; font-weight: 600;
color: #495057; color: #495057;
font-family: monospace; font-family: monospace;
overflow: hidden;
}
.m-path-text {
display: block;
width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.m-path-btn { .m-path-btn {
@@ -1,128 +0,0 @@
/*
Page 4 - 数据导入模式
手机横屏优化:左右分栏,大触摸区域,紧凑布局
*/
/* ========== 手机横屏核心布局 ========== */
.l-mobile-landscape {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
padding: 0 60px;
height: 100%;
flex: 1;
}
/* 配置面板 */
.m-config-panel {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 20px 30px;
min-width: 220px;
}
/* 面板标题 */
.m-panel-title {
font-size: 14px;
font-weight: 700;
color: #495057;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.m-panel-title i {
color: var(--cs-primary);
font-size: 16px;
}
/* 垂直分隔线 */
.m-divider-v {
width: 1px;
height: 100px;
background: #e9ecef;
}
/* ========== 路径选择 - 大按钮设计 ========== */
.m-path-box {
width: 100%;
padding: 12px 16px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
color: #495057;
font-family: monospace;
}
.m-path-btn {
width: 100%;
height: 44px;
background: #fff;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
color: #495057;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s ease;
}
.m-path-btn:hover {
border-color: var(--cs-primary);
color: var(--cs-primary);
}
.m-path-btn i {
color: var(--cs-primary);
}
/* ========== 单选按钮 - 大触摸区域 ========== */
.m-radio-group {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.m-radio-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
color: #495057;
transition: all 0.2s ease;
}
.m-radio-item input {
width: 18px;
height: 18px;
margin: 0;
cursor: pointer;
accent-color: var(--cs-primary);
}
.m-radio-item:hover {
border-color: var(--cs-primary);
}
.m-radio-item:has(input:checked) {
border-color: var(--cs-primary);
background: rgba(0, 128, 0, 0.05);
}
@@ -1,360 +0,0 @@
/*
Page 8 - 循环任务执行中样式
左右分布布局:左 = 状态+工作流,右 = 大圆环
Index 首页也复用此样式
*/
/* ========== Index 首页仪表板样式 ========== */
.l-dashboard {
display: flex;
align-items: center;
justify-content: center;
gap: 60px;
padding: 20px 80px;
height: 100%;
flex: 1;
}
/* 区域标题 */
.m-section-title {
font-size: 11px;
font-weight: 700;
color: #adb5bd;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 1px;
padding-left: 4px;
}
/* ========== 工具区域(左) ========== */
.m-tool-section {
width: 180px;
flex-shrink: 0;
}
.m-tool-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.m-tool-btn {
width: 100%;
height: 48px;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0 16px;
gap: 10px;
font-size: 13px;
font-weight: 600;
color: #495057;
cursor: pointer;
transition: all 0.2s ease;
}
.m-tool-btn i {
font-size: 16px;
color: #6c757d;
width: 20px;
text-align: center;
transition: color 0.2s ease;
}
.m-tool-btn:hover {
border-color: var(--cs-primary);
box-shadow: 0 4px 12px rgba(0, 128, 0, 0.1);
}
.m-tool-btn:hover i {
color: var(--cs-primary);
}
/* ========== 垂直分隔线 ========== */
.m-divider {
width: 1px;
height: 140px;
background: linear-gradient(to bottom, transparent, #dee2e6, transparent);
}
/* ========== 任务区域(右) ========== */
.m-task-section {
flex: 1;
max-width: 400px;
}
.m-task-grid {
display: flex;
gap: 12px;
}
.m-task-card {
flex: 1;
min-height: 100px;
background: #fff;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
transition: all 0.25s ease;
}
.m-task-icon {
width: 42px;
height: 42px;
border-radius: 8px;
background: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s ease;
}
.m-task-icon i {
font-size: 18px;
color: #6c757d;
transition: color 0.25s ease;
}
.m-task-info {
text-align: center;
}
.m-task-info h4 {
font-size: 14px;
font-weight: 800;
color: #495057;
margin: 0 0 4px 0;
transition: color 0.25s ease;
}
.m-task-info p {
font-size: 11px;
color: #adb5bd;
margin: 0;
font-weight: 500;
}
/* 悬停效果 - 绿色主题 */
.m-task-card:hover {
border-color: var(--cs-primary);
box-shadow: 0 6px 16px rgba(0, 128, 0, 0.12);
transform: translateY(-2px);
}
.m-task-card:hover .m-task-icon {
background: var(--cs-primary);
}
.m-task-card:hover .m-task-icon i {
color: #fff;
}
.m-task-card:hover h4 {
color: var(--cs-primary);
}
/* 停止按钮样式 - 醒目红色 */
.c-nav-btn--stop {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%) !important;
box-shadow: 0 3px 10px rgba(220, 53, 69, 0.35) !important;
width: 48px !important;
height: 48px !important;
}
.c-nav-btn--stop i {
color: #fff !important;
font-size: 18px !important;
}
.c-nav-btn--stop span {
color: #fff !important;
font-weight: 700 !important;
}
.c-nav-btn--stop:hover {
background: linear-gradient(135deg, #c82333 0%, #a71d2a 100%) !important;
box-shadow: 0 4px 14px rgba(220, 53, 69, 0.45) !important;
}
/* ========== 核心布局:左右分布 ========== */
.l-hero-container {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 80px !important;
padding: 0 120px !important;
}
/* 左侧面板:状态 + 工作流 */
.m-left-panel {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 30px;
flex: 1;
max-width: 380px;
}
/* 右侧面板:圆环进度 */
.m-right-panel {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
/* ========== 状态消息 - 左对齐 ========== */
.c-status-panel {
text-align: left !important;
width: 100% !important;
}
.c-status-title.is-looping {
color: var(--cs-primary);
font-size: 24px;
font-weight: 800;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
justify-content: flex-start;
}
.c-status-title.is-looping::before {
content: '';
width: 12px;
height: 12px;
background: var(--cs-primary);
border-radius: 50%;
animation: blink 1.5s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.c-status-sub {
text-align: left !important;
font-size: 13px !important;
}
/* 统计计数文字 */
.c-status-counter {
font-size: 12px;
color: #6c757d;
font-weight: 600;
margin: 8px 0 0 0;
}
.c-status-counter .ok {
color: var(--cs-primary);
font-weight: 800;
}
.c-status-counter .err {
color: #dc3545;
font-weight: 800;
}
/* ========== 4步工作流 - 直接渲染 ========== */
.m-steps-flow {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0;
width: 100%;
padding-top: 10px;
}
.m-steps-flow .step-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 70px;
}
.m-steps-flow .step-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #dee2e6;
}
.m-steps-flow .step-item.is-active .step-dot {
background: var(--cs-primary);
box-shadow: 0 0 0 4px rgba(0, 128, 0, 0.15);
}
.m-steps-flow .step-label {
font-size: 11px;
font-weight: 600;
color: #adb5bd;
white-space: nowrap;
}
.m-steps-flow .step-item.is-active .step-label {
color: var(--cs-primary);
font-weight: 700;
}
.m-steps-flow .step-line {
width: 50px;
height: 3px;
background: #dee2e6;
margin-bottom: 18px;
}
.m-steps-flow .step-line.is-active {
background: var(--cs-primary);
}
/* ========== 圆形进度条 ========== */
.m-progress-circle {
position: relative;
width: 160px;
height: 160px;
}
.m-progress-circle svg {
transform: rotate(-90deg);
width: 100%;
height: 100%;
}
.m-progress-circle circle {
fill: none;
stroke-width: 10;
stroke-linecap: round;
}
.m-progress-circle .bg {
stroke: #ecf0f1;
}
.m-progress-circle .fill {
stroke: var(--cs-primary);
stroke-dasharray: 283;
transition: stroke-dashoffset 0.5s ease;
}
.m-progress-value {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 34px;
font-weight: 900;
color: var(--cs-primary);
}
@@ -1,172 +0,0 @@
/*
Page 3 - 任务失败界面
风格与 page8 统一:左右分布,红色错误主题
*/
/* ========== 核心布局:左右分布 ========== */
.l-hero-container {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 80px !important;
padding: 0 120px !important;
}
/* 左侧面板:状态 + 工作流 */
.m-left-panel {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 30px;
flex: 1;
max-width: 380px;
}
/* 右侧面板:错误图标 */
.m-right-panel {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
/* ========== 状态消息 - 红色错误主题 ========== */
.c-status-panel {
text-align: left !important;
width: 100% !important;
}
.c-status-title.is-error {
color: #dc3545;
font-size: 24px;
font-weight: 800;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
justify-content: flex-start;
}
.c-status-title.is-error::before {
content: '';
width: 12px;
height: 12px;
background: #dc3545;
border-radius: 50%;
animation: blink-red 1.5s infinite;
}
@keyframes blink-red {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.c-status-sub {
text-align: left !important;
font-size: 13px !important;
color: #6c757d;
}
/* 统计计数文字 */
.c-status-counter {
font-size: 12px;
color: #6c757d;
font-weight: 600;
margin: 8px 0 0 0;
}
.c-status-counter .ok {
color: #28a745;
font-weight: 800;
}
.c-status-counter .err {
color: #dc3545;
font-weight: 800;
}
/* ========== 4步工作流 - 红色错误主题 ========== */
.m-steps-flow {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0;
width: 100%;
padding-top: 10px;
}
.m-steps-flow .step-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 70px;
}
.m-steps-flow .step-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #dee2e6;
}
/* 完成状态 - 绿色 */
.m-steps-flow .step-item.is-completed .step-dot {
background: #28a745;
}
.m-steps-flow .step-item.is-completed .step-label {
color: #28a745;
font-weight: 700;
}
/* 错误状态 - 红色 */
.m-steps-flow .step-item.is-error .step-dot {
background: #dc3545;
box-shadow: 0 0 0 4px rgba(220, 53, 69, 0.15);
}
.m-steps-flow .step-item.is-error .step-label {
color: #dc3545;
font-weight: 700;
}
.m-steps-flow .step-label {
font-size: 11px;
font-weight: 600;
color: #adb5bd;
white-space: nowrap;
}
.m-steps-flow .step-line {
width: 50px;
height: 3px;
background: #dee2e6;
margin-bottom: 18px;
}
.m-steps-flow .step-line.is-completed {
background: #28a745;
}
.m-steps-flow .step-line.is-error {
background: linear-gradient(to right, #28a745 50%, #dc3545 50%);
}
/* ========== 错误图标 - 红色大三角 ========== */
.m-error-icon {
width: 160px;
height: 160px;
border-radius: 50%;
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(220, 53, 69, 0.3);
}
.m-error-icon i {
font-size: 70px;
color: #fff;
}
-213
View File
@@ -1,8 +1,3 @@
/*
Page 7 业务样式 - 打印系统核心界面
基于 base.css 构建
*/
/* 左右栏固定 1:1,内容变化不挤占宽度 */ /* 左右栏固定 1:1,内容变化不挤占宽度 */
.app-shell__main.l-main-flex { .app-shell__main.l-main-flex {
display: grid; display: grid;
@@ -410,214 +405,6 @@
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
} }
/* 10. 设置弹窗 (Page 8) */
.m-settings-modal {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
z-index: 50;
}
.m-settings-modal.is-open {
display: flex;
}
.m-settings-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.24);
}
.m-settings-modal__dialog {
position: relative;
width: min(540px, calc(100% - 20px));
max-height: calc(100% - 12px);
min-height: 0;
background: #f3f3f3;
border: 1px solid #cfcfcf;
border-radius: 6px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
display: flex;
flex-direction: column;
overflow: hidden;
}
.m-settings-modal__header {
flex-shrink: 0;
height: 28px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 0 12px;
border-bottom: 1px solid #dddddd;
background: linear-gradient(to bottom, #fbfbfb, #efefef);
}
.m-settings-modal__header h2 {
font-size: 11px;
color: #5a5a5a;
font-weight: 700;
}
.m-settings-modal__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 3px;
background: transparent;
color: #666;
cursor: pointer;
}
.m-settings-modal__close:hover {
background: rgba(0, 0, 0, 0.06);
color: #333;
}
.m-settings-modal__body {
flex: 1;
min-height: 0;
padding: 6px 10px 4px;
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: #bbb transparent;
}
.m-settings-modal__body::-webkit-scrollbar {
width: 5px;
}
.m-settings-modal__body::-webkit-scrollbar-thumb {
background: #bbb;
border-radius: 3px;
}
.m-settings-group {
flex-shrink: 0;
border: 1px solid #d8d8d8;
background: #f5f5f5;
padding: 6px;
}
.m-settings-group--advanced {
min-height: 0;
}
.m-settings-group__title {
font-size: 11px;
color: #444;
margin-bottom: 4px;
font-weight: 700;
}
.m-settings-group__panel {
background: #efefef;
border: 1px solid #d9d9d9;
padding: 6px;
overflow: visible;
}
.m-settings-row {
display: grid;
grid-template-columns: 58px 155px 58px 1fr;
align-items: center;
column-gap: 8px;
margin-bottom: 6px;
}
.m-settings-row:last-child {
margin-bottom: 0;
grid-template-columns: 86px 155px 1fr;
}
.m-settings-row label {
font-size: 10px;
color: #333;
white-space: nowrap;
}
.m-settings-row .c-app-select {
min-width: 0;
}
.m-settings-options {
display: grid;
grid-template-columns: 1fr 1fr;
row-gap: 6px;
column-gap: 14px;
align-content: start;
min-height: 0;
}
.m-settings-check {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: #333;
white-space: nowrap;
}
.m-settings-check input[type='checkbox'] {
width: 12px;
height: 12px;
margin: 0;
}
.m-settings-modal__footer {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 4px 12px 8px;
border-top: 1px solid #ddd;
background: #f3f3f3;
}
.m-settings-modal__cancel {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 68px;
height: 24px;
padding: 0 10px;
border: 1px solid #ced4da;
border-radius: 3px;
background: #fff;
font-size: 12px;
color: #495057;
cursor: pointer;
}
.m-settings-modal__cancel:hover {
border-color: #adb5bd;
background: #f8f9fa;
}
.m-settings-modal__cancel .fas {
font-size: 11px;
color: #6c757d;
}
.m-settings-modal__confirm {
min-width: 68px;
height: 24px;
font-size: 12px;
border-radius: 3px;
padding: 0 12px;
}
/* 路径提示 */ /* 路径提示 */
.m-path-hint { .m-path-hint {
display: flex; display: flex;
@@ -1,660 +0,0 @@
/*
Page 7 业务样式 - 打印系统核心界面
基于 base.css 构建
*/
/* 布局微调:增加左侧面板宽度给新控件 */
.m-panel--left {
flex: 5;
}
.m-panel--right {
flex: 5;
}
/* ========== 工具栏 - 紧凑两行布局 ========== */
.m-panel-toolbar {
padding: 10px 14px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
display: flex;
flex-direction: column;
gap: 8px;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 16px;
}
.toolbar-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: 600;
color: #6c757d;
white-space: nowrap;
}
.toolbar-item .c-input {
width: 90px;
height: 24px;
padding: 0 6px;
font-size: 9px;
border-radius: 3px;
border: 1px solid #ced4da;
}
.toolbar-item .c-select {
width: 90px;
height: 24px;
padding: 0 6px;
font-size: 9px;
border-radius: 3px;
border: 1px solid #ced4da;
}
/* 复选框样式 */
.m-panel-toolbar .c-checkbox-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
font-weight: 600;
color: #6c757d;
white-space: nowrap;
cursor: pointer;
padding: 2px 0;
}
.m-panel-toolbar .c-checkbox-item input[type="checkbox"] {
width: 14px;
height: 14px;
margin: 0;
cursor: pointer;
}
/* 加密狗数量输入框 */
.m-panel-toolbar .c-checkbox-item .c-input.dog-count {
width: 40px;
height: 20px;
padding: 0 4px;
font-size: 9px;
text-align: center;
border-radius: 3px;
border: 1px solid #ced4da;
margin-left: 3px;
}
/* 提示文字 */
.m-panel-toolbar .c-checkbox-item .dog-hint {
font-size: 8px;
color: #adb5bd;
font-weight: 500;
margin-left: 2px;
}
/* 列表业务项 (File Items) */
.m-file-item {
display: flex;
align-items: center;
padding: 6px 10px;
border-bottom: 1px solid #f8f8f8;
}
.m-file-item__icon {
width: 32px;
font-size: 22px; /* 图标大幅放大,对齐 case.png */
color: var(--cs-dark-blue);
margin-right: 12px;
text-align: center;
}
.m-file-item__info {
flex: 1;
}
.m-file-item__name {
font-size: 11px;
font-weight: 600;
}
.m-file-item__meta {
font-size: 9px;
color: #999;
}
.m-file-item__delete {
border: none;
background: none;
color: var(--cs-primary);
font-size: 14px;
cursor: pointer;
}
/* 卡片预览区 (Preview Area) */
.m-preview-area {
background: #333;
margin: 8px;
height: 100px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.m-card-small {
width: 130px;
height: 84px;
background: white;
border-radius: 3px;
padding: 5px;
font-size: 7px;
}
.m-card-small__row {
margin-bottom: 2px;
}
/* 面板头部专用布局 */
.c-panel__header .c-nav-group {
gap: 4px;
align-items: center;
}
/* 优化按钮文字展示,确保不换行 */
.c-button--mini,
.c-button--primary {
background: var(--cs-primary) !important;
color: #fff !important;
border: none !important;
border-radius: 4px;
padding: 1px 8px; /* 进一步收紧边距 */
font-weight: 800;
font-size: 10px;
white-space: nowrap;
letter-spacing: -0.2px; /* 微调字间距 */
}
.c-button--mini:hover {
background: var(--cs-primary-hover) !important;
}
/* 数据详情表格 (Data Table) */
.m-data-section {
flex: 1;
padding: 0 10px;
overflow-y: auto;
}
.m-data-table {
width: 100%;
border-collapse: collapse;
font-size: 10px;
}
.m-data-table td {
padding: 3px 0;
border-bottom: 1px solid #f5f5f5;
}
.m-data-table td:first-child {
color: #888;
width: 40%;
}
/* 动态表单字段 (Dynamic Fields) */
.m-dynamic-fields {
padding: 0 10px 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.m-dynamic-row {
display: flex;
align-items: center; /* 垂直居中 */
gap: 8px;
min-height: 24px; /* 增加最小高度确保对齐空间 */
}
.m-dynamic-row select,
.m-dynamic-row input[type='text'] {
border: 1px solid var(--cs-border);
border-radius: 3px;
font-size: 10px;
}
.m-dynamic-row select {
flex: 1.2;
background: #f9f9f9;
}
.m-dynamic-row input[type='text'] {
flex: 1.8;
}
/* 单选框组对齐优化 */
.m-dynamic-row .radio-group {
display: flex;
align-items: center;
gap: 10px;
flex: 1.8; /* 与输入框占据同样的宽度比例,保持视觉对称 */
}
.m-dynamic-row label {
display: flex;
align-items: center; /* 关键:Label 内部 Flex 居中 */
gap: 4px;
font-size: 10px;
color: #444;
cursor: pointer;
line-height: 1; /* 防止行高干扰 */
}
.m-dynamic-row input[type='radio'] {
margin: 0;
cursor: pointer;
width: 12px;
height: 12px;
position: relative;
top: 1px; /* 视觉补偿:单选框通常在浏览器中偏上 1px */
}
.c-list-item {
padding: 6px 12px;
border-bottom: 1px solid #f5f6f7;
}
.c-list-item__icon {
color: #3498db;
font-size: 14px;
width: 20px;
}
.c-list-item__name {
font-size: 11px;
font-weight: 800;
color: #333;
}
.c-list-item__meta {
font-size: 9px;
color: #999;
}
.c-list-item__action {
color: #e74c3c;
font-size: 14px;
}
/* 4. 预览区:高保真拟物化 */
.m-preview-area {
background: #2d3436;
margin: 8px;
height: 105px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.5);
}
.m-card-small {
width: 135px;
height: 88px;
background: #fff;
border-radius: 4px;
padding: 6px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
}
.m-card-small__row {
font-size: 8px;
color: #333;
margin-bottom: 2px;
line-height: 1.2;
}
.m-card-small__label {
font-weight: 800;
}
/* 5. 业务数据表格:极致紧凑化 (2px 间距) */
.m-data-section { padding: 2px 10px; }
.m-data-table { width: 100%; border-collapse: separate; border-spacing: 0 2px; font-size: 10px !important; }
.m-data-table td { padding: 0; border: none; vertical-align: middle; }
.m-data-table td:first-child {
color: #666; width: 35%; font-weight: 700; padding-right: 8px;
}
.m-data-table td:last-child {
color: #333; font-weight: 800; text-align: left;
background: #f9fafb; border: 1px solid #dcdfe6; border-radius: 3px;
padding: 1px 8px; height: 20px; /* 进一步压低高度 */
}
/* 6. 动态表单项:极致压缩 (解决挤压问题) */
.m-dynamic-fields { padding: 0 10px 6px; display: flex; flex-direction: column; gap: 2px; }
.m-dynamic-row { display: flex; align-items: center; gap: 6px; min-height: 20px; }
.m-dynamic-row .c-select {
width: 35%; /* 强制与上方 Label 宽度一致,实现对齐 */
height: 20px; font-size: 10px; border: 1px solid #dcdfe6; border-radius: 3px;
}
.m-dynamic-row .c-input {
flex: 1; height: 20px; font-size: 10px; border: 1px solid #dcdfe6; border-radius: 3px; padding: 0 8px;
}
.m-dynamic-row .m-file-item__delete {
font-size: 12px; color: #999; padding: 0 4px;
}
.m-dynamic-row label { display: flex; align-items: center; gap: 4px; cursor: pointer; color: #444; font-size: 10px; }
/* 7. 主色调回归:Page 3 式工业绿 */
.c-button--primary {
background: var(--cs-primary) !important;
border-color: #3b633a !important;
}
/* 8. 进度条深度美化 (极致窄版 - 12px 极简设计) */
.c-progress {
height: 12px; /* 极致窄版,节省空间 */
background: #dee2e6;
border-radius: 6px;
position: relative;
overflow: hidden;
margin: 2px 10px; /* 减小外边距 */
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15);
border: 1px solid #adb5bd;
}
.c-progress-fill {
height: 100%;
background: linear-gradient(to bottom, #40c057, #2f9e44);
border-radius: 5px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
.c-progress-text {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 8.5px; /* 极致字号 */
font-weight: 800;
color: #fff;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.4);
z-index: 2;
line-height: 12px;
}
/* 9. Footer 区域收缩 */
.c-panel__footer {
padding: 4px 0 !important; /* 彻底压缩 Footer 高度 */
min-height: auto !important;
border-top: 1px solid #f0f0f0;
}
/* 10. 设置弹窗 (Page 8) */
.m-settings-modal {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
z-index: 50;
}
.m-settings-modal.is-open {
display: flex;
}
.m-settings-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.24);
}
.m-settings-modal__dialog {
position: relative;
width: 560px;
min-height: 265px;
background: #f3f3f3;
border: 1px solid #cfcfcf;
border-radius: 6px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
display: flex;
flex-direction: column;
}
.m-settings-modal__header {
height: 28px;
display: flex;
align-items: center;
padding: 0 12px;
border-bottom: 1px solid #dddddd;
background: linear-gradient(to bottom, #fbfbfb, #efefef);
}
.m-settings-modal__header h2 {
font-size: 11px;
color: #5a5a5a;
font-weight: 700;
}
.m-settings-modal__body {
flex: 1;
padding: 8px 12px 6px;
display: flex;
flex-direction: column;
gap: 8px;
}
.m-settings-group {
border: 1px solid #d8d8d8;
background: #f5f5f5;
padding: 8px;
}
.m-settings-group--advanced {
min-height: 132px;
}
.m-settings-group__title {
font-size: 11px;
color: #444;
margin-bottom: 7px;
font-weight: 700;
}
.m-settings-group__panel {
background: #efefef;
border: 1px solid #d9d9d9;
padding: 8px;
}
.m-settings-row {
display: grid;
grid-template-columns: 58px 155px 58px 1fr;
align-items: center;
column-gap: 8px;
margin-bottom: 6px;
}
.m-settings-row:last-child {
margin-bottom: 0;
grid-template-columns: 86px 155px 1fr;
}
.m-settings-row label {
font-size: 10px;
color: #333;
white-space: nowrap;
}
.m-settings-row .c-select {
height: 20px;
font-size: 10px;
border-radius: 2px;
border-color: #c9c9c9;
background: #fff;
}
.m-settings-options {
display: grid;
grid-template-columns: 1fr 1fr;
row-gap: 12px;
column-gap: 22px;
align-content: start;
min-height: 92px;
}
.m-settings-check {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: #333;
white-space: nowrap;
}
.m-settings-check input[type='checkbox'] {
width: 12px;
height: 12px;
margin: 0;
}
.m-settings-modal__footer {
display: flex;
justify-content: flex-end;
padding: 0 12px 10px;
}
.m-settings-modal__confirm {
min-width: 68px;
height: 22px;
font-size: 10px;
border-radius: 3px;
padding: 0 12px;
}
/* 路径提示 */
.m-path-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
font-size: 10px;
color: #6c757d;
}
.m-path-hint i {
color: var(--cs-dark-grey);
font-size: 12px;
}
/* 路径列表项 */
.c-path-item {
display: flex;
align-items: center;
padding: 6px 12px;
border-bottom: 1px solid #f5f6f7;
}
.c-path-item__info {
flex: 1;
}
.c-path-item__name {
font-size: 11px;
font-weight: 700;
color: #333;
}
.c-path-item__meta {
font-size: 9px;
color: #999;
margin-top: 2px;
}
.c-path-item__delete {
border: none;
background: none;
color: #e74c3c;
font-size: 16px;
cursor: pointer;
padding: 2px 6px;
font-weight: 800;
}
/* 标签预览区 */
.c-preview-area {
background: #2d3436;
margin: 8px;
height: 105px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.5);
}
.c-card-small {
width: 135px;
height: 88px;
background: #fff;
border-radius: 4px;
padding: 6px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
}
.c-card-small__row {
font-size: 8px;
color: #333;
margin-bottom: 2px;
line-height: 1.2;
}
.c-card-small__label {
font-weight: 800;
}
/* 紧凑数据表格 */
.c-data-table-mini {
width: 100%;
border-collapse: separate;
border-spacing: 0 2px;
font-size: 9px;
}
.c-data-table-mini td {
padding: 0;
border: none;
vertical-align: middle;
}
.c-data-table-mini td:first-child {
color: #666;
width: 35%;
font-weight: 700;
padding-right: 8px;
font-size: 8px;
}
.c-data-table-mini td:last-child {
color: #333;
font-weight: 800;
text-align: left;
background: #f9fafb;
border: 1px solid #dcdfe6;
border-radius: 3px;
padding: 1px 6px;
height: 18px;
font-size: 8px;
}
.c-data-table-mini td:last-child.c-path-cell {
background: transparent;
border: none;
padding: 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.c-path-update {
color: var(--cs-primary);
font-weight: 800;
cursor: pointer;
padding: 0 4px;
font-size: 10px;
}
-8
View File
@@ -1,4 +1,3 @@
/* Electron 壳层:覆盖 design 原型用的深色信箱背景 */
html, html,
body, body,
#app { #app {
@@ -13,7 +12,6 @@ body,
position: relative; position: relative;
} }
/* 720×360 逻辑画布;内容区同比例;useScale 按 innerWidth/720 顶对齐 */
.app-shell { .app-shell {
position: absolute; position: absolute;
left: 0; left: 0;
@@ -25,11 +23,6 @@ body,
overflow: hidden; overflow: hidden;
} }
/*
* 首页两侧「空白」主要来自 page2.css 的 .l-dashboard
* padding 左右 + justify-content:center + 左栏固定 180px / 右栏 max-width:400px
* 在 DevTools 里选中 main.app-shell__main.l-dashboard 可看到盒模型
*/
.app-shell__main.l-dashboard { .app-shell__main.l-dashboard {
padding: 20px 40px; padding: 20px 40px;
gap: 48px; gap: 48px;
@@ -75,7 +68,6 @@ body,
margin-bottom: 2px; margin-bottom: 2px;
} }
/* 与 page2.css 中 .m-tool-btn i / .m-task-icon i 对齐 */
.m-tool-btn .fas { .m-tool-btn .fas {
font-size: 16px; font-size: 16px;
color: #6c757d; color: #6c757d;
+10
View File
@@ -28,9 +28,19 @@ export interface JobPollPayload {
} }
export interface UsbPollPayload { export interface UsbPollPayload {
/** SAPI_GetUsbCopyState 返回值,0 表示成功 */
queryCode: number
/** task_status: 0 preparing, 1 copying, 2 completed, 3 failed */
taskStatus: number taskStatus: number
/** copy_progress 0-100 */
progress: number progress: number
terminal: boolean terminal: boolean
failed: boolean failed: boolean
success: boolean success: boolean
errorMessage?: string
}
export interface CardPositionPollPayload {
queryCode: number
position: number
} }
-1
View File
@@ -5,7 +5,6 @@ export interface PrinterStatusDisplay {
printedCount: number printedCount: number
} }
/** Init 前 Header 占位;阶段二由 GetPrinterInfo 覆盖 */
export const defaultPrinterStatus: PrinterStatusDisplay = { export const defaultPrinterStatus: PrinterStatusDisplay = {
ribbonType: '—', ribbonType: '—',
statusText: '—', statusText: '—',
+21 -6
View File
@@ -1,32 +1,47 @@
import type { DistributeFormState } from '@/stores/distributeForm' import type { DistributeFormState } from '@/stores/distributeForm'
import { cleanPathPattern } from '@shared/path-pattern' import { cleanPathPattern } from '@shared/path-pattern'
import { resolveJobTasks } from '@/utils/validateJobConfig'
export interface BuildJobOptions {
taskId: string
udfFile?: string
}
export function buildJobConfig(
form: DistributeFormState,
opts: BuildJobOptions
): Record<string, unknown> {
const { hasCopy, hasPrint } = resolveJobTasks(form)
const needFormat = form.formatType !== 'none'
export function buildJobConfig(form: DistributeFormState): Record<string, unknown> {
const taskId = `T${Date.now()}`
const hasCopy = form.pathList.length > 0
const hasPrint = !!form.templateFile.trim()
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
task_id: taskId, task_id: opts.taskId,
print_copys: 1, print_copys: 1,
has_print_task: hasPrint, has_print_task: hasPrint,
has_copy_task: hasCopy, has_copy_task: hasCopy,
label: form.volumeLabel || 'DATA_CARD', label: form.volumeLabel || 'DATA_CARD',
file_type: String(form.copyType), file_type: String(form.copyType),
zone_type: form.copyType === 1 ? '1' : '0', zone_type: form.copyType === 1 ? '1' : '0',
need_format: form.formatType !== 'none', need_format: needFormat,
format_file: form.formatType === 'ntfs' ? 'NTFS' : 'FAT', format_file: form.formatType === 'ntfs' ? 'NTFS' : 'FAT',
disk_size: '16GB', disk_size: '16GB',
dongle_install_count: form.dongleEnabled ? form.dongleMode : -1 dongle_install_count: form.dongleEnabled ? form.dongleMode : -1
} }
if (hasCopy) { if (hasCopy) {
body.path_file = form.pathList.map((x) => cleanPathPattern(x.path)) body.path_file = form.pathList.map((x) => cleanPathPattern(x.path))
} }
if (hasPrint) { if (hasPrint) {
body.json_file = form.templateFile.trim() body.json_file = form.templateFile.trim()
body.print_flag = 1 body.print_flag = 1
const udf = opts.udfFile?.trim()
if (udf) body.udf_file = udf
} }
if (form.generateIso) body.is_generate_iso = true if (form.generateIso) body.is_generate_iso = true
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
return body return body
} }
@@ -0,0 +1,42 @@
import { genTaskId } from '@shared/gen-task-id'
import { buildJobConfig } from '@/utils/buildJobConfig'
import { resolveJobTasks } from '@/utils/validateJobConfig'
import { dllJobCreate, fsWriteJobCsv } from '@/api/cardsoon'
import type { DistributeFormState } from '@/stores/distributeForm'
function printFieldRows(form: DistributeFormState) {
return (form.templatePreview?.fields ?? []).map((f) => ({
originName: f.originName || f.label.replace(/\[.*\]$/, ''),
value: f.value ?? ''
}))
}
export async function createDistributeJob(
form: DistributeFormState,
opts?: { resubmit?: boolean }
): Promise<{ ok: true; jobId: string } | { ok: false; message: string }> {
const { hasCopy, hasPrint } = resolveJobTasks(form)
if (!hasCopy && !hasPrint) {
return { ok: false, message: '请配置拷贝路径或打印模板' }
}
const taskId = genTaskId()
let udfFile = ''
if (hasPrint) {
const rows = printFieldRows(form)
if (rows.length > 0) {
const csv = await fsWriteJobCsv({ taskId, rows })
if (!csv.ok || !csv.data?.path) {
return { ok: false, message: csv.message || '生成打印变量 CSV 失败' }
}
udfFile = csv.data.path
}
}
const json = JSON.stringify(buildJobConfig(form, { taskId, udfFile }))
const created = await dllJobCreate(json, opts)
if (!created.ok || !created.data?.jobId) {
return { ok: false, message: created.message || '创建任务失败' }
}
return { ok: true, jobId: created.data.jobId }
}
-4
View File
@@ -58,7 +58,3 @@ export function mapJobStateToUi(jobState: number) {
} }
} }
} }
export function shouldUseProgress(jobState: number): boolean {
return jobState === 2 || jobState === 3
}
@@ -1,8 +1,13 @@
import type { DistributeFormState } from '@/stores/distributeForm' import type { DistributeFormState } from '@/stores/distributeForm'
export function validateJobConfig(f: DistributeFormState): string | null { export function resolveJobTasks(f: DistributeFormState): { hasCopy: boolean; hasPrint: boolean } {
const hasCopy = f.pathList.length > 0 const hasCopy = f.pathList.some((p) => !!p.path.trim())
const hasPrint = !!f.templateFile.trim() const hasPrint = !!f.templateFile.trim()
return { hasCopy, hasPrint }
}
export function validateJobConfig(f: DistributeFormState): string | null {
const { hasCopy, hasPrint } = resolveJobTasks(f)
if (!hasCopy && !hasPrint) return '请配置拷贝路径或打印模板' if (!hasCopy && !hasPrint) return '请配置拷贝路径或打印模板'
if (hasPrint && !/\.soon$/i.test(f.templateFile.trim())) return '请选择 .soon 模板' if (hasPrint && !/\.soon$/i.test(f.templateFile.trim())) return '请选择 .soon 模板'
if (hasCopy && f.pathList.some((p) => !p.path.trim())) return '路径不能为空' if (hasCopy && f.pathList.some((p) => !p.path.trim())) return '路径不能为空'
@@ -0,0 +1,33 @@
import type { DistributeFormState } from '@/stores/distributeForm'
import { fsPathExists } from '@/api/cardsoon'
import { resolveJobTasks, validateJobConfig } from '@/utils/validateJobConfig'
function totalCopyBytes(form: DistributeFormState): number {
return form.pathList.reduce((sum, item) => sum + (item.sizeBytes || 0), 0)
}
/** 提交/续做前:配置 + 路径存在性 + 拷贝体积 */
export async function validateJobPreflight(form: DistributeFormState): Promise<string | null> {
const err = validateJobConfig(form)
if (err) return err
const { hasCopy, hasPrint } = resolveJobTasks(form)
if (hasCopy) {
const paths = form.pathList.map((x) => x.path)
const ex = await fsPathExists(paths)
if (ex.ok && ex.data?.missing.length) {
return `路径不存在: ${ex.data.missing.join(', ')}`
}
if (totalCopyBytes(form) <= 0) {
return '拷贝路径下没有可拷贝的文件'
}
}
if (hasPrint) {
const soon = form.templateFile.trim()
const ex = await fsPathExists([soon])
if (ex.ok && ex.data?.missing.length) {
return '模板文件不存在'
}
}
return null
}
@@ -21,7 +21,9 @@
数据导入地址 数据导入地址
</h3> </h3>
<div class="m-path-box"> <div class="m-path-box">
<span class="m-path-text">{{ collectStore.destPath || '未选择目录' }}</span> <span class="m-path-text" :title="collectStore.destPath || undefined">{{
collectStore.destPath || '未选择目录'
}}</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" />
@@ -63,7 +65,7 @@ import NavButton from '@/components/NavButton.vue'
import AppIcon from '@/components/AppIcon.vue' 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, pollUsbStart } from '@/api/cardsoon' import { dialogOpenDirectory, dllUsbCopy } from '@/api/cardsoon'
const router = useRouter() const router = useRouter()
const collectStore = useCollectStore() const collectStore = useCollectStore()
@@ -100,11 +102,10 @@ async function onSubmit(): Promise<void> {
} }
const r = await dllUsbCopy(dest, collectStore.cardOutput) const r = await dllUsbCopy(dest, collectStore.cardOutput)
if (!r.ok) { if (!r.ok) {
notify.error(r.message || '可能已有任务在执行') notify.error(r.message || '启动 USB 收集失败')
return return
} }
appStore.setMode('usbCopying') appStore.setMode('usbCopying')
await pollUsbStart()
await router.push('/collect/running') await router.push('/collect/running')
} }
@@ -79,7 +79,7 @@
<div class="c-panel__header"> <div class="c-panel__header">
<span class="c-panel__title">标签预览</span> <span class="c-panel__title">标签预览</span>
<div class="c-nav-group"> <div class="c-nav-group">
<button type="button" class="c-button-cs" :disabled="!canUse" @click="pickTemplate"> <button type="button" class="c-button-cs" @click="pickTemplate">
添加标签 添加标签
</button> </button>
</div> </div>
@@ -137,10 +137,16 @@ 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 { validateJobConfig } from '@/utils/validateJobConfig' import { validateJobPreflight } from '@/utils/validateJobPreflight'
import { buildJobConfig } from '@/utils/buildJobConfig' import { createDistributeJob } from '@/utils/createDistributeJob'
import { formatBytesAsGb, formatBytesCompact } from '@/utils/formatBytes' import { formatBytesAsGb, formatBytesCompact } from '@/utils/formatBytes'
import { dialogOpenDirectory, dialogOpenSoon, dllJobCreate, fsDirSize, fsParseSoon, fsPathExists } from '@/api/cardsoon' import {
dialogOpenDirectory,
dialogOpenSoon,
dllJobCancel,
fsDirSize,
fsParseSoon
} from '@/api/cardsoon'
const router = useRouter() const router = useRouter()
const formStore = useDistributeFormStore() const formStore = useDistributeFormStore()
@@ -158,7 +164,8 @@ const totalLoadedBytes = computed(() =>
const loadPercent = computed(() => { const loadPercent = computed(() => {
if (!formStore.pathList.length) return 0 if (!formStore.pathList.length) return 0
return Math.min(100, Math.round((totalLoadedBytes.value / CARD_CAPACITY_BYTES) * 100)) const pct = Math.min(100, Math.round((totalLoadedBytes.value / CARD_CAPACITY_BYTES) * 100))
return pct > 0 ? pct : 1
}) })
const hasTemplatePreview = computed( const hasTemplatePreview = computed(
@@ -219,10 +226,6 @@ function removePath(idx: number): void {
} }
async function pickTemplate(): Promise<void> { async function pickTemplate(): Promise<void> {
if (!canUse.value) {
notifyRequireInit('选择打印模板')
return
}
const r = await dialogOpenSoon() const r = await dialogOpenSoon()
if (!r.ok) { if (!r.ok) {
notify.error(r.message || '打开模板选择失败') notify.error(r.message || '打开模板选择失败')
@@ -255,37 +258,29 @@ async function onSubmit(): Promise<void> {
return return
} }
if (jobStore.submitting) return if (jobStore.submitting) return
const err = validateJobConfig(formStore) const err = await validateJobPreflight(formStore)
if (err) { if (err) {
notify.warning(err) notify.warning(err)
return return
} }
const paths = formStore.pathList.map((x) => x.path)
if (paths.length) {
const ex = await fsPathExists(paths)
if (ex.ok && ex.data?.missing.length) {
notify.error(`路径不存在: ${ex.data.missing.join(', ')}`)
return
}
}
if (formStore.templateFile.trim()) {
const ex = await fsPathExists([formStore.templateFile.trim()])
if (ex.ok && ex.data?.missing.length) {
notify.error('模板文件不存在')
return
}
}
jobStore.submitting = true jobStore.submitting = true
try { try {
const json = JSON.stringify(buildJobConfig(formStore)) const created = await createDistributeJob(formStore)
const created = await dllJobCreate(json) if (!created.ok) {
if (!created.ok || !created.data?.jobId) { notify.error(created.message)
notify.error(created.message || '创建任务失败')
return return
} }
jobStore.setActiveJob(created.data.jobId) const newJobId = created.jobId
jobStore.setActiveJob(newJobId)
appStore.setMode('distributing') appStore.setMode('distributing')
try {
await router.push('/distribute/running') await router.push('/distribute/running')
} catch {
await dllJobCancel(newJobId)
jobStore.clearActiveJob()
appStore.setMode('ready')
notify.error('无法进入运行页,已取消任务')
}
} finally { } finally {
jobStore.submitting = false jobStore.submitting = false
} }
@@ -54,8 +54,13 @@ const errorText = ref('')
onMounted(async () => { onMounted(async () => {
appStore.setMode('ready') appStore.setMode('ready')
await pollJobStop() await pollJobStop()
jobStore.clearActiveJob()
try {
const r = await dllPrinterErrorStr(-1) const r = await dllPrinterErrorStr(-1)
errorText.value = r.ok && r.data?.text ? r.data.text : '' errorText.value = r.ok && r.data?.text ? r.data.text : ''
} catch {
errorText.value = ''
}
}) })
function onBack(): void { function onBack(): void {
@@ -1,13 +1,37 @@
<template> <template>
<AppShell> <AppShell>
<AppHeader :mode="headerMode"> <AppHeader :mode="headerMode">
<NavButton icon="stop" label="停止" variant="stop" @click="onStop" /> <div v-if="phase === 'failed'" class="c-nav-group">
<NavButton icon="arrow-left" label="返回" @click="onFailedBack" />
<NavButton icon="redo" label="重置" variant="primary" @click="onFailedReset" />
</div>
<NavButton
v-else-if="phase === 'completed'"
icon="home"
label="返回"
@click="onReturn"
/>
<NavButton v-else icon="stop" label="停止" 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">
<h2 class="c-status-title is-looping">{{ statusTitle }}</h2> <template v-if="phase === 'failed'">
<h2 class="c-status-title is-error">任务失败</h2>
<p class="c-status-sub">{{ failedSub }}</p>
<p class="c-status-counter">
任务已经完成<span class="ok">{{ successCount }}</span>其中失败次数是<span
class="err"
>{{ failCount }}</span
>
</p>
<p v-if="failureErrorText" class="m-error-detail">{{ failureErrorText }}</p>
</template>
<template v-else>
<h2 class="c-status-title" :class="{ 'is-looping': phase === 'running' }">
{{ statusTitle }}
</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 任务已经完成<span class="ok">{{ successCount }}</span>其中失败次数是<span
@@ -15,15 +39,26 @@
>{{ failCount }}</span >{{ failCount }}</span
> >
</p> </p>
</template>
</div> </div>
<WorkflowSteps <WorkflowSteps
v-if="phase === 'failed'"
mode="failed"
:variant="isCollect ? 'collect' : 'distribute'"
:failed-step="failureStep"
/>
<WorkflowSteps
v-else
:active-step="workflowStep" :active-step="workflowStep"
:variant="isCollect ? 'collect' : 'distribute'" :variant="isCollect ? 'collect' : 'distribute'"
mode="running" mode="running"
/> />
</div> </div>
<div class="m-right-panel"> <div class="m-right-panel">
<div class="m-progress-circle"> <div v-if="phase === 'failed'" class="m-error-icon">
<AppIcon name="warning" size="xl" />
</div>
<div v-else class="m-progress-circle">
<svg viewBox="0 0 100 100"> <svg viewBox="0 0 100 100">
<circle class="bg" cx="50" cy="50" r="45" /> <circle class="bg" cx="50" cy="50" r="45" />
<circle <circle
@@ -51,20 +86,36 @@ import AppShell from '@/layouts/AppShell.vue'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue' import AppFooter from '@/components/AppFooter.vue'
import NavButton from '@/components/NavButton.vue' import NavButton from '@/components/NavButton.vue'
import AppIcon from '@/components/AppIcon.vue'
import WorkflowSteps from '@/components/WorkflowSteps.vue' import WorkflowSteps from '@/components/WorkflowSteps.vue'
import { useJobStore } from '@/stores/job' import { useJobStore } from '@/stores/job'
import { useCollectStore } from '@/stores/collect' import { useCollectStore } from '@/stores/collect'
import { useDistributeFormStore } from '@/stores/distributeForm'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { import {
dllJobCancel, dllJobCancel,
dllPrinterErrorStr,
dllUsbCopy,
onCardPositionTick,
onJobPollTick, onJobPollTick,
onUsbPollTick, onUsbPollTick,
pollCardPositionStart,
pollCardPositionStop,
pollJobStart, pollJobStart,
pollJobStop, pollJobStop,
pollUsbStop pollUsbStop
} from '@/api/cardsoon' } from '@/api/cardsoon'
import { mapJobStateToUi, shouldUseProgress } from '@/utils/job-state' import { createDistributeJob } from '@/utils/createDistributeJob'
import type { JobPollPayload, UsbPollPayload } from '@/types/ipc' import { validateJobPreflight } from '@/utils/validateJobPreflight'
import { mapJobStateToUi } from '@/utils/job-state'
import { POSITION_PREPARE } from '@shared/card-position'
import {
USB_TASK_COPYING,
USB_TASK_PREPARING,
clampUsbCopyProgress,
usbTaskStatusHint
} from '@shared/usb-copy-state'
import type { CardPositionPollPayload, JobPollPayload, UsbPollPayload } from '@/types/ipc'
const CIRCLE_LEN = 283 const CIRCLE_LEN = 283
@@ -72,6 +123,7 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const jobStore = useJobStore() const jobStore = useJobStore()
const collectStore = useCollectStore() const collectStore = useCollectStore()
const formStore = useDistributeFormStore()
const appStore = useAppStore() const appStore = useAppStore()
const isCollect = computed(() => route.name === 'collect-running') const isCollect = computed(() => route.name === 'collect-running')
@@ -81,100 +133,277 @@ const successCount = computed(() =>
) )
const failCount = computed(() => (isCollect.value ? collectStore.failCount : jobStore.failCount)) const failCount = computed(() => (isCollect.value ? collectStore.failCount : jobStore.failCount))
const phase = ref<'running' | 'completed' | 'failed'>('running')
const progress = ref(0) const progress = ref(0)
const strokeOffset = ref(CIRCLE_LEN) const strokeOffset = ref(CIRCLE_LEN)
const workflowStep = ref(1) const workflowStep = ref(1)
const failureStep = ref(1)
const failureErrorText = ref('')
const waitCard = ref(false) const waitCard = ref(false)
let unsub: (() => void) | null = null const collectHint = ref('')
let fakeTimer: ReturnType<typeof setInterval> | null = null let unsubJob: (() => void) | null = null
let unsubUsb: (() => void) | null = null
let unsubCard: (() => void) | null = null
let finishing = false
let resubmitting = false
let lastCardPosition = -1
let usbAwaitNewCycle = false
let queryFailStreak = 0
let usbQueryFailStreak = 0
const displayProgress = computed(() => Math.round(progress.value)) const displayProgress = computed(() => Math.round(progress.value))
const statusTitle = computed(() => (waitCard.value ? '等待插卡' : '循环执行中')) const failedSub = computed(() =>
const statusSub = computed(() => isCollect.value ? '请检查读卡器与卡片后重新尝试' : '请检查设备故障后重新插入数据卡'
waitCard.value ? '请插入数据卡,任务将自动连续执行' : '任务将自动连续执行'
) )
const statusTitle = computed(() => {
if (phase.value === 'completed') {
return isCollect.value ? '收集已完成' : '任务已完成'
}
if (isCollect.value) {
return collectHint.value || '数据收集中'
}
return waitCard.value ? '等待插卡' : '任务执行中'
})
const statusSub = computed(() => {
if (phase.value === 'completed') {
return '请点击返回,或插入数据卡继续下一张'
}
if (isCollect.value) {
return collectHint.value || '正在从卡片读取并写入导入目录'
}
return waitCard.value ? '请插入数据卡,任务将自动执行' : '任务执行中,请稍候'
})
function setProgress(value: number): void { function setProgress(value: number): void {
const p = Math.min(100, Math.max(0, value)) const p = Math.min(100, Math.max(0, value))
progress.value = p progress.value = p
strokeOffset.value = CIRCLE_LEN - (CIRCLE_LEN * p) / 100 strokeOffset.value = CIRCLE_LEN - (CIRCLE_LEN * p) / 100
} }
function startFakeProgress(): void { function clearPollListeners(): void {
stopFakeProgress() unsubJob?.()
fakeTimer = setInterval(() => { unsubJob = null
if (progress.value >= 95) return unsubUsb?.()
setProgress(progress.value + 1.5 + Math.random() * 2.5) unsubUsb = null
}, 380) unsubCard?.()
unsubCard = null
} }
function stopFakeProgress(): void { /** 停止主进程轮询;resetMode=false 时保留 usbCopying/distributing 会话(完成态续做) */
if (fakeTimer) { async function releasePolls(resetMode: boolean): Promise<void> {
clearInterval(fakeTimer) clearPollListeners()
fakeTimer = null await pollCardPositionStop()
if (isCollect.value) {
await pollUsbStop({ resetMode })
} else {
await pollJobStop({ resetMode })
}
}
async function enterFailedPhase(errorText = ''): Promise<void> {
if (phase.value === 'failed') return
phase.value = 'failed'
failureStep.value = workflowStep.value
failureErrorText.value = errorText
if (!failureErrorText.value && !isCollect.value) {
try {
const r = await dllPrinterErrorStr(-1)
failureErrorText.value = r.ok && r.data?.text ? r.data.text : ''
} catch {
failureErrorText.value = ''
}
}
await releasePolls(true)
jobStore.clearActiveJob()
appStore.setMode('ready')
}
async function enterCompletedPhase(): Promise<void> {
if (phase.value === 'completed') return
phase.value = 'completed'
setProgress(100)
if (isCollect.value) {
workflowStep.value = 3
await pollUsbStop({ resetMode: false })
} else {
workflowStep.value = 4
await pollJobStop({ resetMode: false })
}
const cardStarted = await pollCardPositionStart()
if (!cardStarted.ok) {
notify.warning(cardStarted.message || '无法监控卡位,请手动点击返回或重新提交')
}
if (!unsubCard) {
unsubCard = onCardPositionTick((payload) =>
applyCardPosition(payload as CardPositionPollPayload)
)
}
}
function applyCardPosition(p: CardPositionPollPayload): void {
if (phase.value !== 'completed' || resubmitting) return
if (p.queryCode !== 0) return
const pos = p.position
const enteredPrepare = pos === POSITION_PREPARE && lastCardPosition !== POSITION_PREPARE
lastCardPosition = pos
if (!enteredPrepare) return
void resubmitTask()
}
async function resubmitTask(): Promise<void> {
if (resubmitting || phase.value !== 'completed') return
resubmitting = true
await pollCardPositionStop()
phase.value = 'running'
setProgress(0)
workflowStep.value = 1
collectHint.value = ''
waitCard.value = false
try {
if (isCollect.value) {
const dest = collectStore.destPath.trim()
if (!dest) {
notify.error('导入目录无效,无法继续')
await enterCompletedPhase()
return
}
const r = await dllUsbCopy(dest, collectStore.cardOutput, { resubmit: true })
if (!r.ok) {
notify.error(r.message || '重新提交收集任务失败')
await enterCompletedPhase()
return
}
usbAwaitNewCycle = true
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
if (!unsubUsb) {
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
}
return
}
const err = await validateJobPreflight(formStore)
if (err) {
notify.warning(err)
await enterCompletedPhase()
return
}
const created = await createDistributeJob(formStore, { resubmit: true })
if (!created.ok) {
notify.error(created.message)
await enterCompletedPhase()
return
}
jobStore.setActiveJob(created.jobId)
const started = await pollJobStart(created.jobId)
if (!started.ok) {
notify.error(started.message || '启动任务轮询失败')
await enterCompletedPhase()
return
}
if (!unsubJob) {
unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
}
} finally {
resubmitting = false
} }
} }
function applyJobProgress(p: JobPollPayload): void { function applyJobProgress(p: JobPollPayload): void {
if (finishing || phase.value !== 'running') return
if (p.queryErrorCode !== 0) {
queryFailStreak += 1
if (queryFailStreak < 3) return
jobStore.failCount += 1
void enterFailedPhase(`查询任务失败: ${p.queryErrorCode}`)
return
}
queryFailStreak = 0
setProgress(p.progress)
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 (shouldUseProgress(p.jobState)) {
setProgress(p.progress)
}
if (p.queryErrorCode !== 0) {
notify.error(`查询任务失败: ${p.queryErrorCode}`)
finishDistribute(false)
return
}
if (p.failed) { if (p.failed) {
jobStore.failCount += 1 jobStore.failCount += 1
router.push('/distribute/failed') void enterFailedPhase()
return return
} }
if (p.cancelled) { if (p.cancelled) {
finishDistribute(false) void finishDistribute(false)
return return
} }
if (p.finished) { if (p.finished) {
jobStore.successCount += 1 jobStore.successCount += 1
void enterCompletedPhase()
return
} }
} }
function applyUsbProgress(p: UsbPollPayload): void { function applyUsbProgress(p: UsbPollPayload): void {
if (p.taskStatus === 1) workflowStep.value = 2 if (finishing || phase.value !== 'running') return
if (p.taskStatus === 1 && p.progress > 0) { if (p.queryCode !== 0) {
setProgress(Math.max(progress.value, p.progress)) usbQueryFailStreak += 1
if (usbQueryFailStreak < 3) return
collectStore.failCount += 1
void enterFailedPhase(`查询 USB 任务失败: ${p.queryCode}`)
return
} }
usbQueryFailStreak = 0
if (p.failed) { if (p.failed) {
collectStore.failCount += 1 collectStore.failCount += 1
notify.error('USB 收集失败') void enterFailedPhase(p.errorMessage || usbTaskStatusHint(p.taskStatus))
finishCollect(false)
return return
} }
if (p.success) { if (p.success) {
if (usbAwaitNewCycle) return
collectStore.successCount += 1 collectStore.successCount += 1
setProgress(100)
workflowStep.value = 3 workflowStep.value = 3
collectHint.value = usbTaskStatusHint(p.taskStatus)
notify.success('USB 收集完成') notify.success('USB 收集完成')
window.setTimeout(() => finishCollect(true), 600) void enterCompletedPhase()
return
}
const copyProgress = clampUsbCopyProgress(p.progress)
workflowStep.value = p.taskStatus === USB_TASK_COPYING ? 2 : 1
collectHint.value = usbTaskStatusHint(p.taskStatus)
setProgress(copyProgress)
if (usbAwaitNewCycle && p.taskStatus === USB_TASK_PREPARING) {
usbAwaitNewCycle = false
} }
} }
function finishDistribute(toHome: boolean): void { async function finishDistribute(
stopFakeProgress() toHome: boolean,
pollJobStop() redirect: '/home' | '/distribute/config' = toHome ? '/home' : '/distribute/config'
): Promise<void> {
if (finishing) return
finishing = true
const skipCancel = phase.value === 'completed'
await releasePolls(true)
const id = jobStore.jobId
if (id && !skipCancel) {
const r = await dllJobCancel(id)
if (!r.ok) {
notify.warning(r.message || '取消任务时出现问题')
}
}
jobStore.clearActiveJob() jobStore.clearActiveJob()
appStore.setMode('ready') appStore.setMode('ready')
router.push(toHome ? '/home' : '/distribute/config') await router.push(redirect)
} }
function finishCollect(toHome: boolean): void { async function finishCollect(toHome: boolean): Promise<void> {
stopFakeProgress() if (finishing) return
pollUsbStop() finishing = true
await releasePolls(true)
appStore.setMode('ready') appStore.setMode('ready')
router.push(toHome ? '/home' : '/collect') await router.push(toHome ? '/home' : '/collect')
} }
onMounted(async () => { onMounted(async () => {
@@ -185,8 +414,8 @@ onMounted(async () => {
} }
workflowStep.value = 1 workflowStep.value = 1
setProgress(0) setProgress(0)
startFakeProgress() collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
unsub = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload)) unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
return return
} }
@@ -195,34 +424,74 @@ onMounted(async () => {
return return
} }
appStore.setMode('distributing') appStore.setMode('distributing')
await pollJobStart(jobStore.jobId) setProgress(0)
unsub = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload)) const started = await pollJobStart(jobStore.jobId)
if (!started.ok) {
notify.error(started.message || '启动任务轮询失败')
await finishDistribute(false)
return
}
unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
}) })
onUnmounted(() => { onUnmounted(() => {
unsub?.() if (finishing || phase.value !== 'running') return
stopFakeProgress() void releasePolls(true)
if (isCollect.value) { if (!isCollect.value && appStore.mode === 'distributing') {
if (appStore.mode === 'usbCopying') pollUsbStop()
} else if (appStore.mode === 'distributing') {
pollJobStop()
appStore.setMode('ready') appStore.setMode('ready')
} }
}) })
async function onStop(): Promise<void> { async function onFailedBack(): Promise<void> {
finishing = true
if (isCollect.value) { if (isCollect.value) {
finishCollect(false) await router.push('/collect')
return return
} }
await dllJobCancel(jobStore.jobId) await router.push('/distribute/config')
finishDistribute(false) }
async function onFailedReset(): Promise<void> {
finishing = true
if (isCollect.value) {
collectStore.reset()
} else {
formStore.reset()
jobStore.reset()
}
await router.push('/home')
}
async function onReturn(): Promise<void> {
if (isCollect.value) {
await finishCollect(false)
return
}
await finishDistribute(false)
}
async function onStop(): Promise<void> {
if (isCollect.value) {
await finishCollect(false)
return
}
await finishDistribute(false)
} }
</script> </script>
<style src="@/styles/pages/page2.css"></style> <style src="@/styles/pages/page2.css"></style>
<style src="@/styles/pages/page3.css"></style>
<style scoped> <style scoped>
.m-error-detail {
margin-top: 6px;
font-size: 10px;
color: #dc3545;
font-weight: 600;
max-width: 320px;
word-break: break-all;
}
.m-progress-circle .fill { .m-progress-circle .fill {
stroke-dasharray: 283; stroke-dasharray: 283;
transition: stroke-dashoffset 0.45s ease; transition: stroke-dashoffset 0.45s ease;
+10 -3
View File
@@ -49,9 +49,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { notify, notifyRequireInit } from '@/composables/useNotify' import { notify, notifyRequireInit } from '@/composables/useNotify'
import { refreshPrinterHeader } from '@/composables/usePrinterStatus'
import AppShell from '@/layouts/AppShell.vue' import AppShell from '@/layouts/AppShell.vue'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue' import AppFooter from '@/components/AppFooter.vue'
@@ -74,8 +75,10 @@ function guardInit(action?: string): boolean {
async function onReset(): Promise<void> { async function onReset(): Promise<void> {
if (!guardInit('重置打印机')) return if (!guardInit('重置打印机')) return
const r = await dllPrinterReset() const r = await dllPrinterReset()
if (r.ok) notify.success('已发送重置指令') if (r.ok) {
else notify.error(r.message || '重置失败') notify.success('已发送重置指令')
await refreshPrinterHeader(configStore)
} else notify.error(r.message || '重置失败')
} }
async function onReject(): Promise<void> { async function onReject(): Promise<void> {
@@ -119,6 +122,10 @@ function goCollect(): void {
if (!guardBusy()) return if (!guardBusy()) return
router.push('/collect') router.push('/collect')
} }
onMounted(() => {
if (canUse.value) void refreshPrinterHeader(configStore)
})
</script> </script>
<style src="@/styles/pages/page2.css"></style> <style src="@/styles/pages/page2.css"></style>
+2
View File
@@ -0,0 +1,2 @@
/** workDll 卡位:备卡位,检测到后可自动重提任务 */
export const POSITION_PREPARE = 13
+4
View File
@@ -0,0 +1,4 @@
/** RestJobEx JSON 根字段 task_id */
export function genTaskId(): string {
return `T${Date.now()}`
}
+65 -16
View File
@@ -1,4 +1,3 @@
/** DLL GetPrinterInfo JSON → Header 展示字段(与 mocks/printer 一致) */
export interface PrinterStatusSnapshot { export interface PrinterStatusSnapshot {
ribbonType: string ribbonType: string
statusText: string statusText: string
@@ -6,28 +5,78 @@ export interface PrinterStatusSnapshot {
printedCount: number printedCount: number
} }
export function parsePrinterInfoFromDll(json: Record<string, unknown>): PrinterStatusSnapshot { const PRINTER_STATUS_MAP: Record<string, string> = {
const list = (json.printerList as Record<string, unknown>[]) || [] I: '空闲',
const p = list[0] || {} B: '忙碌',
const serial = P: '正在打印'
p.szPrinterSerial ?? p.PrinterSerial ?? p.SerialNo ?? p.PrinterName ?? '—' }
let statusText = '—' function pickFirst(obj: Record<string, unknown>, keys: string[]): unknown {
const direct = p.PrinterType ?? p.PrinterStatus ?? p.Status for (const k of keys) {
if (direct != null && String(direct).trim() !== '') { const v = obj[k]
statusText = String(direct) if (v != null && String(v).trim() !== '') return v
} else { }
const remain = p.RibbonRemain ?? p.RemainCount return undefined
const capacity = p.RibbonCapacity ?? p.Capacity ?? p.MaxCount }
function normalizeStatus(raw: unknown): string {
if (raw == null) return '—'
if (typeof raw === 'number' && raw > 0 && raw < 128) {
const c = String.fromCharCode(raw)
return PRINTER_STATUS_MAP[c] ?? c
}
const s = String(raw).trim()
if (!s) return '—'
return PRINTER_STATUS_MAP[s] ?? s
}
function snapshotFromRecord(row: Record<string, unknown>): PrinterStatusSnapshot {
const serial = pickFirst(row, [
'serial_no',
'SerialNo',
'serialNo',
'szPrinterSerial',
'PrinterSerial',
'PrinterName'
])
const statusRaw = pickFirst(row, [
'printer_status',
'PrinterStatus',
'PrinterType',
'Status'
])
const ribbon = pickFirst(row, ['ribbon_type', 'RibbonType', 'RibbonAmount'])
const printed = pickFirst(row, ['printed_count', 'PrintedCount', 'PrintCount', 'printedCount'])
let statusText = normalizeStatus(statusRaw)
if (statusText === '—') {
const remain = row.RibbonRemain ?? row.RemainCount
const capacity = row.RibbonCapacity ?? row.Capacity ?? row.MaxCount
if (remain != null && capacity != null) { if (remain != null && capacity != null) {
statusText = `${remain}/${capacity}` statusText = `${remain}/${capacity}`
} }
} }
return { return {
ribbonType: String(p.RibbonType ?? '—'), ribbonType: String(ribbon ?? '—'),
statusText, statusText,
serialNo: String(serial), serialNo: String(serial ?? '—'),
printedCount: Number(p.PrintedCount ?? p.PrintCount ?? 0) printedCount: Number(printed ?? 0)
} }
} }
/** SAPI_GetPrinterInfoEx(扁平 JSON)与 SAPI_GetPrinterInfoprinterList */
export function parsePrinterInfoFromDll(json: Record<string, unknown>): PrinterStatusSnapshot {
const flatSerial = pickFirst(json, ['serial_no', 'SerialNo', 'serialNo', 'szPrinterSerial'])
const flatStatus = pickFirst(json, ['printer_status', 'PrinterStatus'])
if (flatSerial != null || flatStatus != null) {
return snapshotFromRecord(json)
}
const list = (json.printerList as Record<string, unknown>[]) || []
if (list.length > 0) {
return snapshotFromRecord(list[0] as Record<string, unknown>)
}
return snapshotFromRecord(json)
}
+33
View File
@@ -0,0 +1,33 @@
/** SAPI_GetUsbCopyState: task_status0=preparing, 1=copying, 2=completed, 3=failed */
export const USB_TASK_PREPARING = 0
export const USB_TASK_COPYING = 1
export const USB_TASK_COMPLETED = 2
export const USB_TASK_FAILED = 3
/** @deprecated 使用 USB_TASK_PREPARING */
export const USB_TASK_IDLE = USB_TASK_PREPARING
/** @deprecated 使用 USB_TASK_COMPLETED */
export const USB_TASK_SUCCESS = USB_TASK_COMPLETED
export function clampUsbCopyProgress(value: number): number {
return Math.min(100, Math.max(0, Math.round(value)))
}
export function usbTaskStatusHint(status: number): string {
switch (status) {
case USB_TASK_PREPARING:
return '准备读取数据卡,请确认卡片已插入读卡位'
case USB_TASK_COPYING:
return '正在从卡片拷贝数据'
case USB_TASK_COMPLETED:
return '拷贝完成'
case USB_TASK_FAILED:
return 'USB 收集失败:未检测到卡片存储、读卡失败或无法移动到 USB 读卡位'
default:
return `USB 任务状态异常 (taskStatus=${status})`
}
}
export function isUsbCopyTerminal(status: number): boolean {
return status === USB_TASK_COMPLETED || status === USB_TASK_FAILED
}
File diff suppressed because one or more lines are too long