完善分发与收集任务全流程
- RestJobEx 对齐 task_id,path_file 提交目录路径 - 运行页进度仅跟接口轮询,失败态停留 page3 UI - 完成态卡位续做,USB/任务轮询生命周期优化 - 拆分 DLL 加载、任务 staging 与提交前校验 - 移除 mock 与冗余样式,补充 native 依赖 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@ node_modules/
|
||||
dist/
|
||||
out/
|
||||
release/
|
||||
*.tsbuildinfo
|
||||
*.log
|
||||
.env
|
||||
.env.*
|
||||
|
||||
+4
-1
@@ -9,7 +9,6 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:dll": "electron-vite dev -- --with-dll",
|
||||
"build": "electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.web.json",
|
||||
@@ -33,6 +32,10 @@
|
||||
"build": {
|
||||
"appId": "com.cardsoon.machine",
|
||||
"productName": "卡树数据卡打印系统",
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
"**/node_modules/koffi/**"
|
||||
],
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"designAppPath": "C:\\myData\\projects\\sideline\\shanghaikashu\\SoonMachine\\app\\release\\卡树数据卡打印系统-0.0.1-win\\卡树数据卡打印系统.exe"
|
||||
}
|
||||
@@ -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.
@@ -9,13 +9,15 @@ const required = [
|
||||
'dcrf32.dll',
|
||||
'Entry.dll',
|
||||
'libpng16.dll',
|
||||
'zint.dll'
|
||||
'zint.dll',
|
||||
'freetype.dll',
|
||||
'opencv_world490d.dll'
|
||||
]
|
||||
|
||||
const missing = required.filter((name) => !fs.existsSync(path.join(nativeDir, name)))
|
||||
if (missing.length) {
|
||||
console.error(`resources/native 缺少: ${missing.join(', ')}`)
|
||||
console.error('请从 docs/API/lib 复制 7 个 dll(不含 .lib)')
|
||||
console.error('请从 docs/API/lib 复制完整 native 依赖(不含 .lib)')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('resources/native: 7 dll 齐全')
|
||||
console.log(`resources/native: ${required.length} dll 齐全`)
|
||||
|
||||
+48
-18
@@ -4,14 +4,29 @@ import { join } from 'path'
|
||||
app.commandLine.appendSwitch('disable-gpu-shader-disk-cache')
|
||||
|
||||
import log from 'electron-log'
|
||||
|
||||
if (app.isPackaged) {
|
||||
app.disableHardwareAcceleration()
|
||||
}
|
||||
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock()
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.quit()
|
||||
}
|
||||
|
||||
import { suppressKnownDllStderr } from './utils/suppress-dll-stderr'
|
||||
import { loadAppFileConfig } from './services/app-config'
|
||||
import { migrateTraceConfig, setTraceWebContents } from './utils/trace-bridge'
|
||||
import { setupNativeWorkingDir } from './services/native-path'
|
||||
import { configStore } from './services/config-store'
|
||||
|
||||
suppressKnownDllStderr()
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
log.error('uncaughtException', err)
|
||||
dialog.showErrorBox('程序异常', err instanceof Error ? err.message : String(err))
|
||||
})
|
||||
|
||||
import { registerIpcHandlers, handleBeforeQuit } from './ipc/register-handlers'
|
||||
import { ensureDllInitialized } from './services/dll-bootstrap'
|
||||
import { setPollMainWindow } from './services/poll-manager'
|
||||
import { DESIGN_WIDTH, DESIGN_HEIGHT, contentHeightForWidth } from '@shared/viewport'
|
||||
|
||||
@@ -19,7 +34,13 @@ let mainWindow: BrowserWindow | null = null
|
||||
|
||||
const MIN_CONTENT_WIDTH = 960
|
||||
|
||||
/** 默认内容区:约 85% 工作区宽,高 720:360 */
|
||||
function focusMainWindow(): void {
|
||||
if (!mainWindow) return
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
function getDefaultWindowSize(): { width: number; height: number } {
|
||||
const { width: sw, height: sh } = screen.getPrimaryDisplay().workAreaSize
|
||||
let w = Math.max(1280, Math.min(Math.floor(sw * 0.85), 1600))
|
||||
@@ -71,7 +92,6 @@ function createWindow(): void {
|
||||
}
|
||||
})
|
||||
|
||||
// 内容区 720:360,与 useScale 一致
|
||||
mainWindow.on('resize', () => {
|
||||
if (!mainWindow) return
|
||||
const [cw, ch] = mainWindow.getContentSize()
|
||||
@@ -86,6 +106,14 @@ function createWindow(): void {
|
||||
mainWindow = null
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
log.error('render-process-gone', details)
|
||||
dialog.showErrorBox(
|
||||
'界面进程异常退出',
|
||||
`reason=${details.reason} exitCode=${details.exitCode}\n请查看 %APPDATA%\\cardsoon-machine\\logs\\main.log`
|
||||
)
|
||||
})
|
||||
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||
} else {
|
||||
@@ -93,24 +121,26 @@ function createWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
try {
|
||||
if (app.isPackaged) {
|
||||
configStore.set('skipDllInit', false)
|
||||
} else {
|
||||
const withDll =
|
||||
process.argv.includes('--with-dll') ||
|
||||
process.argv.includes('--no-skip-dll-init')
|
||||
configStore.set('skipDllInit', !withDll)
|
||||
if (!withDll) {
|
||||
log.info('skipDllInit enabled (dev default); use npm run dev:dll to load workDll')
|
||||
}
|
||||
}
|
||||
if (gotSingleInstanceLock) {
|
||||
app.on('second-instance', () => {
|
||||
focusMainWindow()
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
if (!gotSingleInstanceLock) return
|
||||
|
||||
try {
|
||||
migrateTraceConfig()
|
||||
loadAppFileConfig()
|
||||
setupNativeWorkingDir()
|
||||
log.info('app startup', { packaged: app.isPackaged, execPath: process.execPath })
|
||||
registerIpcHandlers()
|
||||
try {
|
||||
const r = await ensureDllInitialized()
|
||||
if (r.warning) log.warn(r.warning)
|
||||
} catch (e) {
|
||||
log.error('startup DLL init failed', e)
|
||||
}
|
||||
createWindow()
|
||||
if (!app.isPackaged) {
|
||||
globalShortcut.register('CommandOrControl+Shift+I', () => {
|
||||
|
||||
@@ -1,34 +1,19 @@
|
||||
import { app, dialog, shell } from 'electron'
|
||||
import { dialog, shell } from 'electron'
|
||||
import fs from 'fs'
|
||||
import log from 'electron-log'
|
||||
import { CS_FAIL, CS_OK } from '../constants'
|
||||
import { assertNotBusy, assertReady, mainAppState } from '../services/app-state'
|
||||
import { configStore } from '../services/config-store'
|
||||
import {
|
||||
startJobPoll,
|
||||
startUsbPoll,
|
||||
stopAllPolls,
|
||||
stopJobPoll,
|
||||
stopUsbPoll,
|
||||
getPollMainWindow
|
||||
} from '../services/poll-manager'
|
||||
import { startUsbPoll, startJobPoll, stopAllPolls, stopJobPoll, stopUsbPoll, stopCardPositionPoll, startCardPositionPoll, getPollMainWindow, isJobPollActive } from '../services/poll-manager'
|
||||
import { parsePrinterInfoFromDll, type PrinterStatusSnapshot } from '@shared/printer-info'
|
||||
import { cleanPathPattern, getDirectorySizeBytes } from '../utils/dir-size'
|
||||
import { getDesignAppPath } from '../services/app-config'
|
||||
import { openDesignApp } from '../services/open-design-app'
|
||||
import { writeJobCsv, type JobCsvRow } from '../utils/job-csv'
|
||||
import { parseSoonTemplate } from '../utils/parse-soon'
|
||||
import {
|
||||
dllAdminJobCancel,
|
||||
dllCopyFromUsb,
|
||||
dllGetPrinterErrorStr,
|
||||
dllGetPrinterInfo,
|
||||
dllInit,
|
||||
dllPrinterReject,
|
||||
dllPrinterReset,
|
||||
dllRestJobEx,
|
||||
isCancelApiAvailable,
|
||||
isRejectApiAvailable
|
||||
} from '../services/work-dll.service'
|
||||
import { stageJobPayloadJson } from '../utils/stage-job-payload'
|
||||
import { ensureDllInitialized, isDllInitAttempted } from '../services/dll-bootstrap'
|
||||
import { loadDllModule } from '../services/dll-loader'
|
||||
import { tracedHandle } from './traced-handler'
|
||||
|
||||
function ok<T>(data?: T) {
|
||||
@@ -39,63 +24,49 @@ function fail(code: number, message: string) {
|
||||
return { ok: false as const, code, message }
|
||||
}
|
||||
|
||||
let dllInitAttempted = false
|
||||
function summarizeStagedPayload(json: string): Record<string, unknown> {
|
||||
try {
|
||||
const p = JSON.parse(json) as Record<string, unknown>
|
||||
return {
|
||||
task_id: p.task_id,
|
||||
has_copy_task: p.has_copy_task,
|
||||
has_print_task: p.has_print_task,
|
||||
path_file_count: Array.isArray(p.path_file) ? p.path_file.length : 0,
|
||||
json_file: p.json_file,
|
||||
udf_file: p.udf_file
|
||||
}
|
||||
} catch {
|
||||
return { parseError: true }
|
||||
}
|
||||
}
|
||||
|
||||
function parseBool(v: unknown): boolean {
|
||||
return v === true || v === 'true' || v === 1 || v === '1' || String(v).toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
tracedHandle('dll:init', (_e, params) => {
|
||||
if (dllInitAttempted) {
|
||||
return ok({
|
||||
skipped: true,
|
||||
printerReady: false,
|
||||
warning: '已初始化,跳过重复 Init'
|
||||
})
|
||||
}
|
||||
tracedHandle('dll:init', async (_e, params) => {
|
||||
stopAllPolls()
|
||||
try {
|
||||
const sharedDir = params?.sharedDir || (configStore.get('sharedDir') as string)
|
||||
fs.mkdirSync(sharedDir, { recursive: true })
|
||||
const code = dllInit({
|
||||
sharedDir,
|
||||
keepCombinedImage: params?.keepCombinedImage,
|
||||
stopOnFailure: params?.stopOnFailure,
|
||||
cleanTaskFile: params?.cleanTaskFile,
|
||||
autoRetryTimes: params?.autoRetryTimes,
|
||||
rejectConfig: params?.rejectConfig,
|
||||
logLevel: params?.logLevel,
|
||||
outBack: params?.outBack
|
||||
})
|
||||
dllInitAttempted = true
|
||||
mainAppState.initialized = true
|
||||
configStore.set('sharedDir', sharedDir)
|
||||
if (code === CS_OK) {
|
||||
return ok({ code, printerReady: true })
|
||||
}
|
||||
return ok({
|
||||
code,
|
||||
printerReady: false,
|
||||
warning: '打印机未连接或驱动未就绪,界面可浏览,接好设备后可在设置中重试 Init'
|
||||
})
|
||||
const r = await ensureDllInitialized(params)
|
||||
return ok({ code: r.code, warning: r.warning })
|
||||
} catch (err) {
|
||||
mainAppState.initialized = false
|
||||
return fail(CS_FAIL, String(err))
|
||||
}
|
||||
})
|
||||
|
||||
tracedHandle('dll:printer-info', () => {
|
||||
tracedHandle('dll:printer-info', async () => {
|
||||
try {
|
||||
assertReady()
|
||||
const r = dllGetPrinterInfo()
|
||||
const dll = await loadDllModule()
|
||||
const r = dll.dllGetPrinterInfo()
|
||||
if (!r.json) {
|
||||
const cached = configStore.get('lastPrinterStatus')
|
||||
if (cached) {
|
||||
return ok({
|
||||
...cached,
|
||||
fromCache: true,
|
||||
liveError: r.code <= 0 ? '未连接打印机' : `GetPrinterInfo code=${r.code}`
|
||||
liveError: r.code <= 0 ? '未连接打印机' : `GetPrinterInfoEx code=${r.code}`
|
||||
})
|
||||
}
|
||||
return fail(0, '未连接打印机')
|
||||
@@ -112,77 +83,139 @@ export function registerIpcHandlers(): void {
|
||||
}
|
||||
})
|
||||
|
||||
tracedHandle('dll:printer-reset', () => {
|
||||
tracedHandle('dll:printer-reset', async () => {
|
||||
try {
|
||||
assertReady()
|
||||
const code = dllPrinterReset()
|
||||
const dll = await loadDllModule()
|
||||
const code = dll.dllPrinterReset()
|
||||
return code === CS_OK ? ok() : fail(code, '重置失败')
|
||||
} catch (err) {
|
||||
return fail(CS_FAIL, String(err))
|
||||
}
|
||||
})
|
||||
|
||||
tracedHandle('dll:printer-reject', () => {
|
||||
tracedHandle('dll:printer-reject', async () => {
|
||||
try {
|
||||
assertReady()
|
||||
if (!isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE')
|
||||
const code = dllPrinterReject()
|
||||
const dll = await loadDllModule()
|
||||
if (!dll.isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE')
|
||||
const code = dll.dllPrinterReject()
|
||||
return code === CS_OK ? ok() : fail(code, '废卡失败')
|
||||
} catch (err) {
|
||||
return fail(CS_FAIL, String(err))
|
||||
}
|
||||
})
|
||||
|
||||
tracedHandle('dll:printer-error-str', (_e, errorNo?: number) => {
|
||||
tracedHandle('dll:printer-error-str', async (_e, errorNo?: number) => {
|
||||
if (!mainAppState.initialized) return ok({ text: '' })
|
||||
try {
|
||||
assertReady()
|
||||
return ok({ text: dllGetPrinterErrorStr(errorNo ?? -1) })
|
||||
const dll = await loadDllModule()
|
||||
return ok({ text: dll.dllGetPrinterErrorStr(errorNo ?? -1) })
|
||||
} catch (err) {
|
||||
return fail(CS_FAIL, String(err))
|
||||
log.warn('dll:printer-error-str', err)
|
||||
return ok({ text: '' })
|
||||
}
|
||||
})
|
||||
|
||||
tracedHandle('dll:job-create', (_e, json: string) => {
|
||||
tracedHandle('dll:job-create', async (_e, json: string, opts?: { resubmit?: boolean }) => {
|
||||
try {
|
||||
assertReady()
|
||||
assertNotBusy()
|
||||
const r = dllRestJobEx(json)
|
||||
if (r.code !== CS_OK) return fail(r.code, 'RestJobEx 失败')
|
||||
if (opts?.resubmit) {
|
||||
if (mainAppState.mode !== 'distributing') {
|
||||
return fail(CS_FAIL, '当前不在分发任务会话中')
|
||||
}
|
||||
stopCardPositionPoll()
|
||||
} else {
|
||||
assertNotBusy()
|
||||
}
|
||||
const dll = await loadDllModule()
|
||||
const sharedDir = configStore.get('sharedDir') as string
|
||||
let staged: { json: string; taskDir: string }
|
||||
try {
|
||||
staged = stageJobPayloadJson(json, sharedDir, dll)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
return fail(CS_FAIL, msg)
|
||||
}
|
||||
log.info('RestJobEx staging', {
|
||||
taskDir: staged.taskDir,
|
||||
summary: summarizeStagedPayload(staged.json)
|
||||
})
|
||||
const r = dll.dllRestJobEx(staged.json)
|
||||
if (r.code !== CS_OK) {
|
||||
log.warn('RestJobEx rejected', { code: r.code, json: staged.json.slice(0, 800) })
|
||||
const errText = dll.dllGetPrinterErrorStr(r.code)
|
||||
let detail = errText ? `${errText} (code=${r.code})` : `RestJobEx 失败 (code=${r.code})`
|
||||
if (r.code === -1 && !errText) {
|
||||
detail +=
|
||||
':请确认模板路径、变量 CSV(udf_file)及拷贝路径有效,且打印机/任务目录已就绪'
|
||||
}
|
||||
return fail(r.code, detail)
|
||||
}
|
||||
if (!r.jobId?.trim()) {
|
||||
return fail(CS_FAIL, 'RestJobEx 未返回 jobId')
|
||||
}
|
||||
mainAppState.mode = 'distributing'
|
||||
mainAppState.activeJobId = r.jobId
|
||||
return ok({ jobId: r.jobId })
|
||||
} catch (err) {
|
||||
if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
|
||||
return fail(CS_FAIL, String(err))
|
||||
const msg = String(err)
|
||||
if (msg.includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
|
||||
if (msg.includes('NOT_INITIALIZED')) {
|
||||
return fail(CS_FAIL, '系统未初始化,请重启应用')
|
||||
}
|
||||
return fail(CS_FAIL, msg)
|
||||
}
|
||||
})
|
||||
|
||||
tracedHandle('dll:job-cancel', (_e, jobId: string) => {
|
||||
tracedHandle('dll:job-cancel', async (_e, jobId: string) => {
|
||||
const id = String(jobId || mainAppState.activeJobId || '').trim()
|
||||
stopJobPoll(true)
|
||||
mainAppState.mode = 'ready'
|
||||
mainAppState.activeJobId = ''
|
||||
if (!id) return ok()
|
||||
if (!mainAppState.initialized) return ok()
|
||||
try {
|
||||
assertReady()
|
||||
const id = jobId || mainAppState.activeJobId
|
||||
stopJobPoll(true)
|
||||
let code = CS_OK
|
||||
if (isCancelApiAvailable()) {
|
||||
code = dllAdminJobCancel(id)
|
||||
}
|
||||
mainAppState.mode = 'ready'
|
||||
mainAppState.activeJobId = ''
|
||||
const dll = await loadDllModule()
|
||||
if (!dll.isCancelApiAvailable()) return ok()
|
||||
const code = dll.dllAdminJobCancel(id)
|
||||
return code === CS_OK ? ok() : fail(code, '取消失败')
|
||||
} catch (err) {
|
||||
return fail(CS_FAIL, String(err))
|
||||
log.warn('dll:job-cancel', err)
|
||||
return ok()
|
||||
}
|
||||
})
|
||||
|
||||
tracedHandle('dll:usb-copy', (_e, req: { destFolder: string; cardOutput: number }) => {
|
||||
tracedHandle('dll:usb-copy', async (_e, req: { destFolder: string; cardOutput: number; resubmit?: boolean }) => {
|
||||
try {
|
||||
assertReady()
|
||||
assertNotBusy()
|
||||
const code = dllCopyFromUsb(req.destFolder, req.cardOutput)
|
||||
if (req.resubmit) {
|
||||
if (mainAppState.mode !== 'usbCopying') {
|
||||
return fail(CS_FAIL, '当前不在数据收集会话中')
|
||||
}
|
||||
stopCardPositionPoll()
|
||||
} else {
|
||||
assertNotBusy()
|
||||
}
|
||||
const destFolder = String(req.destFolder || '').trim()
|
||||
if (!destFolder) return fail(CS_FAIL, '请先选择数据导入目录')
|
||||
fs.mkdirSync(destFolder, { recursive: true })
|
||||
|
||||
const dll = await loadDllModule()
|
||||
if (dll.isUsbReaderApiAvailable()) {
|
||||
const moveCode = dll.dllPrinterMoveToUsbReader()
|
||||
if (moveCode !== CS_OK) {
|
||||
log.warn('MoveToUsbReader before copy', { moveCode })
|
||||
}
|
||||
}
|
||||
|
||||
const code = dll.dllCopyFromUsb(destFolder, req.cardOutput)
|
||||
if (code !== CS_OK) {
|
||||
return fail(code, '可能已有任务在执行')
|
||||
const errText = dll.dllGetPrinterErrorStr(code)
|
||||
return fail(code, errText || '启动 USB 收集失败')
|
||||
}
|
||||
mainAppState.mode = 'usbCopying'
|
||||
startUsbPoll()
|
||||
return ok()
|
||||
} catch (err) {
|
||||
if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
|
||||
@@ -195,8 +228,8 @@ export function registerIpcHandlers(): void {
|
||||
return ok()
|
||||
})
|
||||
|
||||
tracedHandle('poll:job-stop', () => {
|
||||
stopJobPoll(true)
|
||||
tracedHandle('poll:job-stop', (_e, opts?: { resetMode?: boolean }) => {
|
||||
stopJobPoll(opts?.resetMode !== false)
|
||||
return ok()
|
||||
})
|
||||
|
||||
@@ -205,9 +238,27 @@ export function registerIpcHandlers(): void {
|
||||
return ok()
|
||||
})
|
||||
|
||||
tracedHandle('poll:usb-stop', () => {
|
||||
stopUsbPoll()
|
||||
mainAppState.mode = 'ready'
|
||||
tracedHandle('poll:usb-stop', (_e, opts?: { resetMode?: boolean }) => {
|
||||
stopUsbPoll(opts?.resetMode !== false)
|
||||
return ok()
|
||||
})
|
||||
|
||||
tracedHandle('poll:card-position-start', async () => {
|
||||
try {
|
||||
assertReady()
|
||||
const dll = await loadDllModule()
|
||||
if (!dll.isCardPositionApiAvailable()) {
|
||||
return fail(CS_FAIL, '当前 DLL 不支持卡位查询,无法自动续做')
|
||||
}
|
||||
startCardPositionPoll()
|
||||
return ok()
|
||||
} catch (err) {
|
||||
return fail(CS_FAIL, String(err))
|
||||
}
|
||||
})
|
||||
|
||||
tracedHandle('poll:card-position-stop', () => {
|
||||
stopCardPositionPoll()
|
||||
return ok()
|
||||
})
|
||||
|
||||
@@ -253,6 +304,19 @@ export function registerIpcHandlers(): void {
|
||||
return ok({ items })
|
||||
})
|
||||
|
||||
tracedHandle(
|
||||
'fs:write-job-csv',
|
||||
(_e, payload: { taskId: string; rows: JobCsvRow[] }) => {
|
||||
try {
|
||||
const sharedDir = configStore.get('sharedDir') as string
|
||||
const csvPath = writeJobCsv(sharedDir, payload.taskId, payload.rows || [])
|
||||
return ok({ path: csvPath })
|
||||
} catch (err) {
|
||||
return fail(CS_FAIL, err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
tracedHandle('fs:parse-soon', (_e, filePath: string) => {
|
||||
try {
|
||||
const soonPath = String(filePath || '').trim()
|
||||
@@ -272,26 +336,19 @@ export function registerIpcHandlers(): void {
|
||||
templateDir: string
|
||||
traceEnabled: boolean
|
||||
lastPrinterStatus?: PrinterStatusSnapshot
|
||||
skipDllInit?: boolean
|
||||
dllInitialized: boolean
|
||||
} = {
|
||||
sharedDir: configStore.get('sharedDir'),
|
||||
templateDir: configStore.get('templateDir'),
|
||||
traceEnabled: configStore.get('traceEnabled', true),
|
||||
lastPrinterStatus: configStore.get('lastPrinterStatus')
|
||||
}
|
||||
if (!app.isPackaged) {
|
||||
payload.skipDllInit = configStore.get('skipDllInit', false)
|
||||
lastPrinterStatus: configStore.get('lastPrinterStatus'),
|
||||
dllInitialized: mainAppState.initialized
|
||||
}
|
||||
return ok(payload)
|
||||
})
|
||||
|
||||
tracedHandle('config:set', (_e, patch: Record<string, unknown>) => {
|
||||
Object.entries(patch).forEach(([k, v]) => {
|
||||
if (k === 'skipDllInit') {
|
||||
if (app.isPackaged) return
|
||||
configStore.set(k, parseBool(v))
|
||||
return
|
||||
}
|
||||
if (k === 'traceEnabled' || k === 'dllTraceEnabled') {
|
||||
configStore.set('traceEnabled', parseBool(v))
|
||||
return
|
||||
@@ -314,19 +371,24 @@ export function registerIpcHandlers(): void {
|
||||
return ok()
|
||||
})
|
||||
|
||||
tracedHandle('dll:reject-available', () => ok({ available: isRejectApiAvailable() }))
|
||||
tracedHandle('dll:reject-available', async () => {
|
||||
const dll = await loadDllModule()
|
||||
return ok({ available: dll.isRejectApiAvailable() })
|
||||
})
|
||||
}
|
||||
|
||||
export async function handleBeforeQuit(): Promise<void> {
|
||||
const dll = isDllInitAttempted() ? await loadDllModule().catch(() => null) : null
|
||||
const shouldCancel =
|
||||
mainAppState.mode === 'distributing' &&
|
||||
!!mainAppState.activeJobId &&
|
||||
isCancelApiAvailable()
|
||||
isJobPollActive() &&
|
||||
!!dll?.isCancelApiAvailable()
|
||||
const cancelJobId = mainAppState.activeJobId
|
||||
stopAllPolls()
|
||||
if (shouldCancel && cancelJobId) {
|
||||
if (shouldCancel && cancelJobId && dll) {
|
||||
try {
|
||||
dllAdminJobCancel(cancelJobId)
|
||||
dll.dllAdminJobCancel(cancelJobId)
|
||||
} catch (e) {
|
||||
log.warn('before-quit cancel', e)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getProcessExecDir } from './native-path'
|
||||
|
||||
export const APP_CONFIG_FILENAME = 'cardsoon.config.json'
|
||||
|
||||
/** 与 cardsoon.config.json 键名一致,后续配置在此扩展 */
|
||||
export interface AppFileConfig {
|
||||
designAppPath: string
|
||||
}
|
||||
@@ -16,7 +15,6 @@ const defaults: AppFileConfig = {
|
||||
}
|
||||
|
||||
let cached: AppFileConfig | null = null
|
||||
let loadedFrom = ''
|
||||
|
||||
function bundledConfigPath(): string {
|
||||
if (app.isPackaged) {
|
||||
@@ -46,7 +44,6 @@ export function loadAppFileConfig(): AppFileConfig {
|
||||
if (!fs.existsSync(filePath)) continue
|
||||
try {
|
||||
cached = parseConfigFile(filePath)
|
||||
loadedFrom = filePath
|
||||
log.info(`Loaded ${APP_CONFIG_FILENAME} from ${filePath}`)
|
||||
return cached
|
||||
} catch (e) {
|
||||
@@ -55,18 +52,12 @@ export function loadAppFileConfig(): AppFileConfig {
|
||||
}
|
||||
|
||||
cached = { ...defaults }
|
||||
loadedFrom = ''
|
||||
log.warn(
|
||||
`${APP_CONFIG_FILENAME} not found (checked: ${configSearchPaths().join(', ')}), using defaults`
|
||||
)
|
||||
return cached
|
||||
}
|
||||
|
||||
export function getAppConfigLoadedPath(): string {
|
||||
loadAppFileConfig()
|
||||
return loadedFrom
|
||||
}
|
||||
|
||||
export function getDesignAppPath(): string {
|
||||
return loadAppFileConfig().designAppPath
|
||||
}
|
||||
|
||||
@@ -6,23 +6,15 @@ import type { PrinterStatusSnapshot } from '@shared/printer-info'
|
||||
interface AppConfig {
|
||||
sharedDir: string
|
||||
templateDir: string
|
||||
/** G2 门禁 false:启动即 SAPI_Init;仅调试可改 true */
|
||||
skipDllInit: boolean
|
||||
/** true:IPC/DLL 等调用输出到 DevTools 控制台 */
|
||||
traceEnabled: boolean
|
||||
/** 上次成功的 GetPrinterInfo 解析结果,供离线/失败时展示 */
|
||||
lastPrinterStatus?: PrinterStatusSnapshot
|
||||
}
|
||||
|
||||
const defaultShared = path.join('C:', 'PrintTasks')
|
||||
|
||||
export const configStore = new Store<AppConfig>({
|
||||
name: 'cardsoon-config',
|
||||
defaults: {
|
||||
sharedDir: defaultShared,
|
||||
sharedDir: path.join('C:', 'PrintTasks'),
|
||||
templateDir: path.join(app.getPath('userData'), 'Cardsoon', 'templates'),
|
||||
// 正式版始终 Init;仅开发时可通过 --skip-dll-init 临时跳过
|
||||
skipDllInit: false,
|
||||
traceEnabled: true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -88,5 +88,10 @@ export function setupNativeWorkingDir(): void {
|
||||
if (!process.env.PATH?.toLowerCase().includes(nativeDir.toLowerCase())) {
|
||||
process.env.PATH = `${pathHead}${path.delimiter}${process.env.PATH || ''}`
|
||||
}
|
||||
log.debug(`Native DLL search path: ${nativeDir}; cwd kept at ${process.cwd()}`)
|
||||
try {
|
||||
process.chdir(execDir)
|
||||
} catch (e) {
|
||||
log.warn(`chdir to ${execDir} failed`, e)
|
||||
}
|
||||
log.debug(`Native DLL search path: ${nativeDir}; cwd=${process.cwd()}`)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { shell } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export function validateDesignAppPath(exePath: string): { ok: true } | { ok: false; message: string } {
|
||||
function validateDesignAppPath(exePath: string): { ok: true } | { ok: false; message: string } {
|
||||
const p = exePath.trim()
|
||||
if (!p) {
|
||||
return { ok: false, message: '请在 cardsoon.config.json 中配置 designAppPath' }
|
||||
@@ -14,7 +14,6 @@ export function validateDesignAppPath(exePath: string): { ok: true } | { ok: fal
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** 由系统启动外部程序;空字符串表示成功,非空为失败原因 */
|
||||
export async function openDesignApp(
|
||||
exePath: string
|
||||
): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
import log from 'electron-log'
|
||||
import { POLL_INTERVAL_MS } from '../constants'
|
||||
import { POLL_INTERVAL_MS, CS_OK } from '../constants'
|
||||
import {
|
||||
USB_TASK_COMPLETED,
|
||||
USB_TASK_FAILED,
|
||||
clampUsbCopyProgress,
|
||||
usbTaskStatusHint
|
||||
} from '@shared/usb-copy-state'
|
||||
import { mainAppState } from './app-state'
|
||||
import { emitTrace } from '../utils/trace-bridge'
|
||||
import { dllGetJobStateById, dllGetUsbCopyState } from './work-dll.service'
|
||||
import { loadDllModule } from './dll-loader'
|
||||
|
||||
let jobTimer: ReturnType<typeof setInterval> | null = null
|
||||
let usbTimer: ReturnType<typeof setInterval> | null = null
|
||||
let cardTimer: ReturnType<typeof setInterval> | null = null
|
||||
let usbPollGen = 0
|
||||
let jobId = ''
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
|
||||
@@ -35,84 +43,145 @@ export function stopJobPoll(resetMode = false): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function stopUsbPoll(): void {
|
||||
export function stopUsbPoll(resetMode = false): void {
|
||||
usbPollGen += 1
|
||||
if (usbTimer) {
|
||||
clearInterval(usbTimer)
|
||||
usbTimer = null
|
||||
}
|
||||
if (resetMode && mainAppState.mode === 'usbCopying') {
|
||||
mainAppState.mode = 'ready'
|
||||
}
|
||||
}
|
||||
|
||||
export function stopCardPositionPoll(): void {
|
||||
if (cardTimer) {
|
||||
clearInterval(cardTimer)
|
||||
cardTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
export function stopAllPolls(): void {
|
||||
stopJobPoll(true)
|
||||
stopUsbPoll()
|
||||
stopUsbPoll(true)
|
||||
stopCardPositionPoll()
|
||||
}
|
||||
|
||||
export function startJobPoll(id: string): void {
|
||||
stopJobPoll(false)
|
||||
jobId = id
|
||||
jobTimer = setInterval(() => {
|
||||
try {
|
||||
const r = dllGetJobStateById(jobId)
|
||||
const failed = r.jobState === 4
|
||||
const cancelled = r.jobState === 6
|
||||
const finished = r.jobState === 100
|
||||
const terminal = failed || cancelled
|
||||
const tick = {
|
||||
jobId,
|
||||
queryErrorCode: r.queryErrorCode,
|
||||
jobState: r.jobState,
|
||||
progress: r.progress,
|
||||
terminal,
|
||||
failed,
|
||||
cancelled,
|
||||
finished
|
||||
}
|
||||
emitTrace('[poll] job:poll-tick', tick)
|
||||
send('job:poll-tick', tick)
|
||||
if (r.queryErrorCode !== 0) {
|
||||
log.warn('GetJobStateById query failed', r.queryErrorCode)
|
||||
stopJobPoll(true)
|
||||
return
|
||||
}
|
||||
if (failed || cancelled) {
|
||||
void (async () => {
|
||||
try {
|
||||
const dll = await loadDllModule()
|
||||
const r = dll.dllGetJobStateById(jobId)
|
||||
const failed = r.jobState === 4
|
||||
const cancelled = r.jobState === 6
|
||||
const finished = r.jobState === 100
|
||||
const terminal = failed || cancelled
|
||||
const tick = {
|
||||
jobId,
|
||||
queryErrorCode: r.queryErrorCode,
|
||||
jobState: r.jobState,
|
||||
progress: r.progress,
|
||||
terminal,
|
||||
failed,
|
||||
cancelled,
|
||||
finished
|
||||
}
|
||||
emitTrace('[poll] job:poll-tick', tick)
|
||||
send('job:poll-tick', tick)
|
||||
if (r.queryErrorCode !== 0) {
|
||||
log.warn('GetJobStateById query failed', r.queryErrorCode)
|
||||
return
|
||||
}
|
||||
if (failed || cancelled) {
|
||||
stopJobPoll(true)
|
||||
} else if (finished) {
|
||||
stopJobPoll(false)
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('job poll error', e)
|
||||
stopJobPoll(true)
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('job poll error', e)
|
||||
stopJobPoll(true)
|
||||
}
|
||||
})()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
export function startUsbPoll(): void {
|
||||
stopUsbPoll()
|
||||
stopUsbPoll(false)
|
||||
const gen = usbPollGen
|
||||
void pollUsbOnce(gen).catch((e) => log.error('usb poll error', e))
|
||||
usbTimer = setInterval(() => {
|
||||
try {
|
||||
const r = dllGetUsbCopyState()
|
||||
const failed = r.taskStatus === 3
|
||||
const success = r.taskStatus === 2
|
||||
const terminal = failed || success
|
||||
const tick = {
|
||||
taskStatus: r.taskStatus,
|
||||
progress: r.progress,
|
||||
terminal,
|
||||
failed,
|
||||
success
|
||||
}
|
||||
emitTrace('[poll] usb:poll-tick', tick)
|
||||
send('usb:poll-tick', tick)
|
||||
if (terminal) {
|
||||
stopUsbPoll()
|
||||
mainAppState.mode = 'ready'
|
||||
}
|
||||
} catch (e) {
|
||||
void pollUsbOnce(gen).catch((e) => {
|
||||
log.error('usb poll error', e)
|
||||
stopUsbPoll()
|
||||
mainAppState.mode = 'ready'
|
||||
}
|
||||
stopUsbPoll(true)
|
||||
})
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
async function pollUsbOnce(gen: number): Promise<void> {
|
||||
if (gen !== usbPollGen) return
|
||||
const dll = await loadDllModule()
|
||||
if (gen !== usbPollGen) return
|
||||
const r = dll.dllGetUsbCopyState()
|
||||
if (gen !== usbPollGen) return
|
||||
const copyProgress = clampUsbCopyProgress(r.progress)
|
||||
const failed = r.taskStatus === USB_TASK_FAILED
|
||||
const success = r.taskStatus === USB_TASK_COMPLETED
|
||||
const terminal = failed || success
|
||||
let errorMessage = ''
|
||||
if (failed) {
|
||||
const errText = dll.dllGetPrinterErrorStr(-1)
|
||||
errorMessage = errText || usbTaskStatusHint(USB_TASK_FAILED)
|
||||
}
|
||||
const tick = {
|
||||
queryCode: r.queryCode,
|
||||
taskStatus: r.taskStatus,
|
||||
progress: copyProgress,
|
||||
terminal,
|
||||
failed,
|
||||
success,
|
||||
errorMessage
|
||||
}
|
||||
emitTrace('[poll] usb:poll-tick', tick)
|
||||
if (gen !== usbPollGen) return
|
||||
send('usb:poll-tick', tick)
|
||||
if (r.queryCode !== CS_OK) {
|
||||
log.warn('GetUsbCopyState query failed', r.queryCode)
|
||||
return
|
||||
}
|
||||
if (terminal) {
|
||||
stopUsbPoll(false)
|
||||
}
|
||||
}
|
||||
|
||||
export function startCardPositionPoll(): void {
|
||||
stopCardPositionPoll()
|
||||
cardTimer = setInterval(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const dll = await loadDllModule()
|
||||
if (!dll.isCardPositionApiAvailable()) return
|
||||
const r = dll.dllGetPrinterCardPosition()
|
||||
const tick = { queryCode: r.queryCode, position: r.position }
|
||||
emitTrace('[poll] card:position-tick', tick)
|
||||
send('card:position-tick', tick)
|
||||
} catch (e) {
|
||||
log.error('card position poll error', e)
|
||||
}
|
||||
})()
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
export function getActiveJobId(): string {
|
||||
return jobId
|
||||
}
|
||||
|
||||
export function isJobPollActive(): boolean {
|
||||
return jobTimer !== null
|
||||
}
|
||||
|
||||
export function isUsbPollActive(): boolean {
|
||||
return usbTimer !== null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'path'
|
||||
import koffi from 'koffi'
|
||||
import log from 'electron-log'
|
||||
import { CS_OK, JOB_ID_BUF_SIZE, LOG_FATAL_FLAG } from '../constants'
|
||||
import { emitTrace, isTraceEnabled } from '../utils/trace-bridge'
|
||||
import { getNativeDir } from './native-path'
|
||||
@@ -22,6 +23,10 @@ let SAPI_Init: any = null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let SAPI_GetPrinterInfo: any = null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let SAPI_GetPrinterInfoEx: any = null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let SAPI_FreePrinterInfo: any = null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let SAPI_GetPrinterErrorStr: any = null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let SAPI_RestJobEx: any = null
|
||||
@@ -37,8 +42,18 @@ let SAPI_GetUsbCopyState: any = null
|
||||
let SAPI_PrinterResetprinter: any = null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let SAPI_PrinterMovetoreject: any = null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let SAPI_PrinterMovetousbreader: any = null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let SAPI_GetPrinterCardPosition: any = null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let SAPI_UploadFile: any = null
|
||||
let hasRejectApi = false
|
||||
let hasCardPositionApi = false
|
||||
let hasCancelApi = false
|
||||
let hasUploadApi = false
|
||||
let hasPrinterInfoEx = false
|
||||
let hasUsbReaderApi = false
|
||||
let loggedCancelMissing = false
|
||||
let loggedRejectMissing = false
|
||||
|
||||
@@ -63,6 +78,31 @@ function traceCall<T>(name: string, args: Record<string, unknown> | undefined, f
|
||||
}
|
||||
}
|
||||
|
||||
function readPrinterJsonFromOutPtr(len: number, outPtr: Buffer): { code: number; json?: Record<string, unknown> } {
|
||||
if (len <= 0) return { code: len }
|
||||
const ptr = koffi.decode(outPtr, 0, 'void *') as number
|
||||
if (!ptr) return { code: len }
|
||||
try {
|
||||
const jsonStr = koffi.decode(ptr, 'char', len) as string
|
||||
if (!jsonStr?.trim()) return { code: len }
|
||||
try {
|
||||
return { code: len, json: JSON.parse(jsonStr) as Record<string, unknown> }
|
||||
} catch {
|
||||
return { code: len }
|
||||
}
|
||||
} finally {
|
||||
if (SAPI_FreePrinterInfo) {
|
||||
try {
|
||||
SAPI_FreePrinterInfo(ptr)
|
||||
} catch (e) {
|
||||
log.warn('SAPI_FreePrinterInfo', e)
|
||||
}
|
||||
} else {
|
||||
koffi.free(ptr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadLibrary(): void {
|
||||
if (lib) return
|
||||
const dllPath = path.join(getNativeDir(), 'workDll.dll')
|
||||
@@ -79,6 +119,25 @@ function loadLibrary(): void {
|
||||
SAPI_GetUsbCopyState = lib.func('int __stdcall SAPI_GetUsbCopyState(_Out_ int *, _Out_ int *)')
|
||||
SAPI_PrinterResetprinter = lib.func('int __stdcall SAPI_PrinterResetprinter()')
|
||||
|
||||
try {
|
||||
SAPI_GetPrinterInfoEx = lib.func('int __stdcall SAPI_GetPrinterInfoEx(_Out_ void **)')
|
||||
SAPI_FreePrinterInfo = lib.func('void __stdcall SAPI_FreePrinterInfo(void *)')
|
||||
hasPrinterInfoEx = true
|
||||
} catch {
|
||||
SAPI_GetPrinterInfoEx = null
|
||||
SAPI_FreePrinterInfo = null
|
||||
hasPrinterInfoEx = false
|
||||
}
|
||||
|
||||
try {
|
||||
SAPI_UploadFile = lib.func('int __stdcall SAPI_UploadFile(str, str, str)')
|
||||
hasUploadApi = true
|
||||
} catch {
|
||||
SAPI_UploadFile = null
|
||||
hasUploadApi = false
|
||||
log.warn('SAPI_UploadFile not in workDll')
|
||||
}
|
||||
|
||||
try {
|
||||
SAPI_AdminJobCancel = lib.func('int __stdcall SAPI_AdminJobCancel(str)')
|
||||
hasCancelApi = true
|
||||
@@ -101,6 +160,33 @@ function loadLibrary(): void {
|
||||
emitTrace('[dll] SAPI_PrinterMovetoreject not in workDll (optional)')
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
SAPI_PrinterMovetousbreader = lib.func('int __stdcall SAPI_PrinterMovetousbreader()')
|
||||
hasUsbReaderApi = true
|
||||
} catch {
|
||||
SAPI_PrinterMovetousbreader = null
|
||||
hasUsbReaderApi = false
|
||||
emitTrace('[dll] SAPI_PrinterMovetousbreader not in workDll (optional)')
|
||||
}
|
||||
|
||||
try {
|
||||
SAPI_GetPrinterCardPosition = lib.func('int __stdcall SAPI_GetPrinterCardPosition(_Out_ int *)')
|
||||
hasCardPositionApi = true
|
||||
} catch {
|
||||
SAPI_GetPrinterCardPosition = null
|
||||
hasCardPositionApi = false
|
||||
emitTrace('[dll] SAPI_GetPrinterCardPosition not in workDll (optional)')
|
||||
}
|
||||
|
||||
log.info('workDll loaded', {
|
||||
upload: hasUploadApi,
|
||||
printerInfoEx: hasPrinterInfoEx,
|
||||
cancel: hasCancelApi,
|
||||
reject: hasRejectApi,
|
||||
usbReader: hasUsbReaderApi,
|
||||
cardPosition: hasCardPositionApi
|
||||
})
|
||||
}
|
||||
|
||||
export function isRejectApiAvailable(): boolean {
|
||||
@@ -113,6 +199,21 @@ export function isCancelApiAvailable(): boolean {
|
||||
return hasCancelApi
|
||||
}
|
||||
|
||||
export function isUploadApiAvailable(): boolean {
|
||||
loadLibrary()
|
||||
return hasUploadApi
|
||||
}
|
||||
|
||||
export function isUsbReaderApiAvailable(): boolean {
|
||||
loadLibrary()
|
||||
return hasUsbReaderApi
|
||||
}
|
||||
|
||||
export function isCardPositionApiAvailable(): boolean {
|
||||
loadLibrary()
|
||||
return hasCardPositionApi
|
||||
}
|
||||
|
||||
export function dllInit(params: InitParams): number {
|
||||
return traceCall(
|
||||
'SAPI_Init',
|
||||
@@ -142,29 +243,33 @@ export function dllInit(params: InitParams): number {
|
||||
)
|
||||
}
|
||||
|
||||
export function dllGetPrinterInfo(): { code: number; json?: Record<string, unknown> } {
|
||||
return traceCall('SAPI_GetPrinterInfo', undefined, () => {
|
||||
function dllGetPrinterInfoInternal(
|
||||
apiName: 'SAPI_GetPrinterInfo' | 'SAPI_GetPrinterInfoEx',
|
||||
fn: (outPtr: Buffer) => number
|
||||
): { code: number; json?: Record<string, unknown> } {
|
||||
return traceCall(apiName, undefined, () => {
|
||||
loadLibrary()
|
||||
const outPtr = koffi.alloc('void *', 8)
|
||||
try {
|
||||
const len = SAPI_GetPrinterInfo!(outPtr) as number
|
||||
if (len <= 0) return { code: len }
|
||||
const ptr = koffi.decode(outPtr, 0, 'void *') as number
|
||||
if (!ptr) return { code: len }
|
||||
const jsonStr = koffi.decode(ptr, 'char', len) as string
|
||||
koffi.free(ptr)
|
||||
if (!jsonStr?.trim()) return { code: len }
|
||||
try {
|
||||
return { code: len, json: JSON.parse(jsonStr) as Record<string, unknown> }
|
||||
} catch {
|
||||
return { code: len }
|
||||
}
|
||||
const len = fn(outPtr) as number
|
||||
return readPrinterJsonFromOutPtr(len, outPtr)
|
||||
} finally {
|
||||
koffi.free(outPtr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function dllGetPrinterInfo(): { code: number; json?: Record<string, unknown> } {
|
||||
loadLibrary()
|
||||
if (hasPrinterInfoEx && SAPI_GetPrinterInfoEx) {
|
||||
const ex = dllGetPrinterInfoInternal('SAPI_GetPrinterInfoEx', (p) => SAPI_GetPrinterInfoEx!(p))
|
||||
if (ex.json && Object.keys(ex.json).length > 0) {
|
||||
return ex
|
||||
}
|
||||
}
|
||||
return dllGetPrinterInfoInternal('SAPI_GetPrinterInfo', (p) => SAPI_GetPrinterInfo!(p))
|
||||
}
|
||||
|
||||
export function dllGetPrinterErrorStr(errorNo = -1): string {
|
||||
return traceCall('SAPI_GetPrinterErrorStr', { errorNo }, () => {
|
||||
loadLibrary()
|
||||
@@ -173,6 +278,18 @@ export function dllGetPrinterErrorStr(errorNo = -1): string {
|
||||
})
|
||||
}
|
||||
|
||||
export function dllUploadFile(userDir: string, fileName: string, fileText: string): number {
|
||||
return traceCall(
|
||||
'SAPI_UploadFile',
|
||||
{ userDir, fileName, bytes: Buffer.byteLength(fileText ?? '', 'utf8') },
|
||||
() => {
|
||||
loadLibrary()
|
||||
if (!SAPI_UploadFile) throw new Error('UPLOAD_API_UNAVAILABLE')
|
||||
return SAPI_UploadFile(userDir, fileName, fileText) as number
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function dllRestJobEx(json: string): { code: number; jobId: string } {
|
||||
return traceCall('SAPI_RestJobEx', { jsonBytes: Buffer.byteLength(json ?? '', 'utf8') }, () => {
|
||||
loadLibrary()
|
||||
@@ -216,13 +333,22 @@ export function dllCopyFromUsb(destFolder: string, cardOutput: number): number {
|
||||
})
|
||||
}
|
||||
|
||||
export function dllGetUsbCopyState(): { taskStatus: number; progress: number } {
|
||||
export function dllGetUsbCopyState(): {
|
||||
queryCode: number
|
||||
taskStatus: number
|
||||
/** copy_progress 0-100 */
|
||||
progress: number
|
||||
} {
|
||||
return traceCall('SAPI_GetUsbCopyState', undefined, () => {
|
||||
loadLibrary()
|
||||
const taskStatus = [0]
|
||||
const progress = [0]
|
||||
SAPI_GetUsbCopyState!(taskStatus, progress)
|
||||
return { taskStatus: taskStatus[0], progress: progress[0] }
|
||||
const copyProgress = [0]
|
||||
const queryCode = SAPI_GetUsbCopyState!(taskStatus, copyProgress) as number
|
||||
return {
|
||||
queryCode,
|
||||
taskStatus: taskStatus[0],
|
||||
progress: copyProgress[0]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -233,6 +359,14 @@ export function dllPrinterReset(): number {
|
||||
})
|
||||
}
|
||||
|
||||
export function dllPrinterMoveToUsbReader(): number {
|
||||
return traceCall('SAPI_PrinterMovetousbreader', undefined, () => {
|
||||
loadLibrary()
|
||||
if (!SAPI_PrinterMovetousbreader) throw new Error('USB_READER_API_UNAVAILABLE')
|
||||
return SAPI_PrinterMovetousbreader() as number
|
||||
})
|
||||
}
|
||||
|
||||
export function dllPrinterReject(): number {
|
||||
return traceCall('SAPI_PrinterMovetoreject', undefined, () => {
|
||||
loadLibrary()
|
||||
@@ -240,3 +374,13 @@ export function dllPrinterReject(): number {
|
||||
return SAPI_PrinterMovetoreject() as number
|
||||
})
|
||||
}
|
||||
|
||||
export function dllGetPrinterCardPosition(): { queryCode: number; position: number } {
|
||||
return traceCall('SAPI_GetPrinterCardPosition', undefined, () => {
|
||||
loadLibrary()
|
||||
if (!SAPI_GetPrinterCardPosition) throw new Error('CARD_POSITION_API_UNAVAILABLE')
|
||||
const position = [0]
|
||||
const queryCode = SAPI_GetPrinterCardPosition!(position) as number
|
||||
return { queryCode, position: position[0] }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { pathToFileURL } from 'url'
|
||||
export interface TemplateFieldRow {
|
||||
label: string
|
||||
value: string
|
||||
originName: string
|
||||
}
|
||||
|
||||
export interface ParsedSoonTemplate {
|
||||
@@ -12,6 +13,8 @@ export interface ParsedSoonTemplate {
|
||||
fields: TemplateFieldRow[]
|
||||
}
|
||||
|
||||
const SOON_FIELD_TYPES = new Set([1, 3, 4, 5])
|
||||
|
||||
function pickArray(obj: Record<string, unknown>, key: string): Record<string, unknown>[] {
|
||||
const entry = Object.entries(obj).find(([k]) => k.toLowerCase() === key.toLowerCase())
|
||||
if (!Array.isArray(entry?.[1])) return []
|
||||
@@ -38,18 +41,53 @@ function sideLabel(side: 'front' | 'back'): string {
|
||||
return side === 'front' ? '正面' : '背面'
|
||||
}
|
||||
|
||||
function resolveAssetPath(soonPath: string, ref: string): string {
|
||||
function toImageUrl(soonPath: string, ref: string): string {
|
||||
if (!ref) return ''
|
||||
if (/^(data:|https?:|file:)/i.test(ref)) return ref
|
||||
const clean = ref.replace(/^file:\/\//i, '')
|
||||
const abs = path.isAbsolute(clean) ? clean : path.join(path.dirname(soonPath), clean)
|
||||
return pathToFileURL(abs).href
|
||||
}
|
||||
|
||||
function toFieldLabel(name: string, side: 'front' | 'back'): string {
|
||||
return `${name} [${sideLabel(side)}]`
|
||||
function resolveAssetPath(soonPath: string, ref: string): string {
|
||||
return toImageUrl(soonPath, ref)
|
||||
}
|
||||
|
||||
export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
|
||||
function toFieldLabel(name: string, side: 'front' | 'back'): string {
|
||||
return `${name}[${sideLabel(side)}]`
|
||||
}
|
||||
|
||||
function parseSoonWorkerDisk(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
|
||||
const fields: TemplateFieldRow[] = []
|
||||
|
||||
const appendSide = (arr: unknown, side: '正面' | '背面') => {
|
||||
if (!Array.isArray(arr)) return
|
||||
for (const item of arr) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const o = item as Record<string, unknown>
|
||||
const type = Number(o.type)
|
||||
if (!SOON_FIELD_TYPES.has(type)) continue
|
||||
const name = String(o.name ?? '').trim()
|
||||
if (!name) continue
|
||||
const value = o.DefaultText == null ? '' : String(o.DefaultText)
|
||||
fields.push({ label: `${name}[${side}]`, value, originName: name })
|
||||
}
|
||||
}
|
||||
|
||||
appendSide(raw.frontData, '正面')
|
||||
appendSide(raw.backData, '背面')
|
||||
|
||||
const frontPic = String(raw.frontDisplayPic ?? '').trim()
|
||||
const backPic = String(raw.backDisplayPic ?? '').trim()
|
||||
|
||||
return {
|
||||
frontImageUrl: toImageUrl(soonPath, frontPic),
|
||||
backImageUrl: toImageUrl(soonPath, backPic),
|
||||
fields
|
||||
}
|
||||
}
|
||||
|
||||
function parseSoonLegacy(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
|
||||
const imgs = pickArray(raw, 'Img')
|
||||
const texts = pickArray(raw, 'Text')
|
||||
|
||||
@@ -66,7 +104,7 @@ export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>
|
||||
if (side === 'front') {
|
||||
if (!frontImageUrl) frontImageUrl = url
|
||||
const name = pickStr(item, ['name', 'field', 'key']) || 'IMAGE'
|
||||
fields.push({ label: toFieldLabel(name, 'front'), value: fileRef })
|
||||
fields.push({ label: toFieldLabel(name, 'front'), value: fileRef, originName: name })
|
||||
} else if (!backImageUrl) {
|
||||
backImageUrl = url
|
||||
}
|
||||
@@ -78,8 +116,20 @@ export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>
|
||||
const value = pickStr(item, ['value', 'text', 'default', 'content', 'data'])
|
||||
let side = sideOf(item)
|
||||
if (!side) side = /image|img|front/i.test(name) ? 'front' : 'back'
|
||||
fields.push({ label: toFieldLabel(name, side), value })
|
||||
fields.push({ label: toFieldLabel(name, side), value, originName: name })
|
||||
})
|
||||
|
||||
return { frontImageUrl, backImageUrl, fields }
|
||||
}
|
||||
|
||||
export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
|
||||
if (
|
||||
Array.isArray(raw.frontData) ||
|
||||
Array.isArray(raw.backData) ||
|
||||
raw.frontDisplayPic != null ||
|
||||
raw.backDisplayPic != null
|
||||
) {
|
||||
return parseSoonWorkerDisk(soonPath, raw)
|
||||
}
|
||||
return parseSoonLegacy(soonPath, raw)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -14,18 +14,21 @@ const channels = {
|
||||
'poll:job-stop',
|
||||
'poll:usb-start',
|
||||
'poll:usb-stop',
|
||||
'poll:card-position-start',
|
||||
'poll:card-position-stop',
|
||||
'dialog:open-directory',
|
||||
'dialog:open-file',
|
||||
'fs:path-exists',
|
||||
'fs:dir-size',
|
||||
'fs:parse-soon',
|
||||
'fs:write-job-csv',
|
||||
'config:get',
|
||||
'config:set',
|
||||
'shell:open-path',
|
||||
'design:open',
|
||||
'dll:reject-available'
|
||||
] 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 = {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
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" />
|
||||
<title>卡树数据卡打印系统</title>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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'
|
||||
|
||||
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 }>>
|
||||
}
|
||||
|
||||
export async function dllJobCreate(json: string): Promise<IpcResult<{ jobId: string }>> {
|
||||
return api().invoke('dll:job-create', json) as Promise<IpcResult<{ jobId: string }>>
|
||||
export async function dllJobCreate(
|
||||
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> {
|
||||
return api().invoke('dll:job-cancel', jobId) as Promise<IpcResult>
|
||||
}
|
||||
|
||||
export async function dllUsbCopy(destFolder: string, cardOutput: number): Promise<IpcResult> {
|
||||
return api().invoke('dll:usb-copy', { destFolder, cardOutput }) as Promise<IpcResult>
|
||||
export async function dllUsbCopy(
|
||||
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> {
|
||||
return api().invoke('poll:job-start', jobId) as Promise<IpcResult>
|
||||
}
|
||||
|
||||
export async function pollJobStop(): Promise<IpcResult> {
|
||||
return api().invoke('poll:job-stop') as Promise<IpcResult>
|
||||
export async function pollJobStop(opts?: { resetMode?: boolean }): Promise<IpcResult> {
|
||||
return api().invoke('poll:job-stop', opts) as Promise<IpcResult>
|
||||
}
|
||||
|
||||
export async function pollUsbStart(): Promise<IpcResult> {
|
||||
return api().invoke('poll:usb-start') as Promise<IpcResult>
|
||||
export async function pollUsbStop(opts?: { resetMode?: boolean }): Promise<IpcResult> {
|
||||
return api().invoke('poll:usb-stop', opts) as Promise<IpcResult>
|
||||
}
|
||||
|
||||
export async function pollUsbStop(): Promise<IpcResult> {
|
||||
return api().invoke('poll:usb-stop') as Promise<IpcResult>
|
||||
export async function pollCardPositionStart(): 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 {
|
||||
@@ -70,6 +81,10 @@ export function onUsbPollTick(cb: (p: UsbPollPayload) => void): () => 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[] }>> {
|
||||
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<
|
||||
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<
|
||||
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
|
||||
traceEnabled: boolean
|
||||
lastPrinterStatus?: PrinterStatusDisplay
|
||||
skipDllInit?: boolean
|
||||
dllInitialized: boolean
|
||||
}>
|
||||
> {
|
||||
return api().invoke('config:get') as Promise<
|
||||
@@ -115,7 +145,7 @@ export async function configGet(): Promise<
|
||||
templateDir: string
|
||||
traceEnabled: boolean
|
||||
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 { notify } from '@/composables/useNotify'
|
||||
import {
|
||||
configGet,
|
||||
dllInit,
|
||||
dllPrinterInfo,
|
||||
dllRejectAvailable,
|
||||
parsePrinterInfo
|
||||
} from '@/api/cardsoon'
|
||||
import type { PrinterStatusDisplay } from '@/types/printer'
|
||||
import { applyPrinterPayload, refreshPrinterHeader } from '@/composables/usePrinterStatus'
|
||||
import { configGet, dllInit, dllRejectAvailable } from '@/api/cardsoon'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
|
||||
let bootstrapped = false
|
||||
|
||||
function applyPrinterPayload(
|
||||
configStore: ReturnType<typeof useConfigStore>,
|
||||
data: Record<string, unknown>
|
||||
): void {
|
||||
const snapshot = data.snapshot as PrinterStatusDisplay | undefined
|
||||
if (snapshot) {
|
||||
configStore.setPrinter(snapshot)
|
||||
return
|
||||
function placeholderStatus(configStore: ReturnType<typeof useConfigStore>, text: string): void {
|
||||
if (configStore.printer.statusText === '—') {
|
||||
configStore.setPrinter({ ...configStore.printer, statusText: text })
|
||||
}
|
||||
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(): {
|
||||
retryInit: () => Promise<void>
|
||||
refreshHeader: () => Promise<void>
|
||||
} {
|
||||
export function useAppBootstrap(): void {
|
||||
const appStore = useAppStore()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
async function hydratePrinterFromLocal(): Promise<void> {
|
||||
async function hydrateFromConfig() {
|
||||
const cfg = await configGet()
|
||||
if (cfg.ok && 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> {
|
||||
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
|
||||
async function syncRejectApi(): Promise<void> {
|
||||
try {
|
||||
const rej = await dllRejectAvailable()
|
||||
if (rej.ok && rej.data) configStore.rejectApiAvailable = rej.data.available
|
||||
} catch {
|
||||
/* optional API */
|
||||
}
|
||||
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()
|
||||
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
|
||||
}
|
||||
|
||||
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: '未初始化' })
|
||||
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 {
|
||||
const rej = await dllRejectAvailable()
|
||||
if (rej.ok && rej.data) configStore.rejectApiAvailable = rej.data.available
|
||||
} catch {
|
||||
/* optional API */
|
||||
}
|
||||
return
|
||||
}
|
||||
await hydratePrinterFromLocal()
|
||||
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(() => {
|
||||
if (bootstrapped) return
|
||||
bootstrapped = true
|
||||
window.setTimeout(() => {
|
||||
void doInit()
|
||||
}, 300)
|
||||
void bootstrap()
|
||||
}, 100)
|
||||
})
|
||||
|
||||
return { retryInit: doInit, refreshHeader }
|
||||
}
|
||||
|
||||
@@ -11,9 +11,9 @@ export const notify = {
|
||||
info: (message: string, durationMs?: number) => push('info', message, durationMs)
|
||||
}
|
||||
|
||||
const INIT_HINT = '系统未初始化,请进入「数据分发 → 设置」重试 Init'
|
||||
const INIT_HINT = '系统未就绪,请重启应用或检查打印机与任务目录'
|
||||
|
||||
/** 未初始化等业务拦截时的统一提示 */
|
||||
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 {
|
||||
/* 无打印机时不阻塞 */
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,43 +1,43 @@
|
||||
import type { Router } from 'vue-router'
|
||||
import { useJobStore } from '@/stores/job'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
export function setupRouterGuards(router: Router): void {
|
||||
router.beforeEach((to, from) => {
|
||||
const job = useJobStore()
|
||||
const app = useAppStore()
|
||||
|
||||
if (to.path === '/distribute/running' && !job.jobId) {
|
||||
return { path: '/distribute/config' }
|
||||
}
|
||||
|
||||
if (to.path === '/distribute/failed' && job.failCount === 0) {
|
||||
return { path: '/distribute/config' }
|
||||
}
|
||||
|
||||
if (to.path === '/collect/running' && app.mode !== 'usbCopying') {
|
||||
return { path: '/collect' }
|
||||
}
|
||||
|
||||
if (to.path === '/collect' && app.mode === 'distributing') {
|
||||
return { path: '/home' }
|
||||
}
|
||||
|
||||
if (app.mode === 'usbCopying') {
|
||||
if (to.path.startsWith('/distribute')) return { path: '/collect/running' }
|
||||
if (from.path === '/collect/running') {
|
||||
const allowed = ['/collect/running', '/collect', '/home']
|
||||
if (!allowed.includes(to.path)) return false
|
||||
}
|
||||
}
|
||||
|
||||
if (from.path === '/distribute/running' && to.path !== '/distribute/failed') {
|
||||
if (to.path !== '/distribute/config') {
|
||||
app.setMode('ready')
|
||||
return { path: '/distribute/config' }
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
import type { Router } from 'vue-router'
|
||||
import { useJobStore } from '@/stores/job'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
export function setupRouterGuards(router: Router): void {
|
||||
router.beforeEach((to, from) => {
|
||||
const job = useJobStore()
|
||||
const app = useAppStore()
|
||||
|
||||
if (to.path === '/distribute/running' && !job.jobId && app.mode !== 'distributing') {
|
||||
return { path: '/distribute/config' }
|
||||
}
|
||||
|
||||
if (to.path === '/distribute/failed') {
|
||||
return { path: '/distribute/config' }
|
||||
}
|
||||
|
||||
if (to.path === '/collect/running' && app.mode !== 'usbCopying' && app.mode !== 'ready') {
|
||||
return { path: '/collect' }
|
||||
}
|
||||
|
||||
if (to.path === '/collect' && app.mode === 'distributing') {
|
||||
return { path: '/home' }
|
||||
}
|
||||
|
||||
if (app.mode === 'usbCopying' && to.path.startsWith('/distribute')) {
|
||||
return { path: '/collect/running' }
|
||||
}
|
||||
|
||||
if (from.path === '/distribute/running' && app.mode === 'distributing') {
|
||||
if (to.path === '/distribute/config') return true
|
||||
return { path: '/distribute/config' }
|
||||
}
|
||||
|
||||
if (from.path === '/collect/running' && app.mode === 'usbCopying') {
|
||||
const allowed = ['/collect/running', '/collect', '/home']
|
||||
if (!allowed.includes(to.path)) return { path: '/collect/running' }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
|
||||
export interface TemplateFieldRow {
|
||||
label: string
|
||||
value: string
|
||||
originName: string
|
||||
}
|
||||
|
||||
export interface TemplatePreview {
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 20px 30px;
|
||||
min-width: 220px;
|
||||
min-width: 0;
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
/* 面板标题 */
|
||||
@@ -51,6 +52,8 @@
|
||||
/* ========== 路径选择 - 大按钮设计 ========== */
|
||||
.m-path-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 12px 16px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
@@ -59,6 +62,16 @@
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,8 +1,3 @@
|
||||
/*
|
||||
Page 7 业务样式 - 打印系统核心界面
|
||||
基于 base.css 构建
|
||||
*/
|
||||
|
||||
/* 左右栏固定 1:1,内容变化不挤占宽度 */
|
||||
.app-shell__main.l-main-flex {
|
||||
display: grid;
|
||||
@@ -410,214 +405,6 @@
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
/* Electron 壳层:覆盖 design 原型用的深色信箱背景 */
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
@@ -13,7 +12,6 @@ body,
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 720×360 逻辑画布;内容区同比例;useScale 按 innerWidth/720 顶对齐 */
|
||||
.app-shell {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -25,11 +23,6 @@ body,
|
||||
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 {
|
||||
padding: 20px 40px;
|
||||
gap: 48px;
|
||||
@@ -75,7 +68,6 @@ body,
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* 与 page2.css 中 .m-tool-btn i / .m-task-icon i 对齐 */
|
||||
.m-tool-btn .fas {
|
||||
font-size: 16px;
|
||||
color: #6c757d;
|
||||
|
||||
@@ -28,9 +28,19 @@ export interface JobPollPayload {
|
||||
}
|
||||
|
||||
export interface UsbPollPayload {
|
||||
/** SAPI_GetUsbCopyState 返回值,0 表示成功 */
|
||||
queryCode: number
|
||||
/** task_status: 0 preparing, 1 copying, 2 completed, 3 failed */
|
||||
taskStatus: number
|
||||
/** copy_progress 0-100 */
|
||||
progress: number
|
||||
terminal: boolean
|
||||
failed: boolean
|
||||
success: boolean
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export interface CardPositionPollPayload {
|
||||
queryCode: number
|
||||
position: number
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ export interface PrinterStatusDisplay {
|
||||
printedCount: number
|
||||
}
|
||||
|
||||
/** Init 前 Header 占位;阶段二由 GetPrinterInfo 覆盖 */
|
||||
export const defaultPrinterStatus: PrinterStatusDisplay = {
|
||||
ribbonType: '—',
|
||||
statusText: '—',
|
||||
|
||||
@@ -1,32 +1,47 @@
|
||||
import type { DistributeFormState } from '@/stores/distributeForm'
|
||||
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> = {
|
||||
task_id: taskId,
|
||||
task_id: opts.taskId,
|
||||
print_copys: 1,
|
||||
has_print_task: hasPrint,
|
||||
has_copy_task: hasCopy,
|
||||
label: form.volumeLabel || 'DATA_CARD',
|
||||
file_type: String(form.copyType),
|
||||
zone_type: form.copyType === 1 ? '1' : '0',
|
||||
need_format: form.formatType !== 'none',
|
||||
need_format: needFormat,
|
||||
format_file: form.formatType === 'ntfs' ? 'NTFS' : 'FAT',
|
||||
disk_size: '16GB',
|
||||
dongle_install_count: form.dongleEnabled ? form.dongleMode : -1
|
||||
}
|
||||
|
||||
if (hasCopy) {
|
||||
body.path_file = form.pathList.map((x) => cleanPathPattern(x.path))
|
||||
}
|
||||
|
||||
if (hasPrint) {
|
||||
body.json_file = form.templateFile.trim()
|
||||
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.generateZip) body.is_generate_zip = true
|
||||
if (form.failPrintLabel) body.is_printer_record_logo = true
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
export function validateJobConfig(f: DistributeFormState): string | null {
|
||||
const hasCopy = f.pathList.length > 0
|
||||
export function resolveJobTasks(f: DistributeFormState): { hasCopy: boolean; hasPrint: boolean } {
|
||||
const hasCopy = f.pathList.some((p) => !!p.path.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 (hasPrint && !/\.soon$/i.test(f.templateFile.trim())) return '请选择 .soon 模板'
|
||||
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>
|
||||
<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>
|
||||
<button type="button" class="m-path-btn" @click="addPath">
|
||||
<AppIcon name="plus" size="sm" />
|
||||
@@ -63,7 +65,7 @@ import NavButton from '@/components/NavButton.vue'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import { useCollectStore } from '@/stores/collect'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { dialogOpenDirectory, dllUsbCopy, pollUsbStart } from '@/api/cardsoon'
|
||||
import { dialogOpenDirectory, dllUsbCopy } from '@/api/cardsoon'
|
||||
|
||||
const router = useRouter()
|
||||
const collectStore = useCollectStore()
|
||||
@@ -100,11 +102,10 @@ async function onSubmit(): Promise<void> {
|
||||
}
|
||||
const r = await dllUsbCopy(dest, collectStore.cardOutput)
|
||||
if (!r.ok) {
|
||||
notify.error(r.message || '可能已有任务在执行')
|
||||
notify.error(r.message || '启动 USB 收集失败')
|
||||
return
|
||||
}
|
||||
appStore.setMode('usbCopying')
|
||||
await pollUsbStart()
|
||||
await router.push('/collect/running')
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
<div class="c-panel__header">
|
||||
<span class="c-panel__title">标签预览</span>
|
||||
<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>
|
||||
</div>
|
||||
@@ -137,10 +137,16 @@ import { CARD_CAPACITY_BYTES, CARD_CAPACITY_GB } from '@/constants/cardCapacity'
|
||||
import { useDistributeFormStore } from '@/stores/distributeForm'
|
||||
import { useJobStore } from '@/stores/job'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { validateJobConfig } from '@/utils/validateJobConfig'
|
||||
import { buildJobConfig } from '@/utils/buildJobConfig'
|
||||
import { validateJobPreflight } from '@/utils/validateJobPreflight'
|
||||
import { createDistributeJob } from '@/utils/createDistributeJob'
|
||||
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 formStore = useDistributeFormStore()
|
||||
@@ -158,7 +164,8 @@ const totalLoadedBytes = computed(() =>
|
||||
|
||||
const loadPercent = computed(() => {
|
||||
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(
|
||||
@@ -219,10 +226,6 @@ function removePath(idx: number): void {
|
||||
}
|
||||
|
||||
async function pickTemplate(): Promise<void> {
|
||||
if (!canUse.value) {
|
||||
notifyRequireInit('选择打印模板')
|
||||
return
|
||||
}
|
||||
const r = await dialogOpenSoon()
|
||||
if (!r.ok) {
|
||||
notify.error(r.message || '打开模板选择失败')
|
||||
@@ -255,37 +258,29 @@ async function onSubmit(): Promise<void> {
|
||||
return
|
||||
}
|
||||
if (jobStore.submitting) return
|
||||
const err = validateJobConfig(formStore)
|
||||
const err = await validateJobPreflight(formStore)
|
||||
if (err) {
|
||||
notify.warning(err)
|
||||
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
|
||||
try {
|
||||
const json = JSON.stringify(buildJobConfig(formStore))
|
||||
const created = await dllJobCreate(json)
|
||||
if (!created.ok || !created.data?.jobId) {
|
||||
notify.error(created.message || '创建任务失败')
|
||||
const created = await createDistributeJob(formStore)
|
||||
if (!created.ok) {
|
||||
notify.error(created.message)
|
||||
return
|
||||
}
|
||||
jobStore.setActiveJob(created.data.jobId)
|
||||
const newJobId = created.jobId
|
||||
jobStore.setActiveJob(newJobId)
|
||||
appStore.setMode('distributing')
|
||||
await router.push('/distribute/running')
|
||||
try {
|
||||
await router.push('/distribute/running')
|
||||
} catch {
|
||||
await dllJobCancel(newJobId)
|
||||
jobStore.clearActiveJob()
|
||||
appStore.setMode('ready')
|
||||
notify.error('无法进入运行页,已取消任务')
|
||||
}
|
||||
} finally {
|
||||
jobStore.submitting = false
|
||||
}
|
||||
|
||||
@@ -54,8 +54,13 @@ const errorText = ref('')
|
||||
onMounted(async () => {
|
||||
appStore.setMode('ready')
|
||||
await pollJobStop()
|
||||
const r = await dllPrinterErrorStr(-1)
|
||||
errorText.value = r.ok && r.data?.text ? r.data.text : ''
|
||||
jobStore.clearActiveJob()
|
||||
try {
|
||||
const r = await dllPrinterErrorStr(-1)
|
||||
errorText.value = r.ok && r.data?.text ? r.data.text : ''
|
||||
} catch {
|
||||
errorText.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
function onBack(): void {
|
||||
|
||||
@@ -1,29 +1,64 @@
|
||||
<template>
|
||||
<AppShell>
|
||||
<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>
|
||||
<main class="app-shell__main l-main-full">
|
||||
<section class="l-hero-container">
|
||||
<div class="m-left-panel">
|
||||
<div class="c-status-panel">
|
||||
<h2 class="c-status-title is-looping">{{ statusTitle }}</h2>
|
||||
<p class="c-status-sub">{{ statusSub }}</p>
|
||||
<p class="c-status-counter">
|
||||
任务已经完成<span class="ok">{{ successCount }}</span>次,其中失败次数是<span
|
||||
class="err"
|
||||
>{{ failCount }}</span
|
||||
>。
|
||||
</p>
|
||||
<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-counter">
|
||||
任务已经完成<span class="ok">{{ successCount }}</span>次,其中失败次数是<span
|
||||
class="err"
|
||||
>{{ failCount }}</span
|
||||
>。
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<WorkflowSteps
|
||||
v-if="phase === 'failed'"
|
||||
mode="failed"
|
||||
:variant="isCollect ? 'collect' : 'distribute'"
|
||||
:failed-step="failureStep"
|
||||
/>
|
||||
<WorkflowSteps
|
||||
v-else
|
||||
:active-step="workflowStep"
|
||||
:variant="isCollect ? 'collect' : 'distribute'"
|
||||
mode="running"
|
||||
/>
|
||||
</div>
|
||||
<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">
|
||||
<circle class="bg" cx="50" cy="50" r="45" />
|
||||
<circle
|
||||
@@ -51,20 +86,36 @@ import AppShell from '@/layouts/AppShell.vue'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import AppFooter from '@/components/AppFooter.vue'
|
||||
import NavButton from '@/components/NavButton.vue'
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import WorkflowSteps from '@/components/WorkflowSteps.vue'
|
||||
import { useJobStore } from '@/stores/job'
|
||||
import { useCollectStore } from '@/stores/collect'
|
||||
import { useDistributeFormStore } from '@/stores/distributeForm'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import {
|
||||
dllJobCancel,
|
||||
dllPrinterErrorStr,
|
||||
dllUsbCopy,
|
||||
onCardPositionTick,
|
||||
onJobPollTick,
|
||||
onUsbPollTick,
|
||||
pollCardPositionStart,
|
||||
pollCardPositionStop,
|
||||
pollJobStart,
|
||||
pollJobStop,
|
||||
pollUsbStop
|
||||
} from '@/api/cardsoon'
|
||||
import { mapJobStateToUi, shouldUseProgress } from '@/utils/job-state'
|
||||
import type { JobPollPayload, UsbPollPayload } from '@/types/ipc'
|
||||
import { createDistributeJob } from '@/utils/createDistributeJob'
|
||||
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
|
||||
|
||||
@@ -72,6 +123,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const jobStore = useJobStore()
|
||||
const collectStore = useCollectStore()
|
||||
const formStore = useDistributeFormStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const isCollect = computed(() => route.name === 'collect-running')
|
||||
@@ -81,100 +133,277 @@ const successCount = computed(() =>
|
||||
)
|
||||
const failCount = computed(() => (isCollect.value ? collectStore.failCount : jobStore.failCount))
|
||||
|
||||
const phase = ref<'running' | 'completed' | 'failed'>('running')
|
||||
const progress = ref(0)
|
||||
const strokeOffset = ref(CIRCLE_LEN)
|
||||
const workflowStep = ref(1)
|
||||
const failureStep = ref(1)
|
||||
const failureErrorText = ref('')
|
||||
const waitCard = ref(false)
|
||||
let unsub: (() => void) | null = null
|
||||
let fakeTimer: ReturnType<typeof setInterval> | null = null
|
||||
const collectHint = ref('')
|
||||
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 statusTitle = computed(() => (waitCard.value ? '等待插卡' : '循环执行中'))
|
||||
const statusSub = computed(() =>
|
||||
waitCard.value ? '请插入数据卡,任务将自动连续执行' : '任务将自动连续执行'
|
||||
const failedSub = computed(() =>
|
||||
isCollect.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 {
|
||||
const p = Math.min(100, Math.max(0, value))
|
||||
progress.value = p
|
||||
strokeOffset.value = CIRCLE_LEN - (CIRCLE_LEN * p) / 100
|
||||
}
|
||||
|
||||
function startFakeProgress(): void {
|
||||
stopFakeProgress()
|
||||
fakeTimer = setInterval(() => {
|
||||
if (progress.value >= 95) return
|
||||
setProgress(progress.value + 1.5 + Math.random() * 2.5)
|
||||
}, 380)
|
||||
function clearPollListeners(): void {
|
||||
unsubJob?.()
|
||||
unsubJob = null
|
||||
unsubUsb?.()
|
||||
unsubUsb = null
|
||||
unsubCard?.()
|
||||
unsubCard = null
|
||||
}
|
||||
|
||||
function stopFakeProgress(): void {
|
||||
if (fakeTimer) {
|
||||
clearInterval(fakeTimer)
|
||||
fakeTimer = null
|
||||
/** 停止主进程轮询;resetMode=false 时保留 usbCopying/distributing 会话(完成态续做) */
|
||||
async function releasePolls(resetMode: boolean): Promise<void> {
|
||||
clearPollListeners()
|
||||
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 {
|
||||
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)
|
||||
workflowStep.value = ui.workflowStep
|
||||
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) {
|
||||
jobStore.failCount += 1
|
||||
router.push('/distribute/failed')
|
||||
void enterFailedPhase()
|
||||
return
|
||||
}
|
||||
if (p.cancelled) {
|
||||
finishDistribute(false)
|
||||
void finishDistribute(false)
|
||||
return
|
||||
}
|
||||
if (p.finished) {
|
||||
jobStore.successCount += 1
|
||||
void enterCompletedPhase()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function applyUsbProgress(p: UsbPollPayload): void {
|
||||
if (p.taskStatus === 1) workflowStep.value = 2
|
||||
if (p.taskStatus === 1 && p.progress > 0) {
|
||||
setProgress(Math.max(progress.value, p.progress))
|
||||
if (finishing || phase.value !== 'running') return
|
||||
if (p.queryCode !== 0) {
|
||||
usbQueryFailStreak += 1
|
||||
if (usbQueryFailStreak < 3) return
|
||||
collectStore.failCount += 1
|
||||
void enterFailedPhase(`查询 USB 任务失败: ${p.queryCode}`)
|
||||
return
|
||||
}
|
||||
usbQueryFailStreak = 0
|
||||
|
||||
if (p.failed) {
|
||||
collectStore.failCount += 1
|
||||
notify.error('USB 收集失败')
|
||||
finishCollect(false)
|
||||
void enterFailedPhase(p.errorMessage || usbTaskStatusHint(p.taskStatus))
|
||||
return
|
||||
}
|
||||
if (p.success) {
|
||||
if (usbAwaitNewCycle) return
|
||||
collectStore.successCount += 1
|
||||
setProgress(100)
|
||||
workflowStep.value = 3
|
||||
collectHint.value = usbTaskStatusHint(p.taskStatus)
|
||||
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 {
|
||||
stopFakeProgress()
|
||||
pollJobStop()
|
||||
async function finishDistribute(
|
||||
toHome: boolean,
|
||||
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()
|
||||
appStore.setMode('ready')
|
||||
router.push(toHome ? '/home' : '/distribute/config')
|
||||
await router.push(redirect)
|
||||
}
|
||||
|
||||
function finishCollect(toHome: boolean): void {
|
||||
stopFakeProgress()
|
||||
pollUsbStop()
|
||||
async function finishCollect(toHome: boolean): Promise<void> {
|
||||
if (finishing) return
|
||||
finishing = true
|
||||
await releasePolls(true)
|
||||
appStore.setMode('ready')
|
||||
router.push(toHome ? '/home' : '/collect')
|
||||
await router.push(toHome ? '/home' : '/collect')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -185,8 +414,8 @@ onMounted(async () => {
|
||||
}
|
||||
workflowStep.value = 1
|
||||
setProgress(0)
|
||||
startFakeProgress()
|
||||
unsub = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
|
||||
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
|
||||
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -195,34 +424,74 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
appStore.setMode('distributing')
|
||||
await pollJobStart(jobStore.jobId)
|
||||
unsub = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
|
||||
setProgress(0)
|
||||
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(() => {
|
||||
unsub?.()
|
||||
stopFakeProgress()
|
||||
if (isCollect.value) {
|
||||
if (appStore.mode === 'usbCopying') pollUsbStop()
|
||||
} else if (appStore.mode === 'distributing') {
|
||||
pollJobStop()
|
||||
if (finishing || phase.value !== 'running') return
|
||||
void releasePolls(true)
|
||||
if (!isCollect.value && appStore.mode === 'distributing') {
|
||||
appStore.setMode('ready')
|
||||
}
|
||||
})
|
||||
|
||||
async function onStop(): Promise<void> {
|
||||
async function onFailedBack(): Promise<void> {
|
||||
finishing = true
|
||||
if (isCollect.value) {
|
||||
finishCollect(false)
|
||||
await router.push('/collect')
|
||||
return
|
||||
}
|
||||
await dllJobCancel(jobStore.jobId)
|
||||
finishDistribute(false)
|
||||
await router.push('/distribute/config')
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<style src="@/styles/pages/page2.css"></style>
|
||||
<style src="@/styles/pages/page3.css"></style>
|
||||
|
||||
<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 {
|
||||
stroke-dasharray: 283;
|
||||
transition: stroke-dashoffset 0.45s ease;
|
||||
|
||||
@@ -49,9 +49,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { notify, notifyRequireInit } from '@/composables/useNotify'
|
||||
import { refreshPrinterHeader } from '@/composables/usePrinterStatus'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import AppFooter from '@/components/AppFooter.vue'
|
||||
@@ -74,8 +75,10 @@ function guardInit(action?: string): boolean {
|
||||
async function onReset(): Promise<void> {
|
||||
if (!guardInit('重置打印机')) return
|
||||
const r = await dllPrinterReset()
|
||||
if (r.ok) notify.success('已发送重置指令')
|
||||
else notify.error(r.message || '重置失败')
|
||||
if (r.ok) {
|
||||
notify.success('已发送重置指令')
|
||||
await refreshPrinterHeader(configStore)
|
||||
} else notify.error(r.message || '重置失败')
|
||||
}
|
||||
|
||||
async function onReject(): Promise<void> {
|
||||
@@ -119,6 +122,10 @@ function goCollect(): void {
|
||||
if (!guardBusy()) return
|
||||
router.push('/collect')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (canUse.value) void refreshPrinterHeader(configStore)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style src="@/styles/pages/page2.css"></style>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** workDll 卡位:备卡位,检测到后可自动重提任务 */
|
||||
export const POSITION_PREPARE = 13
|
||||
@@ -0,0 +1,4 @@
|
||||
/** RestJobEx JSON 根字段 task_id */
|
||||
export function genTaskId(): string {
|
||||
return `T${Date.now()}`
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
/** DLL GetPrinterInfo JSON → Header 展示字段(与 mocks/printer 一致) */
|
||||
export interface PrinterStatusSnapshot {
|
||||
ribbonType: string
|
||||
statusText: string
|
||||
@@ -6,28 +5,78 @@ export interface PrinterStatusSnapshot {
|
||||
printedCount: number
|
||||
}
|
||||
|
||||
export function parsePrinterInfoFromDll(json: Record<string, unknown>): PrinterStatusSnapshot {
|
||||
const list = (json.printerList as Record<string, unknown>[]) || []
|
||||
const p = list[0] || {}
|
||||
const serial =
|
||||
p.szPrinterSerial ?? p.PrinterSerial ?? p.SerialNo ?? p.PrinterName ?? '—'
|
||||
const PRINTER_STATUS_MAP: Record<string, string> = {
|
||||
I: '空闲',
|
||||
B: '忙碌',
|
||||
P: '正在打印'
|
||||
}
|
||||
|
||||
let statusText = '—'
|
||||
const direct = p.PrinterType ?? p.PrinterStatus ?? p.Status
|
||||
if (direct != null && String(direct).trim() !== '') {
|
||||
statusText = String(direct)
|
||||
} else {
|
||||
const remain = p.RibbonRemain ?? p.RemainCount
|
||||
const capacity = p.RibbonCapacity ?? p.Capacity ?? p.MaxCount
|
||||
function pickFirst(obj: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const k of keys) {
|
||||
const v = obj[k]
|
||||
if (v != null && String(v).trim() !== '') return v
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
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) {
|
||||
statusText = `${remain}/${capacity}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ribbonType: String(p.RibbonType ?? '—'),
|
||||
ribbonType: String(ribbon ?? '—'),
|
||||
statusText,
|
||||
serialNo: String(serial),
|
||||
printedCount: Number(p.PrintedCount ?? p.PrintCount ?? 0)
|
||||
serialNo: String(serial ?? '—'),
|
||||
printedCount: Number(printed ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
/** SAPI_GetPrinterInfoEx(扁平 JSON)与 SAPI_GetPrinterInfo(printerList) */
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/** SAPI_GetUsbCopyState: task_status(0=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
Reference in New Issue
Block a user