优化一些bug
This commit is contained in:
+9
-3
@@ -8,11 +8,16 @@
|
|||||||
"node": "16.15.0"
|
"node": "16.15.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev -- --skip-dll-init",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
"typecheck": "vue-tsc --noEmit -p tsconfig.web.json",
|
"typecheck": "vue-tsc --noEmit -p tsconfig.web.json",
|
||||||
"dist": "electron-vite build && electron-builder"
|
"predist": "node scripts/check-native-dlls.js",
|
||||||
|
"predist:dir": "node scripts/check-native-dlls.js",
|
||||||
|
"predist:zip": "node scripts/check-native-dlls.js",
|
||||||
|
"dist": "electron-vite build && electron-builder",
|
||||||
|
"dist:dir": "electron-vite build && electron-builder --dir",
|
||||||
|
"dist:zip": "electron-vite build && electron-builder --win zip"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.4.0",
|
"@fortawesome/fontawesome-free": "^6.4.0",
|
||||||
@@ -45,7 +50,8 @@
|
|||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
"nsis"
|
"nsis"
|
||||||
]
|
],
|
||||||
|
"signAndEditExecutable": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const nativeDir = path.join(__dirname, '..', 'resources', 'native')
|
||||||
|
const required = [
|
||||||
|
'workDll.dll',
|
||||||
|
'FCSDK.dll',
|
||||||
|
'SeaorySDK.dll',
|
||||||
|
'dcrf32.dll',
|
||||||
|
'Entry.dll',
|
||||||
|
'libpng16.dll',
|
||||||
|
'zint.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)')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log('resources/native: 7 dll 齐全')
|
||||||
+16
-3
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow, globalShortcut, screen } from 'electron'
|
import { app, BrowserWindow, dialog, globalShortcut, screen } from 'electron'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
|
||||||
app.commandLine.appendSwitch('disable-gpu-shader-disk-cache')
|
app.commandLine.appendSwitch('disable-gpu-shader-disk-cache')
|
||||||
@@ -6,6 +6,7 @@ app.commandLine.appendSwitch('disable-gpu-shader-disk-cache')
|
|||||||
import log from 'electron-log'
|
import log from 'electron-log'
|
||||||
import { suppressKnownDllStderr } from './utils/suppress-dll-stderr'
|
import { suppressKnownDllStderr } from './utils/suppress-dll-stderr'
|
||||||
import { setupNativeWorkingDir } from './services/native-path'
|
import { setupNativeWorkingDir } from './services/native-path'
|
||||||
|
import { configStore } from './services/config-store'
|
||||||
|
|
||||||
suppressKnownDllStderr()
|
suppressKnownDllStderr()
|
||||||
import { registerIpcHandlers, handleBeforeQuit } from './ipc/register-handlers'
|
import { registerIpcHandlers, handleBeforeQuit } from './ipc/register-handlers'
|
||||||
@@ -16,7 +17,7 @@ let mainWindow: BrowserWindow | null = null
|
|||||||
|
|
||||||
const MIN_CONTENT_WIDTH = 960
|
const MIN_CONTENT_WIDTH = 960
|
||||||
|
|
||||||
/** 默认内容区:约 85% 工作区宽,高 = 宽×360/720 */
|
/** 默认内容区:约 85% 工作区宽,高 720:360 */
|
||||||
function getDefaultWindowSize(): { width: number; height: number } {
|
function getDefaultWindowSize(): { width: number; height: number } {
|
||||||
const { width: sw, height: sh } = screen.getPrimaryDisplay().workAreaSize
|
const { width: sw, height: sh } = screen.getPrimaryDisplay().workAreaSize
|
||||||
let w = Math.max(1280, Math.min(Math.floor(sw * 0.85), 1600))
|
let w = Math.max(1280, Math.min(Math.floor(sw * 0.85), 1600))
|
||||||
@@ -67,7 +68,7 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 内容区锁定 720:360,与 useScale(scale = innerWidth/720) 一致
|
// 内容区 720:360,与 useScale 一致
|
||||||
mainWindow.on('resize', () => {
|
mainWindow.on('resize', () => {
|
||||||
if (!mainWindow) return
|
if (!mainWindow) return
|
||||||
const [cw, ch] = mainWindow.getContentSize()
|
const [cw, ch] = mainWindow.getContentSize()
|
||||||
@@ -90,6 +91,13 @@ function createWindow(): void {
|
|||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
try {
|
try {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
configStore.set('skipDllInit', false)
|
||||||
|
} else if (process.argv.includes('--skip-dll-init')) {
|
||||||
|
configStore.set('skipDllInit', true)
|
||||||
|
log.warn('skipDllInit enabled by argv: --skip-dll-init (dev only)')
|
||||||
|
}
|
||||||
|
|
||||||
setupNativeWorkingDir()
|
setupNativeWorkingDir()
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
createWindow()
|
createWindow()
|
||||||
@@ -100,7 +108,12 @@ app.whenReady().then(() => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
log.error('startup failed', e)
|
log.error('startup failed', e)
|
||||||
|
dialog.showErrorBox(
|
||||||
|
'启动失败',
|
||||||
|
`${msg}\n\n请确认已完整解压安装包,且 resources\\native 下 7 个 dll 齐全。`
|
||||||
|
)
|
||||||
app.quit()
|
app.quit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import {
|
|||||||
startUsbPoll,
|
startUsbPoll,
|
||||||
stopAllPolls,
|
stopAllPolls,
|
||||||
stopJobPoll,
|
stopJobPoll,
|
||||||
stopUsbPoll
|
stopUsbPoll,
|
||||||
|
getPollMainWindow
|
||||||
} from '../services/poll-manager'
|
} from '../services/poll-manager'
|
||||||
|
import { cleanPathPattern, getDirectorySizeBytes } from '../utils/dir-size'
|
||||||
|
import { parseSoonTemplate } from '../utils/parse-soon'
|
||||||
import {
|
import {
|
||||||
dllAdminJobCancel,
|
dllAdminJobCancel,
|
||||||
dllCopyFromUsb,
|
dllCopyFromUsb,
|
||||||
@@ -34,12 +37,18 @@ function fail(code: number, message: string) {
|
|||||||
return { ok: false as const, code, message }
|
return { ok: false as const, code, message }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let dllInitAttempted = false
|
||||||
|
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
ipcMain.handle('dll:init', (_e, params) => {
|
ipcMain.handle('dll:init', (_e, params) => {
|
||||||
|
if (dllInitAttempted) {
|
||||||
|
return fail(CS_FAIL, '请勿重复初始化,请完全退出应用后重新启动再试')
|
||||||
|
}
|
||||||
stopAllPolls()
|
stopAllPolls()
|
||||||
try {
|
try {
|
||||||
const sharedDir = params?.sharedDir || (configStore.get('sharedDir') as string)
|
const sharedDir = params?.sharedDir || (configStore.get('sharedDir') as string)
|
||||||
fs.mkdirSync(sharedDir, { recursive: true })
|
fs.mkdirSync(sharedDir, { recursive: true })
|
||||||
|
log.info(`SAPI_Init starting, sharedDir=${sharedDir}`)
|
||||||
const code = dllInit({
|
const code = dllInit({
|
||||||
sharedDir,
|
sharedDir,
|
||||||
keepCombinedImage: params?.keepCombinedImage,
|
keepCombinedImage: params?.keepCombinedImage,
|
||||||
@@ -50,17 +59,16 @@ export function registerIpcHandlers(): void {
|
|||||||
logLevel: params?.logLevel,
|
logLevel: params?.logLevel,
|
||||||
outBack: params?.outBack
|
outBack: params?.outBack
|
||||||
})
|
})
|
||||||
if (code === CS_OK) {
|
log.info(`SAPI_Init finished, code=${code}`)
|
||||||
mainAppState.initialized = true
|
dllInitAttempted = true
|
||||||
configStore.set('sharedDir', sharedDir)
|
|
||||||
return ok({ printerDetected: true })
|
|
||||||
}
|
|
||||||
mainAppState.initialized = true
|
mainAppState.initialized = true
|
||||||
configStore.set('sharedDir', sharedDir)
|
configStore.set('sharedDir', sharedDir)
|
||||||
|
if (code === CS_OK) {
|
||||||
|
return ok()
|
||||||
|
}
|
||||||
log.warn(`SAPI_Init returned ${code}; UI ready, printer ops may fail until device connected`)
|
log.warn(`SAPI_Init returned ${code}; UI ready, printer ops may fail until device connected`)
|
||||||
return ok({
|
return ok({
|
||||||
printerDetected: false,
|
warning: '打印机未连接或驱动未就绪,界面可浏览,接好设备后可在设置中重试 Init'
|
||||||
warning: '打印机未连接或驱动未就绪,界面可浏览,业务操作需接真机后重试 Init'
|
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
mainAppState.initialized = false
|
mainAppState.initialized = false
|
||||||
@@ -129,7 +137,7 @@ export function registerIpcHandlers(): void {
|
|||||||
try {
|
try {
|
||||||
assertReady()
|
assertReady()
|
||||||
const id = jobId || mainAppState.activeJobId
|
const id = jobId || mainAppState.activeJobId
|
||||||
stopJobPoll()
|
stopJobPoll(true)
|
||||||
let code = CS_OK
|
let code = CS_OK
|
||||||
if (isCancelApiAvailable()) {
|
if (isCancelApiAvailable()) {
|
||||||
code = dllAdminJobCancel(id)
|
code = dllAdminJobCancel(id)
|
||||||
@@ -166,7 +174,7 @@ export function registerIpcHandlers(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('poll:job-stop', () => {
|
ipcMain.handle('poll:job-stop', () => {
|
||||||
stopJobPoll()
|
stopJobPoll(true)
|
||||||
return ok()
|
return ok()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -182,13 +190,17 @@ export function registerIpcHandlers(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('dialog:open-directory', async () => {
|
ipcMain.handle('dialog:open-directory', async () => {
|
||||||
const r = await dialog.showOpenDialog({ properties: ['openDirectory', 'multiSelections'] })
|
const win = getPollMainWindow()
|
||||||
|
const r = await dialog.showOpenDialog(win ?? undefined, {
|
||||||
|
properties: ['openDirectory', 'multiSelections']
|
||||||
|
})
|
||||||
if (r.canceled || !r.filePaths.length) return ok({ paths: [] as string[] })
|
if (r.canceled || !r.filePaths.length) return ok({ paths: [] as string[] })
|
||||||
return ok({ paths: r.filePaths })
|
return ok({ paths: r.filePaths })
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('dialog:open-file', async (_e, filters?: { name: string; extensions: string[] }[]) => {
|
ipcMain.handle('dialog:open-file', async (_e, filters?: { name: string; extensions: string[] }[]) => {
|
||||||
const r = await dialog.showOpenDialog({
|
const win = getPollMainWindow()
|
||||||
|
const r = await dialog.showOpenDialog(win ?? undefined, {
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
filters: filters ?? [{ name: 'Soon', extensions: ['soon'] }]
|
filters: filters ?? [{ name: 'Soon', extensions: ['soon'] }]
|
||||||
})
|
})
|
||||||
@@ -197,23 +209,67 @@ export function registerIpcHandlers(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('fs:path-exists', (_e, paths: string[]) => {
|
ipcMain.handle('fs:path-exists', (_e, paths: string[]) => {
|
||||||
const missing = paths.filter((p) => {
|
const missing = paths
|
||||||
const clean = p.replace(/\\\*\\.\\*$/i, '').replace(/\/\*\.\*$/i, '')
|
.map((raw) => ({ raw, dir: cleanPathPattern(raw) }))
|
||||||
return !fs.existsSync(clean)
|
.filter(({ dir }) => !dir || !fs.existsSync(dir))
|
||||||
})
|
.map(({ dir, raw }) => dir || raw)
|
||||||
return ok({ missing })
|
return ok({ missing })
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('config:get', () =>
|
ipcMain.handle('fs:dir-size', (_e, paths: string[]) => {
|
||||||
ok({
|
const items = paths.map((raw) => {
|
||||||
sharedDir: configStore.get('sharedDir'),
|
const dir = cleanPathPattern(raw)
|
||||||
templateDir: configStore.get('templateDir'),
|
if (!fs.existsSync(dir)) return { path: raw, bytes: 0, missing: true as const }
|
||||||
skipDllInit: configStore.get('skipDllInit', !app.isPackaged)
|
try {
|
||||||
|
const st = fs.statSync(dir)
|
||||||
|
if (!st.isDirectory()) return { path: raw, bytes: st.size }
|
||||||
|
return { path: raw, bytes: getDirectorySizeBytes(dir) }
|
||||||
|
} catch {
|
||||||
|
return { path: raw, bytes: 0, missing: true as const }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
return ok({ items })
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('config:set', (_e, patch: Record<string, string>) => {
|
ipcMain.handle('fs:parse-soon', (_e, filePath: string) => {
|
||||||
Object.entries(patch).forEach(([k, v]) => configStore.set(k, v))
|
try {
|
||||||
|
const soonPath = String(filePath || '').trim()
|
||||||
|
if (!soonPath) return fail(CS_FAIL, '模板路径为空')
|
||||||
|
if (!fs.existsSync(soonPath)) return fail(CS_FAIL, '模板文件不存在')
|
||||||
|
const raw = JSON.parse(fs.readFileSync(soonPath, 'utf8')) as Record<string, unknown>
|
||||||
|
return ok(parseSoonTemplate(soonPath, raw))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('fs:parse-soon', err)
|
||||||
|
return fail(CS_FAIL, err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('config:get', () => {
|
||||||
|
const payload: {
|
||||||
|
sharedDir: string
|
||||||
|
templateDir: string
|
||||||
|
skipDllInit?: boolean
|
||||||
|
} = {
|
||||||
|
sharedDir: configStore.get('sharedDir'),
|
||||||
|
templateDir: configStore.get('templateDir')
|
||||||
|
}
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
payload.skipDllInit = configStore.get('skipDllInit', false)
|
||||||
|
}
|
||||||
|
return ok(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('config:set', (_e, patch: Record<string, unknown>) => {
|
||||||
|
Object.entries(patch).forEach(([k, v]) => {
|
||||||
|
if (k === 'skipDllInit') {
|
||||||
|
if (app.isPackaged) return
|
||||||
|
const b =
|
||||||
|
v === true || v === 'true' || v === 1 || v === '1' || String(v).toLowerCase() === 'true'
|
||||||
|
configStore.set(k, b)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
configStore.set(k, v as string)
|
||||||
|
})
|
||||||
return ok(configStore.store)
|
return ok(configStore.store)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -228,17 +284,19 @@ export function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function handleBeforeQuit(): Promise<void> {
|
export async function handleBeforeQuit(): Promise<void> {
|
||||||
stopAllPolls()
|
const shouldCancel =
|
||||||
if (
|
|
||||||
mainAppState.mode === 'distributing' &&
|
mainAppState.mode === 'distributing' &&
|
||||||
mainAppState.activeJobId &&
|
!!mainAppState.activeJobId &&
|
||||||
isCancelApiAvailable()
|
isCancelApiAvailable()
|
||||||
) {
|
const cancelJobId = mainAppState.activeJobId
|
||||||
|
stopAllPolls()
|
||||||
|
if (shouldCancel && cancelJobId) {
|
||||||
try {
|
try {
|
||||||
dllAdminJobCancel(mainAppState.activeJobId)
|
dllAdminJobCancel(cancelJobId)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warn('before-quit cancel', e)
|
log.warn('before-quit cancel', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mainAppState.mode = 'ready'
|
mainAppState.mode = 'ready'
|
||||||
|
mainAppState.activeJobId = ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ import path from 'path'
|
|||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
sharedDir: string
|
sharedDir: string
|
||||||
templateDir: string
|
templateDir: string
|
||||||
/** 开发默认 true:不调用 SAPI_Init,避免无打印机时 DLL 刷错 */
|
/** G2 门禁 false:启动即 SAPI_Init;仅调试可改 true */
|
||||||
skipDllInit: boolean
|
skipDllInit: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultShared = path.join(app.getPath('userData'), 'Cardsoon', 'tasks')
|
const defaultShared = path.join('C:', 'PrintTasks')
|
||||||
|
|
||||||
export const configStore = new Store<AppConfig>({
|
export const configStore = new Store<AppConfig>({
|
||||||
name: 'cardsoon-config',
|
name: 'cardsoon-config',
|
||||||
defaults: {
|
defaults: {
|
||||||
sharedDir: defaultShared,
|
sharedDir: defaultShared,
|
||||||
templateDir: path.join(app.getPath('userData'), 'Cardsoon', 'templates'),
|
templateDir: path.join(app.getPath('userData'), 'Cardsoon', 'templates'),
|
||||||
skipDllInit: !app.isPackaged
|
// 正式版始终 Init;仅开发时可通过 --skip-dll-init 临时跳过
|
||||||
|
skipDllInit: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -83,6 +83,10 @@ export function setupNativeWorkingDir(): void {
|
|||||||
}
|
}
|
||||||
ensureBundledConfig(nativeDir)
|
ensureBundledConfig(nativeDir)
|
||||||
deployRuntimeConfigs(nativeDir)
|
deployRuntimeConfigs(nativeDir)
|
||||||
process.chdir(nativeDir)
|
const execDir = getProcessExecDir()
|
||||||
log.debug(`Native working directory: ${nativeDir}`)
|
const pathHead = [nativeDir, execDir].join(path.delimiter)
|
||||||
|
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()}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,17 +13,25 @@ export function setPollMainWindow(win: BrowserWindow): void {
|
|||||||
mainWindow = win
|
mainWindow = win
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPollMainWindow(): BrowserWindow | null {
|
||||||
|
return mainWindow && !mainWindow.isDestroyed() ? mainWindow : null
|
||||||
|
}
|
||||||
|
|
||||||
function send(channel: string, payload: unknown): void {
|
function send(channel: string, payload: unknown): void {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send(channel, payload)
|
mainWindow.webContents.send(channel, payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopJobPoll(): void {
|
export function stopJobPoll(resetMode = false): void {
|
||||||
if (jobTimer) {
|
if (jobTimer) {
|
||||||
clearInterval(jobTimer)
|
clearInterval(jobTimer)
|
||||||
jobTimer = null
|
jobTimer = null
|
||||||
}
|
}
|
||||||
|
if (resetMode && mainAppState.mode === 'distributing') {
|
||||||
|
mainAppState.mode = 'ready'
|
||||||
|
mainAppState.activeJobId = ''
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopUsbPoll(): void {
|
export function stopUsbPoll(): void {
|
||||||
@@ -34,12 +42,12 @@ export function stopUsbPoll(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function stopAllPolls(): void {
|
export function stopAllPolls(): void {
|
||||||
stopJobPoll()
|
stopJobPoll(true)
|
||||||
stopUsbPoll()
|
stopUsbPoll()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startJobPoll(id: string): void {
|
export function startJobPoll(id: string): void {
|
||||||
stopJobPoll()
|
stopJobPoll(false)
|
||||||
jobId = id
|
jobId = id
|
||||||
jobTimer = setInterval(() => {
|
jobTimer = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
@@ -60,16 +68,15 @@ export function startJobPoll(id: string): void {
|
|||||||
})
|
})
|
||||||
if (r.queryErrorCode !== 0) {
|
if (r.queryErrorCode !== 0) {
|
||||||
log.warn('GetJobStateById query failed', r.queryErrorCode)
|
log.warn('GetJobStateById query failed', r.queryErrorCode)
|
||||||
stopJobPoll()
|
stopJobPoll(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (failed || cancelled) {
|
if (failed || cancelled) {
|
||||||
stopJobPoll()
|
stopJobPoll(true)
|
||||||
if (cancelled) mainAppState.mode = 'ready'
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('job poll error', e)
|
log.error('job poll error', e)
|
||||||
stopJobPoll()
|
stopJobPoll(true)
|
||||||
}
|
}
|
||||||
}, POLL_INTERVAL_MS)
|
}, POLL_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function dllInit(params: InitParams): number {
|
|||||||
params.stopOnFailure ?? false,
|
params.stopOnFailure ?? false,
|
||||||
params.cleanTaskFile ?? true,
|
params.cleanTaskFile ?? true,
|
||||||
params.autoRetryTimes ?? 0,
|
params.autoRetryTimes ?? 0,
|
||||||
params.rejectConfig ?? true,
|
params.rejectConfig ?? false,
|
||||||
params.logLevel ?? LOG_FATAL_FLAG,
|
params.logLevel ?? LOG_FATAL_FLAG,
|
||||||
params.outBack ?? false
|
params.outBack ?? false
|
||||||
) as number
|
) as number
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { cleanPathPattern } from '@shared/path-pattern'
|
||||||
|
|
||||||
|
export { cleanPathPattern }
|
||||||
|
|
||||||
|
export function getDirectorySizeBytes(dirPath: string): number {
|
||||||
|
let total = 0
|
||||||
|
const stack = [dirPath]
|
||||||
|
while (stack.length) {
|
||||||
|
const current = stack.pop()!
|
||||||
|
let entries: fs.Dirent[]
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const ent of entries) {
|
||||||
|
const full = path.join(current, ent.name)
|
||||||
|
if (ent.isDirectory()) {
|
||||||
|
stack.push(full)
|
||||||
|
} else if (ent.isFile()) {
|
||||||
|
try {
|
||||||
|
total += fs.statSync(full).size
|
||||||
|
} catch {
|
||||||
|
/* skip unreadable file */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { pathToFileURL } from 'url'
|
||||||
|
|
||||||
|
export interface TemplateFieldRow {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedSoonTemplate {
|
||||||
|
frontImageUrl: string
|
||||||
|
backImageUrl: string
|
||||||
|
fields: TemplateFieldRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
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 []
|
||||||
|
return (entry[1] as unknown[]).filter((x) => x && typeof x === 'object') as Record<string, unknown>[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickStr(item: Record<string, unknown>, keys: string[]): string {
|
||||||
|
for (const k of keys) {
|
||||||
|
const hit = Object.entries(item).find(([name]) => name.toLowerCase() === k.toLowerCase())
|
||||||
|
if (hit && hit[1] != null && String(hit[1]).trim()) return String(hit[1]).trim()
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function sideOf(item: Record<string, unknown>): 'front' | 'back' | '' {
|
||||||
|
const raw = pickStr(item, ['side', 'face', 'surface', 'cardface']).toLowerCase()
|
||||||
|
if (!raw) return ''
|
||||||
|
if (['front', '0', 'f', '正面', '正'].includes(raw)) return 'front'
|
||||||
|
if (['back', '1', 'b', '背面', '背'].includes(raw)) return 'back'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function sideLabel(side: 'front' | 'back'): string {
|
||||||
|
return side === 'front' ? '正面' : '背面'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAssetPath(soonPath: string, ref: string): string {
|
||||||
|
if (!ref) return ''
|
||||||
|
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)}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
|
||||||
|
const imgs = pickArray(raw, 'Img')
|
||||||
|
const texts = pickArray(raw, 'Text')
|
||||||
|
|
||||||
|
let frontImageUrl = ''
|
||||||
|
let backImageUrl = ''
|
||||||
|
const fields: TemplateFieldRow[] = []
|
||||||
|
|
||||||
|
imgs.forEach((item, idx) => {
|
||||||
|
const fileRef = pickStr(item, ['file', 'path', 'img', 'image', 'src', 'filename'])
|
||||||
|
if (!fileRef) return
|
||||||
|
let side = sideOf(item)
|
||||||
|
if (!side) side = idx === 0 ? 'front' : idx === 1 ? 'back' : 'front'
|
||||||
|
const url = resolveAssetPath(soonPath, fileRef)
|
||||||
|
if (side === 'front') {
|
||||||
|
if (!frontImageUrl) frontImageUrl = url
|
||||||
|
const name = pickStr(item, ['name', 'field', 'key']) || 'IMAGE'
|
||||||
|
fields.push({ label: toFieldLabel(name, 'front'), value: fileRef })
|
||||||
|
} else if (!backImageUrl) {
|
||||||
|
backImageUrl = url
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
texts.forEach((item) => {
|
||||||
|
const name = pickStr(item, ['name', 'field', 'key', 'id'])
|
||||||
|
if (!name) return
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
|
||||||
|
return { frontImageUrl, backImageUrl, fields }
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { app } from 'electron'
|
|
||||||
|
|
||||||
const SUPPRESS_PATTERNS = [
|
const SUPPRESS_PATTERNS = [
|
||||||
'Card Printer not detected',
|
'Card Printer not detected',
|
||||||
'PrinterAdaptor.cpp',
|
'PrinterAdaptor.cpp',
|
||||||
@@ -14,8 +12,6 @@ function shouldSuppress(chunk: string | Uint8Array): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function suppressKnownDllStderr(): void {
|
export function suppressKnownDllStderr(): void {
|
||||||
if (app.isPackaged) return
|
|
||||||
|
|
||||||
const stderr = process.stderr
|
const stderr = process.stderr
|
||||||
const original = stderr.write.bind(stderr)
|
const original = stderr.write.bind(stderr)
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const channels = {
|
|||||||
'dialog:open-directory',
|
'dialog:open-directory',
|
||||||
'dialog:open-file',
|
'dialog:open-file',
|
||||||
'fs:path-exists',
|
'fs:path-exists',
|
||||||
|
'fs:dir-size',
|
||||||
|
'fs:parse-soon',
|
||||||
'config:get',
|
'config:get',
|
||||||
'config:set',
|
'config:set',
|
||||||
'shell:open-path',
|
'shell:open-path',
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ export async function dllPrinterInfo(): Promise<IpcResult<Record<string, unknown
|
|||||||
export function parsePrinterInfo(json: Record<string, unknown>): PrinterStatusDisplay {
|
export function parsePrinterInfo(json: Record<string, unknown>): PrinterStatusDisplay {
|
||||||
const list = (json.printerList as Record<string, unknown>[]) || []
|
const list = (json.printerList as Record<string, unknown>[]) || []
|
||||||
const p = list[0] || {}
|
const p = list[0] || {}
|
||||||
|
const serial =
|
||||||
|
p.szPrinterSerial ?? p.PrinterSerial ?? p.SerialNo ?? p.PrinterName ?? '—'
|
||||||
return {
|
return {
|
||||||
ribbonType: String(p.RibbonType ?? '—'),
|
ribbonType: String(p.RibbonType ?? '—'),
|
||||||
statusText: String(p.PrinterType ?? '—'),
|
statusText: String(p.PrinterType ?? '—'),
|
||||||
serialNo: String(p.PrinterName ?? '—'),
|
serialNo: String(serial),
|
||||||
printedCount: Number(p.PrintedCount ?? 0)
|
printedCount: Number(p.PrintedCount ?? 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,6 +92,22 @@ export async function fsPathExists(paths: string[]): Promise<IpcResult<{ missing
|
|||||||
return api().invoke('fs:path-exists', paths) as Promise<IpcResult<{ missing: string[] }>>
|
return api().invoke('fs:path-exists', paths) as Promise<IpcResult<{ missing: string[] }>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fsDirSize(
|
||||||
|
paths: string[]
|
||||||
|
): Promise<IpcResult<{ items: { path: string; bytes: number; missing?: boolean }[] }>> {
|
||||||
|
return api().invoke('fs:dir-size', paths) as Promise<
|
||||||
|
IpcResult<{ items: { path: string; bytes: number; missing?: boolean }[] }>
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fsParseSoon(filePath: string): Promise<
|
||||||
|
IpcResult<{ frontImageUrl: string; backImageUrl: string; fields: { label: string; value: string }[] }>
|
||||||
|
> {
|
||||||
|
return api().invoke('fs:parse-soon', filePath) as Promise<
|
||||||
|
IpcResult<{ frontImageUrl: string; backImageUrl: string; fields: { label: string; value: string }[] }>
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
export async function configGet(): Promise<
|
export async function configGet(): Promise<
|
||||||
IpcResult<{ sharedDir: string; templateDir: string; skipDllInit?: boolean }>
|
IpcResult<{ sharedDir: string; templateDir: string; skipDllInit?: boolean }>
|
||||||
> {
|
> {
|
||||||
@@ -98,7 +116,7 @@ export async function configGet(): Promise<
|
|||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function configSet(patch: Record<string, string>): Promise<IpcResult> {
|
export async function configSet(patch: Record<string, unknown>): Promise<IpcResult> {
|
||||||
return api().invoke('config:set', patch) as Promise<IpcResult>
|
return api().invoke('config:set', patch) as Promise<IpcResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="c-header">
|
<div class="c-header-block">
|
||||||
<div class="c-header__brand">CARDSOON</div>
|
<header class="c-header">
|
||||||
<div class="c-header__center">
|
<div class="c-header__brand">CARDSOON</div>
|
||||||
<div v-if="mode" class="c-mode-badge c-mode-badge--home">{{ mode }}</div>
|
<div class="c-header__center">
|
||||||
<div class="c-status-capsule">
|
<div v-if="mode" class="c-mode-badge c-mode-badge--home">{{ mode }}</div>
|
||||||
<span>色带: <b>{{ status.ribbonType }}</b></span>
|
<div class="c-status-capsule">
|
||||||
<span>状态: <b>{{ status.statusText }}</b></span>
|
<span>色带: <b>{{ status.ribbonType }}</b></span>
|
||||||
<span>序列号: <b>{{ status.serialNo }}</b></span>
|
<span
|
||||||
<span>已发行: <b>{{ status.printedCount }}</b></span>
|
>状态: <b :class="statusTone">{{ status.statusText }}</b></span
|
||||||
|
>
|
||||||
|
<span>序列号: <b>{{ status.serialNo }}</b></span>
|
||||||
|
<span>已发行: <b>{{ status.printedCount }}</b></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="c-header__actions-slot">
|
||||||
<div class="c-header__actions-slot">
|
<div class="c-header-actions">
|
||||||
<div class="c-header-actions">
|
<slot />
|
||||||
<slot />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</header>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -26,4 +30,10 @@ defineProps<{ mode?: string }>()
|
|||||||
|
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const status = computed(() => configStore.printer)
|
const status = computed(() => configStore.printer)
|
||||||
|
|
||||||
|
const statusTone = computed(() => {
|
||||||
|
const t = status.value.statusText
|
||||||
|
if (t.includes('未初始化') || t.includes('未连接')) return 'c-status-warn'
|
||||||
|
return ''
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="rootRef"
|
||||||
|
class="c-app-select"
|
||||||
|
:class="{ 'is-open': open, 'c-app-select--block': block }"
|
||||||
|
:style="width ? { '--app-select-width': width } : undefined"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
:id="id"
|
||||||
|
type="button"
|
||||||
|
class="c-app-select__trigger"
|
||||||
|
:aria-expanded="open"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<span class="c-app-select__label">{{ currentLabel }}</span>
|
||||||
|
<span class="c-app-select__arrow" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<ul v-show="open && list.length" class="c-app-select__menu" role="listbox">
|
||||||
|
<li
|
||||||
|
v-for="opt in list"
|
||||||
|
:key="String(opt.value)"
|
||||||
|
role="option"
|
||||||
|
class="c-app-select__option"
|
||||||
|
:class="{ 'is-active': opt.value === modelValue }"
|
||||||
|
:aria-selected="opt.value === modelValue"
|
||||||
|
@click="pick(opt.value)"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import type { SelectOption } from '@/constants/selectOptions'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string | number
|
||||||
|
items?: SelectOption[]
|
||||||
|
id?: string
|
||||||
|
width?: string
|
||||||
|
block?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
items: () => [],
|
||||||
|
block: false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [string | number] }>()
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const rootRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
const list = computed(() => props.items ?? [])
|
||||||
|
|
||||||
|
const currentLabel = computed(
|
||||||
|
() => list.value.find((o) => o.value === props.modelValue)?.label ?? '—'
|
||||||
|
)
|
||||||
|
|
||||||
|
function toggle(): void {
|
||||||
|
if (!list.value.length) return
|
||||||
|
open.value = !open.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(value: string | number): void {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocClick(e: MouseEvent): void {
|
||||||
|
const el = rootRef.value
|
||||||
|
if (!el || !open.value) return
|
||||||
|
if (!el.contains(e.target as Node)) open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocKeydown(e: KeyboardEvent): void {
|
||||||
|
if (e.key === 'Escape') open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(open, (isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('click', onDocClick)
|
||||||
|
document.addEventListener('keydown', onDocKeydown)
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('click', onDocClick)
|
||||||
|
document.removeEventListener('keydown', onDocKeydown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', onDocClick)
|
||||||
|
document.removeEventListener('keydown', onDocKeydown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<template>
|
||||||
|
<div class="c-message-host" aria-live="polite">
|
||||||
|
<transition name="cs-message">
|
||||||
|
<div
|
||||||
|
v-if="current"
|
||||||
|
:key="current.id"
|
||||||
|
class="c-message"
|
||||||
|
:class="`c-message--${current.type}`"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<AppIcon :name="iconFor(current.type)" size="sm" />
|
||||||
|
<span class="c-message__text">{{ current.message }}</span>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import AppIcon from '@/components/AppIcon.vue'
|
||||||
|
import { useToastStore, type ToastType } from '@/stores/toast'
|
||||||
|
import type { IconName } from '@/assets/icons'
|
||||||
|
|
||||||
|
const toast = useToastStore()
|
||||||
|
const current = computed(() => toast.items[0] ?? null)
|
||||||
|
|
||||||
|
function iconFor(type: ToastType): IconName {
|
||||||
|
switch (type) {
|
||||||
|
case 'success':
|
||||||
|
return 'check-circle'
|
||||||
|
case 'error':
|
||||||
|
return 'times'
|
||||||
|
case 'info':
|
||||||
|
return 'info-circle'
|
||||||
|
default:
|
||||||
|
return 'warning'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="m-settings-modal" :class="{ 'is-open': modelValue }" :aria-hidden="!modelValue">
|
<div class="m-settings-modal" :class="{ 'is-open': modelValue }" :aria-hidden="!modelValue">
|
||||||
<div class="m-settings-modal__backdrop" @click="close" />
|
<div class="m-settings-modal__backdrop" />
|
||||||
<section
|
<section
|
||||||
class="m-settings-modal__dialog"
|
class="m-settings-modal__dialog"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -10,32 +10,62 @@
|
|||||||
>
|
>
|
||||||
<header class="m-settings-modal__header">
|
<header class="m-settings-modal__header">
|
||||||
<h2 id="settingsModalTitle">设置</h2>
|
<h2 id="settingsModalTitle">设置</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="m-settings-modal__close"
|
||||||
|
aria-label="关闭"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<AppIcon name="times" size="sm" />
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="m-settings-modal__body">
|
<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">
|
<section class="m-settings-group">
|
||||||
<h3 class="m-settings-group__title">基础配置</h3>
|
<h3 class="m-settings-group__title">基础配置</h3>
|
||||||
<div class="m-settings-group__panel">
|
<div class="m-settings-group__panel">
|
||||||
<div class="m-settings-row">
|
<div class="m-settings-row">
|
||||||
<label for="settingPriority">优先级</label>
|
<label for="settingPriority">优先级</label>
|
||||||
<select id="settingPriority" v-model="form.priority" class="c-select">
|
<AppSelect
|
||||||
<option value="low">低</option>
|
id="settingPriority"
|
||||||
<option value="mid">中</option>
|
v-model="form.priority"
|
||||||
<option value="high">高</option>
|
block
|
||||||
</select>
|
:items="PRIORITY_OPTIONS"
|
||||||
|
/>
|
||||||
<label for="settingRibbonType">色带类型</label>
|
<label for="settingRibbonType">色带类型</label>
|
||||||
<select id="settingRibbonType" v-model="form.ribbonType" class="c-select">
|
<AppSelect
|
||||||
<option value="any">任何</option>
|
id="settingRibbonType"
|
||||||
<option value="YMCKO">YMCKO</option>
|
v-model="form.ribbonType"
|
||||||
<option value="YMCK">YMCK</option>
|
block
|
||||||
</select>
|
:items="RIBBON_TYPE_OPTIONS"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="m-settings-row">
|
<div class="m-settings-row m-settings-row--format">
|
||||||
<label for="settingCopyFormat">拷贝前格式化类型</label>
|
<label for="settingCopyFormat">拷贝前格式化类型</label>
|
||||||
<select id="settingCopyFormat" v-model="form.formatType" class="c-select">
|
<AppSelect
|
||||||
<option value="none">不格式化</option>
|
id="settingCopyFormat"
|
||||||
<option value="fat">快速格式化</option>
|
v-model="form.formatType"
|
||||||
<option value="ntfs">完全格式化</option>
|
block
|
||||||
</select>
|
:items="FORMAT_TYPE_OPTIONS"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -76,7 +106,11 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<footer class="m-settings-modal__footer">
|
<footer class="m-settings-modal__footer">
|
||||||
<button type="button" class="c-button-cs m-settings-modal__confirm" @click="close">
|
<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>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -85,27 +119,72 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onUnmounted, watch } from 'vue'
|
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'
|
import { useDistributeFormStore } from '@/stores/distributeForm'
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean }>()
|
const props = defineProps<{ modelValue: boolean }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
|
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
|
||||||
|
|
||||||
const form = useDistributeFormStore()
|
const form = useDistributeFormStore()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
const { retryInit } = useAppBootstrap()
|
||||||
|
const sharedDir = ref(configStore.sharedDir)
|
||||||
|
|
||||||
function close(): void {
|
function close(): void {
|
||||||
emit('update:modelValue', false)
|
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 {
|
function onKeydown(e: KeyboardEvent): void {
|
||||||
if (e.key === 'Escape') close()
|
if (e.key === 'Escape') close()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(open) => {
|
async (open) => {
|
||||||
if (open) window.addEventListener('keydown', onKeydown)
|
if (open) {
|
||||||
else window.removeEventListener('keydown', onKeydown)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -113,3 +192,55 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style src="@/styles/pages/page4.css"></style>
|
<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>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="c-nav-btn"
|
class="c-nav-btn"
|
||||||
:class="btnClass"
|
:class="btnClass"
|
||||||
|
:disabled="disabled"
|
||||||
@click="$emit('click')"
|
@click="$emit('click')"
|
||||||
>
|
>
|
||||||
<AppIcon v-if="icon" :name="icon" size="sm" />
|
<AppIcon v-if="icon" :name="icon" size="sm" />
|
||||||
@@ -21,8 +22,9 @@ const props = withDefaults(
|
|||||||
label?: string
|
label?: string
|
||||||
variant?: 'default' | 'primary' | 'stop'
|
variant?: 'default' | 'primary' | 'stop'
|
||||||
active?: boolean
|
active?: boolean
|
||||||
|
disabled?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{ variant: 'default', active: false }
|
{ variant: 'default', active: false, disabled: false }
|
||||||
)
|
)
|
||||||
|
|
||||||
defineEmits<{ click: [] }>()
|
defineEmits<{ click: [] }>()
|
||||||
|
|||||||
@@ -18,16 +18,25 @@ const props = withDefaults(
|
|||||||
activeStep?: number
|
activeStep?: number
|
||||||
failedStep?: number
|
failedStep?: number
|
||||||
mode?: 'running' | 'failed'
|
mode?: 'running' | 'failed'
|
||||||
|
variant?: 'distribute' | 'collect'
|
||||||
}>(),
|
}>(),
|
||||||
{ activeStep: 2, mode: 'running' }
|
{ activeStep: 2, mode: 'running', variant: 'distribute' }
|
||||||
)
|
)
|
||||||
|
|
||||||
const steps = [
|
const distributeSteps = [
|
||||||
{ key: 'prep', label: '任务准备' },
|
{ key: 'prep', label: '任务准备' },
|
||||||
{ key: 'copy', label: '拷贝数据' },
|
{ key: 'copy', label: '拷贝数据' },
|
||||||
{ key: 'print', label: '打印卡片' },
|
{ key: 'print', label: '打印卡片' },
|
||||||
{ key: 'done', label: '完成' }
|
{ key: 'done', label: '完成' }
|
||||||
]
|
] as const
|
||||||
|
|
||||||
|
const collectSteps = [
|
||||||
|
{ key: 'prep', label: '任务准备' },
|
||||||
|
{ key: 'copy', label: '拷贝数据' },
|
||||||
|
{ key: 'done', label: '完成' }
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const steps = computed(() => (props.variant === 'collect' ? collectSteps : distributeSteps))
|
||||||
|
|
||||||
const failedStep = computed(() => props.failedStep ?? (props.mode === 'failed' ? 3 : -1))
|
const failedStep = computed(() => props.failedStep ?? (props.mode === 'failed' ? 3 : -1))
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { notify } from '@/composables/useNotify'
|
||||||
import {
|
import {
|
||||||
configGet,
|
configGet,
|
||||||
dllInit,
|
dllInit,
|
||||||
@@ -23,7 +23,9 @@ export function useAppBootstrap(): {
|
|||||||
const info = await dllPrinterInfo()
|
const info = await dllPrinterInfo()
|
||||||
if (info.ok && info.data) {
|
if (info.ok && info.data) {
|
||||||
configStore.setPrinter(parsePrinterInfo(info.data))
|
configStore.setPrinter(parsePrinterInfo(info.data))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
configStore.setPrinter({ ...configStore.printer, statusText: '未连接打印机' })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doInit(): Promise<void> {
|
async function doInit(): Promise<void> {
|
||||||
@@ -31,34 +33,37 @@ export function useAppBootstrap(): {
|
|||||||
const sharedDir = cfg.data?.sharedDir || ''
|
const sharedDir = cfg.data?.sharedDir || ''
|
||||||
configStore.setSharedDir(sharedDir)
|
configStore.setSharedDir(sharedDir)
|
||||||
|
|
||||||
const skipDll = cfg.data?.skipDllInit ?? import.meta.env.DEV
|
if (import.meta.env.DEV && cfg.data?.skipDllInit === true) {
|
||||||
if (skipDll) {
|
appStore.setInitialized(false, '开发模式已跳过 DLL 初始化')
|
||||||
appStore.setInitialized(true)
|
configStore.setPrinter({ ...configStore.printer, statusText: '未初始化(开发)' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const init = await dllInit({ sharedDir, logLevel: 3 })
|
const init = await dllInit({ sharedDir })
|
||||||
if (!init.ok) {
|
if (!init.ok) {
|
||||||
appStore.setInitialized(false, init.message || 'Init 失败')
|
appStore.setInitialized(false, init.message || 'Init 失败')
|
||||||
ElMessage.error(init.message || '初始化失败,请检查任务目录权限')
|
configStore.setPrinter({ ...configStore.printer, statusText: '未初始化' })
|
||||||
|
notify.error(init.message || '初始化失败,请检查任务目录权限')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
appStore.setInitialized(true)
|
appStore.setInitialized(true)
|
||||||
|
configStore.setPrinter({ ...configStore.printer, statusText: '系统已初始化' })
|
||||||
const warn = (init.data as { warning?: string } | undefined)?.warning
|
const warn = (init.data as { warning?: string } | undefined)?.warning
|
||||||
if (warn) ElMessage.warning(warn)
|
if (warn) notify.warning(warn)
|
||||||
try {
|
try {
|
||||||
await refreshHeader()
|
const rej = await dllRejectAvailable()
|
||||||
|
if (rej.ok && rej.data) configStore.rejectApiAvailable = rej.data.available
|
||||||
} catch {
|
} catch {
|
||||||
/* 无打印机时 GetPrinterInfo 可能失败,保留 Mock 展示 */
|
/* optional API */
|
||||||
}
|
}
|
||||||
const rej = await dllRejectAvailable()
|
|
||||||
if (rej.ok && rej.data) configStore.rejectApiAvailable = rej.data.available
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
if (bootstrapped) return
|
if (bootstrapped) return
|
||||||
bootstrapped = true
|
bootstrapped = true
|
||||||
await doInit()
|
window.setTimeout(() => {
|
||||||
|
void doInit()
|
||||||
|
}, 300)
|
||||||
})
|
})
|
||||||
|
|
||||||
return { retryInit: doInit, refreshHeader }
|
return { retryInit: doInit, refreshHeader }
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useToastStore, type ToastType } from '@/stores/toast'
|
||||||
|
|
||||||
|
function push(type: ToastType, message: string, durationMs = 4500): void {
|
||||||
|
useToastStore().push(type, message, durationMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notify = {
|
||||||
|
success: (message: string, durationMs?: number) => push('success', message, durationMs),
|
||||||
|
warning: (message: string, durationMs?: number) => push('warning', message, durationMs),
|
||||||
|
error: (message: string, durationMs?: number) => push('error', message, durationMs),
|
||||||
|
info: (message: string, durationMs?: number) => push('info', message, durationMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const INIT_HINT = '系统未初始化,请进入「数据分发 → 设置」重试 Init'
|
||||||
|
|
||||||
|
/** 未初始化等业务拦截时的统一提示 */
|
||||||
|
export function notifyRequireInit(action?: string): void {
|
||||||
|
notify.warning(action ? `系统未初始化,无法${action}` : INIT_HINT)
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { DESIGN_WIDTH, DESIGN_HEIGHT } from '@shared/viewport'
|
|||||||
|
|
||||||
export { DESIGN_WIDTH, DESIGN_HEIGHT }
|
export { DESIGN_WIDTH, DESIGN_HEIGHT }
|
||||||
|
|
||||||
/** 按内容区宽度缩放 720×360;内容区高由主进程按同比例设置,顶对齐无底部留白 */
|
/** 按内容区宽度缩放 720×360,顶对齐 */
|
||||||
export function useScale(shellRef: Ref<HTMLElement | null>): void {
|
export function useScale(shellRef: Ref<HTMLElement | null>): void {
|
||||||
function updateScale(): void {
|
function updateScale(): void {
|
||||||
const shell = shellRef.value
|
const shell = shellRef.value
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const CARD_CAPACITY_GB = 16
|
||||||
|
export const CARD_CAPACITY_BYTES = CARD_CAPACITY_GB * 1024 ** 3
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export interface SelectOption {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COPY_TYPE_OPTIONS: SelectOption[] = [
|
||||||
|
{ label: '文件拷贝', value: 0 },
|
||||||
|
{ label: '镜像刻录', value: 1 }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const FORMAT_TYPE_OPTIONS: SelectOption[] = [
|
||||||
|
{ label: '不格式化', value: 'none' },
|
||||||
|
{ label: '快速格式化', value: 'fat' },
|
||||||
|
{ label: '完全格式化', value: 'ntfs' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PRIORITY_OPTIONS: SelectOption[] = [
|
||||||
|
{ label: '低', value: 'low' },
|
||||||
|
{ label: '中', value: 'mid' },
|
||||||
|
{ label: '高', value: 'high' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const RIBBON_TYPE_OPTIONS: SelectOption[] = [
|
||||||
|
{ label: '任何', value: 'any' },
|
||||||
|
{ label: 'YMCKO', value: 'YMCKO' },
|
||||||
|
{ label: 'YMCK', value: 'YMCK' }
|
||||||
|
]
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="shellRef" class="app-shell">
|
<div ref="shellRef" class="app-shell">
|
||||||
|
<AppToastHost />
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import AppToastHost from '@/components/AppToastHost.vue'
|
||||||
import { useScale } from '@/composables/useScale'
|
import { useScale } from '@/composables/useScale'
|
||||||
|
|
||||||
const shellRef = ref<HTMLElement | null>(null)
|
const shellRef = ref<HTMLElement | null>(null)
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import 'element-plus/theme-chalk/el-message.css'
|
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
@@ -11,5 +9,4 @@ import './styles/shell.css'
|
|||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.config.globalProperties.$message = ElMessage
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -2,26 +2,35 @@ import type { Router } from 'vue-router'
|
|||||||
import { useJobStore } from '@/stores/job'
|
import { useJobStore } from '@/stores/job'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
|
|
||||||
/**
|
|
||||||
* 无 jobId 访问 running → 重定向 config
|
|
||||||
* distributing 时访问 collect → 重定向 home
|
|
||||||
* 离开 running(非 failed)→ 清 distributing,回 config
|
|
||||||
*/
|
|
||||||
export function setupRouterGuards(router: Router): void {
|
export function setupRouterGuards(router: Router): void {
|
||||||
router.beforeEach((to, from) => {
|
router.beforeEach((to, from) => {
|
||||||
const job = useJobStore()
|
const job = useJobStore()
|
||||||
const app = useAppStore()
|
const app = useAppStore()
|
||||||
|
|
||||||
if (to.path === '/distribute/running') {
|
if (to.path === '/distribute/running' && !job.jobId) {
|
||||||
if (!job.jobId && !job.mockJobStarted) {
|
return { path: '/distribute/config' }
|
||||||
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') {
|
if (to.path === '/collect' && app.mode === 'distributing') {
|
||||||
return { path: '/home' }
|
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 (from.path === '/distribute/running' && to.path !== '/distribute/failed') {
|
||||||
if (to.path !== '/distribute/config') {
|
if (to.path !== '/distribute/config') {
|
||||||
app.setMode('ready')
|
app.setMode('ready')
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ const router = createRouter({
|
|||||||
name: 'distribute-failed',
|
name: 'distribute-failed',
|
||||||
component: () => import('@/views/DistributeFailedView.vue')
|
component: () => import('@/views/DistributeFailedView.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/collect/running',
|
||||||
|
name: 'collect-running',
|
||||||
|
component: () => import('@/views/DistributeRunningView.vue')
|
||||||
|
},
|
||||||
{ path: '/collect', name: 'collect', component: () => import('@/views/DataCollectView.vue') }
|
{ path: '/collect', name: 'collect', component: () => import('@/views/DataCollectView.vue') }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ import { defineStore } from 'pinia'
|
|||||||
|
|
||||||
export const useCollectStore = defineStore('collect', {
|
export const useCollectStore = defineStore('collect', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
destPath: 'C:/Users/jerry',
|
destPath: '',
|
||||||
cardOutput: 1 as 1 | 2
|
cardOutput: 1 as 1 | 2,
|
||||||
|
successCount: 0,
|
||||||
|
failCount: 0
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
reset() {
|
reset() {
|
||||||
this.destPath = 'C:/Users/jerry'
|
this.destPath = ''
|
||||||
this.cardOutput = 1
|
this.cardOutput = 1
|
||||||
|
this.successCount = 0
|
||||||
|
this.failCount = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import type { PrinterStatusDisplay } from '@/types/printer'
|
import { defaultPrinterStatus, type PrinterStatusDisplay } from '@/types/printer'
|
||||||
import { mockPrinterStatus } from '@/mocks/printer'
|
|
||||||
|
|
||||||
export const useConfigStore = defineStore('config', {
|
export const useConfigStore = defineStore('config', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
sharedDir: '',
|
sharedDir: '',
|
||||||
templateDir: '',
|
templateDir: '',
|
||||||
printer: { ...mockPrinterStatus } as PrinterStatusDisplay,
|
printer: { ...defaultPrinterStatus } as PrinterStatusDisplay,
|
||||||
rejectApiAvailable: false
|
rejectApiAvailable: false
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export interface TemplateFieldRow {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplatePreview {
|
||||||
|
frontImageUrl: string
|
||||||
|
backImageUrl: string
|
||||||
|
fields: TemplateFieldRow[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface PathListItem {
|
export interface PathListItem {
|
||||||
path: string
|
path: string
|
||||||
meta: string
|
meta: string
|
||||||
|
sizeBytes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DistributeFormState {
|
export interface DistributeFormState {
|
||||||
pathList: PathListItem[]
|
pathList: PathListItem[]
|
||||||
volumeLabel: string
|
volumeLabel: string
|
||||||
templateFile: string
|
templateFile: string
|
||||||
|
templatePreview: TemplatePreview | null
|
||||||
copyType: 0 | 1
|
copyType: 0 | 1
|
||||||
formatType: 'none' | 'fat' | 'ntfs'
|
formatType: 'none' | 'fat' | 'ntfs'
|
||||||
dongleEnabled: boolean
|
dongleEnabled: boolean
|
||||||
@@ -26,12 +39,10 @@ export interface DistributeFormState {
|
|||||||
|
|
||||||
function createDefaultForm(): DistributeFormState {
|
function createDefaultForm(): DistributeFormState {
|
||||||
return {
|
return {
|
||||||
pathList: [
|
pathList: [],
|
||||||
{ path: 'D:\\数据备份\\2026-04-20\\*.*', meta: '128 文件 | 3.8 GB' },
|
|
||||||
{ path: 'C:\\Users\\Public\\Documents\\*.*', meta: '45 文件 | 520 MB' }
|
|
||||||
],
|
|
||||||
volumeLabel: 'DATA_CARD',
|
volumeLabel: 'DATA_CARD',
|
||||||
templateFile: 'D:\\images\\template.jpg',
|
templateFile: '',
|
||||||
|
templatePreview: null,
|
||||||
copyType: 0,
|
copyType: 0,
|
||||||
formatType: 'none',
|
formatType: 'none',
|
||||||
dongleEnabled: true,
|
dongleEnabled: true,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { defineStore } from 'pinia'
|
|||||||
export const useJobStore = defineStore('job', {
|
export const useJobStore = defineStore('job', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
jobId: '',
|
jobId: '',
|
||||||
mockJobStarted: false,
|
|
||||||
submitting: false,
|
submitting: false,
|
||||||
successCount: 0,
|
successCount: 0,
|
||||||
failCount: 0
|
failCount: 0
|
||||||
@@ -11,14 +10,12 @@ export const useJobStore = defineStore('job', {
|
|||||||
actions: {
|
actions: {
|
||||||
setActiveJob(jobId: string) {
|
setActiveJob(jobId: string) {
|
||||||
this.jobId = jobId
|
this.jobId = jobId
|
||||||
this.mockJobStarted = true
|
|
||||||
},
|
},
|
||||||
markMockStarted() {
|
clearActiveJob() {
|
||||||
this.mockJobStarted = true
|
this.jobId = ''
|
||||||
},
|
},
|
||||||
reset() {
|
reset() {
|
||||||
this.jobId = ''
|
this.jobId = ''
|
||||||
this.mockJobStarted = false
|
|
||||||
this.submitting = false
|
this.submitting = false
|
||||||
this.successCount = 0
|
this.successCount = 0
|
||||||
this.failCount = 0
|
this.failCount = 0
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export type ToastType = 'success' | 'warning' | 'error' | 'info'
|
||||||
|
|
||||||
|
export interface ToastItem {
|
||||||
|
id: number
|
||||||
|
type: ToastType
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let seq = 0
|
||||||
|
|
||||||
|
const timers = new Map<number, ReturnType<typeof setTimeout>>()
|
||||||
|
|
||||||
|
export const useToastStore = defineStore('toast', {
|
||||||
|
state: () => ({
|
||||||
|
items: [] as ToastItem[]
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
push(type: ToastType, message: string, durationMs = 3200): void {
|
||||||
|
const dup = this.items.find((t) => t.message === message && t.type === type)
|
||||||
|
if (dup) {
|
||||||
|
const oldTimer = timers.get(dup.id)
|
||||||
|
if (oldTimer) clearTimeout(oldTimer)
|
||||||
|
timers.delete(dup.id)
|
||||||
|
if (durationMs > 0) {
|
||||||
|
const timer = setTimeout(() => this.remove(dup.id), durationMs)
|
||||||
|
timers.set(dup.id, timer)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = ++seq
|
||||||
|
this.items = [{ id, type, message }]
|
||||||
|
if (durationMs > 0) {
|
||||||
|
const timer = setTimeout(() => this.remove(id), durationMs)
|
||||||
|
timers.set(id, timer)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove(id: number): void {
|
||||||
|
const timer = timers.get(id)
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer)
|
||||||
|
timers.delete(id)
|
||||||
|
}
|
||||||
|
this.items = this.items.filter((t) => t.id !== id)
|
||||||
|
},
|
||||||
|
clear(): void {
|
||||||
|
timers.forEach((timer) => clearTimeout(timer))
|
||||||
|
timers.clear()
|
||||||
|
this.items = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -340,14 +340,14 @@ body {
|
|||||||
/* [组件] 表单控件 Form */
|
/* [组件] 表单控件 Form */
|
||||||
.c-select,
|
.c-select,
|
||||||
.c-input {
|
.c-input {
|
||||||
padding: 0 4px;
|
padding: 0 8px;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ced4da;
|
||||||
border-radius: 2px;
|
border-radius: 3px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
outline: none;
|
outline: none;
|
||||||
height: 18px;
|
height: 24px;
|
||||||
line-height: 16px;
|
line-height: 22px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,54 @@
|
|||||||
基于 base.css 构建
|
基于 base.css 构建
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* 布局微调:增加左侧面板宽度给新控件 */
|
/* 左右栏固定 1:1,内容变化不挤占宽度 */
|
||||||
.m-panel--left {
|
.app-shell__main.l-main-flex {
|
||||||
flex: 5;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.m-panel--left,
|
||||||
.m-panel--right {
|
.m-panel--right {
|
||||||
flex: 5;
|
flex: none;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 允许自定义下拉菜单溢出面板(列表区仍单独滚动) */
|
||||||
|
.app-shell__main.l-main-flex > .c-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__main.l-main-flex > .c-panel .m-panel-toolbar {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell__main.l-main-flex > .c-panel > .c-panel__body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 工具栏 - 紧凑两行布局 ========== */
|
/* ========== 工具栏 - 紧凑两行布局 ========== */
|
||||||
.m-panel-toolbar {
|
.m-panel-toolbar {
|
||||||
padding: 10px 14px;
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
padding: 8px 10px;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
border-bottom: 1px solid #e9ecef;
|
border-bottom: 1px solid #e9ecef;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-row {
|
.toolbar-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-item {
|
.toolbar-item {
|
||||||
@@ -40,19 +66,14 @@
|
|||||||
.toolbar-item .c-input {
|
.toolbar-item .c-input {
|
||||||
width: 90px;
|
width: 90px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
padding: 0 6px;
|
padding: 0 8px;
|
||||||
font-size: 9px;
|
font-size: 12px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
border: 1px solid #ced4da;
|
border: 1px solid #ced4da;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-item .c-select {
|
.toolbar-item .c-app-select {
|
||||||
width: 90px;
|
--app-select-width: 96px;
|
||||||
height: 24px;
|
|
||||||
padding: 0 6px;
|
|
||||||
font-size: 9px;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: 1px solid #ced4da;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 复选框样式 */
|
/* 复选框样式 */
|
||||||
@@ -93,6 +114,16 @@
|
|||||||
color: #adb5bd;
|
color: #adb5bd;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-left: 2px;
|
margin-left: 2px;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-panel-toolbar .c-checkbox-item {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 列表业务项 (File Items) */
|
/* 列表业务项 (File Items) */
|
||||||
@@ -401,21 +432,25 @@
|
|||||||
|
|
||||||
.m-settings-modal__dialog {
|
.m-settings-modal__dialog {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 560px;
|
width: min(540px, calc(100% - 20px));
|
||||||
min-height: 265px;
|
max-height: calc(100% - 12px);
|
||||||
|
min-height: 0;
|
||||||
background: #f3f3f3;
|
background: #f3f3f3;
|
||||||
border: 1px solid #cfcfcf;
|
border: 1px solid #cfcfcf;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-settings-modal__header {
|
.m-settings-modal__header {
|
||||||
|
flex-shrink: 0;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 12px;
|
justify-content: space-between;
|
||||||
|
padding: 0 8px 0 12px;
|
||||||
border-bottom: 1px solid #dddddd;
|
border-bottom: 1px solid #dddddd;
|
||||||
background: linear-gradient(to bottom, #fbfbfb, #efefef);
|
background: linear-gradient(to bottom, #fbfbfb, #efefef);
|
||||||
}
|
}
|
||||||
@@ -426,35 +461,70 @@
|
|||||||
font-weight: 700;
|
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 {
|
.m-settings-modal__body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 8px 12px 6px;
|
min-height: 0;
|
||||||
|
padding: 6px 10px 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
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 {
|
.m-settings-group {
|
||||||
|
flex-shrink: 0;
|
||||||
border: 1px solid #d8d8d8;
|
border: 1px solid #d8d8d8;
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
padding: 8px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-settings-group--advanced {
|
.m-settings-group--advanced {
|
||||||
min-height: 132px;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-settings-group__title {
|
.m-settings-group__title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #444;
|
color: #444;
|
||||||
margin-bottom: 7px;
|
margin-bottom: 4px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-settings-group__panel {
|
.m-settings-group__panel {
|
||||||
background: #efefef;
|
background: #efefef;
|
||||||
border: 1px solid #d9d9d9;
|
border: 1px solid #d9d9d9;
|
||||||
padding: 8px;
|
padding: 6px;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-settings-row {
|
.m-settings-row {
|
||||||
@@ -476,21 +546,17 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-settings-row .c-select {
|
.m-settings-row .c-app-select {
|
||||||
height: 20px;
|
min-width: 0;
|
||||||
font-size: 10px;
|
|
||||||
border-radius: 2px;
|
|
||||||
border-color: #c9c9c9;
|
|
||||||
background: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-settings-options {
|
.m-settings-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
row-gap: 12px;
|
row-gap: 6px;
|
||||||
column-gap: 22px;
|
column-gap: 14px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
min-height: 92px;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-settings-check {
|
.m-settings-check {
|
||||||
@@ -509,15 +575,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.m-settings-modal__footer {
|
.m-settings-modal__footer {
|
||||||
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 0 12px 10px;
|
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 {
|
.m-settings-modal__confirm {
|
||||||
min-width: 68px;
|
min-width: 68px;
|
||||||
height: 22px;
|
height: 24px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
@@ -527,11 +623,20 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 12px;
|
padding: 6px 10px;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
border-bottom: 1px solid #e9ecef;
|
border-bottom: 1px solid #e9ecef;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-path-hint span {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-path-hint i {
|
.m-path-hint i {
|
||||||
@@ -549,18 +654,25 @@
|
|||||||
|
|
||||||
.c-path-item__info {
|
.c-path-item__info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.c-path-item__name {
|
.c-path-item__name {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.c-path-item__meta {
|
.c-path-item__meta {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: #999;
|
color: #999;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.c-path-item__delete {
|
.c-path-item__delete {
|
||||||
@@ -575,6 +687,7 @@
|
|||||||
|
|
||||||
/* 标签预览区 */
|
/* 标签预览区 */
|
||||||
.c-preview-area {
|
.c-preview-area {
|
||||||
|
flex-shrink: 0;
|
||||||
background: #2d3436;
|
background: #2d3436;
|
||||||
margin: 8px;
|
margin: 8px;
|
||||||
height: 105px;
|
height: 105px;
|
||||||
@@ -584,6 +697,7 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.5);
|
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.c-card-small {
|
.c-card-small {
|
||||||
@@ -606,11 +720,16 @@
|
|||||||
|
|
||||||
.c-card-small__label {
|
.c-card-small__label {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 紧凑数据表格 */
|
/* 紧凑数据表格 */
|
||||||
.c-data-table-mini {
|
.c-data-table-mini {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
border-collapse: separate;
|
border-collapse: separate;
|
||||||
border-spacing: 0 2px;
|
border-spacing: 0 2px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
@@ -624,10 +743,15 @@
|
|||||||
|
|
||||||
.c-data-table-mini td:first-child {
|
.c-data-table-mini td:first-child {
|
||||||
color: #666;
|
color: #666;
|
||||||
width: 35%;
|
width: 112px;
|
||||||
|
max-width: 112px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding-right: 8px;
|
padding-right: 6px;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.c-data-table-mini td:last-child {
|
.c-data-table-mini td:last-child {
|
||||||
@@ -640,6 +764,9 @@
|
|||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
font-size: 8px;
|
font-size: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.c-data-table-mini td:last-child.c-path-cell {
|
.c-data-table-mini td:last-child.c-path-cell {
|
||||||
@@ -649,9 +776,23 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-path-cell__text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.c-path-update {
|
.c-path-update {
|
||||||
|
flex-shrink: 0;
|
||||||
color: var(--cs-primary);
|
color: var(--cs-primary);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ body,
|
|||||||
height: 360px;
|
height: 360px;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -58,6 +59,12 @@ body,
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.c-nav-btn:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.m-tool-btn,
|
.m-tool-btn,
|
||||||
.m-task-card {
|
.m-task-card {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -115,3 +122,169 @@ body,
|
|||||||
.m-error-icon .app-icon--xl {
|
.m-error-icon .app-icon--xl {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.c-header-block {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-status-capsule b.c-status-warn {
|
||||||
|
color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义下拉:在 720×360 壳内渲染,字号与控件一致 */
|
||||||
|
.c-app-select {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: var(--app-select-width, 96px);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-app-select--block {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-app-select__trigger {
|
||||||
|
width: 100%;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 22px 0 8px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: #333;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-app-select__trigger:hover {
|
||||||
|
border-color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-app-select.is-open .c-app-select__trigger {
|
||||||
|
border-color: var(--cs-primary, #2ecc71);
|
||||||
|
box-shadow: 0 0 0 1px rgba(46, 204, 113, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-app-select__label {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-app-select__arrow {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
top: 50%;
|
||||||
|
margin-top: -2px;
|
||||||
|
border: 4px solid transparent;
|
||||||
|
border-top-color: #666;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-app-select.is-open .c-app-select__arrow {
|
||||||
|
margin-top: -6px;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-bottom-color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-app-select__menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 2px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 420;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
list-style: none;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||||
|
max-height: 168px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-app-select__option {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-app-select__option:hover {
|
||||||
|
background: #f0f2f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-app-select__option.is-active {
|
||||||
|
color: var(--cs-primary, #27ae60);
|
||||||
|
font-weight: 600;
|
||||||
|
background: #eef9f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 壳内 Message:顶部居中浮层,随 720×360 画布缩放 */
|
||||||
|
.c-message-host {
|
||||||
|
position: absolute;
|
||||||
|
top: 54px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 300;
|
||||||
|
pointer-events: none;
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100% - 48px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-message {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #303133;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-message__text {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-message .fas {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-message--success .fas {
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-message--warning .fas {
|
||||||
|
color: #e6a23c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-message--error .fas {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-message--info .fas {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-message-enter-active,
|
||||||
|
.cs-message-leave-active {
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cs-message-enter-from,
|
||||||
|
.cs-message-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,3 +4,11 @@ export interface PrinterStatusDisplay {
|
|||||||
serialNo: string
|
serialNo: string
|
||||||
printedCount: number
|
printedCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Init 前 Header 占位;阶段二由 GetPrinterInfo 覆盖 */
|
||||||
|
export const defaultPrinterStatus: PrinterStatusDisplay = {
|
||||||
|
ribbonType: '—',
|
||||||
|
statusText: '—',
|
||||||
|
serialNo: '—',
|
||||||
|
printedCount: 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import type { DistributeFormState } from '@/stores/distributeForm'
|
import type { DistributeFormState } from '@/stores/distributeForm'
|
||||||
|
import { cleanPathPattern } from '@shared/path-pattern'
|
||||||
function cleanPath(p: string): string {
|
|
||||||
return p.replace(/\\\*\\.\\*$/i, '').replace(/\/\*\.\*$/i, '').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildJobConfig(form: DistributeFormState): Record<string, unknown> {
|
export function buildJobConfig(form: DistributeFormState): Record<string, unknown> {
|
||||||
const taskId = `T${Date.now()}`
|
const taskId = `T${Date.now()}`
|
||||||
@@ -18,11 +15,11 @@ export function buildJobConfig(form: DistributeFormState): Record<string, unknow
|
|||||||
zone_type: form.copyType === 1 ? '1' : '0',
|
zone_type: form.copyType === 1 ? '1' : '0',
|
||||||
need_format: form.formatType !== 'none',
|
need_format: form.formatType !== 'none',
|
||||||
format_file: form.formatType === 'ntfs' ? 'NTFS' : 'FAT',
|
format_file: form.formatType === 'ntfs' ? 'NTFS' : 'FAT',
|
||||||
disk_size: '4GB',
|
disk_size: '16GB',
|
||||||
dongle_install_count: form.dongleEnabled ? form.dongleMode : -1
|
dongle_install_count: form.dongleEnabled ? form.dongleMode : -1
|
||||||
}
|
}
|
||||||
if (hasCopy) {
|
if (hasCopy) {
|
||||||
body.path_file = form.pathList.map((x) => cleanPath(x.path))
|
body.path_file = form.pathList.map((x) => cleanPathPattern(x.path))
|
||||||
}
|
}
|
||||||
if (hasPrint) {
|
if (hasPrint) {
|
||||||
body.json_file = form.templateFile.trim()
|
body.json_file = form.templateFile.trim()
|
||||||
@@ -30,5 +27,6 @@ export function buildJobConfig(form: DistributeFormState): Record<string, unknow
|
|||||||
}
|
}
|
||||||
if (form.generateIso) body.is_generate_iso = true
|
if (form.generateIso) body.is_generate_iso = true
|
||||||
if (form.generateZip) body.is_generate_zip = true
|
if (form.generateZip) body.is_generate_zip = true
|
||||||
|
if (form.failPrintLabel) body.is_printer_record_logo = true
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export function formatBytesCompact(bytes: number): string {
|
||||||
|
if (bytes <= 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'] as const
|
||||||
|
let n = bytes
|
||||||
|
let i = 0
|
||||||
|
while (n >= 1024 && i < units.length - 1) {
|
||||||
|
n /= 1024
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
const digits = i >= 2 ? (n >= 100 ? 0 : n >= 10 ? 1 : 2) : 0
|
||||||
|
return `${n.toFixed(digits)} ${units[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytesAsGb(bytes: number): string {
|
||||||
|
return (bytes / 1024 ** 3).toFixed(2)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ export function validateJobConfig(f: DistributeFormState): string | null {
|
|||||||
const hasCopy = f.pathList.length > 0
|
const hasCopy = f.pathList.length > 0
|
||||||
const hasPrint = !!f.templateFile.trim()
|
const hasPrint = !!f.templateFile.trim()
|
||||||
if (!hasCopy && !hasPrint) return '请配置拷贝路径或打印模板'
|
if (!hasCopy && !hasPrint) return '请配置拷贝路径或打印模板'
|
||||||
if (hasPrint && !f.templateFile.trim()) return '请选择 .soon 模板'
|
if (hasPrint && !/\.soon$/i.test(f.templateFile.trim())) return '请选择 .soon 模板'
|
||||||
if (hasCopy && f.pathList.some((p) => !p.path.trim())) return '路径不能为空'
|
if (hasCopy && f.pathList.some((p) => !p.path.trim())) return '路径不能为空'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<AppHeader mode="数据导入模式">
|
<AppHeader mode="数据收集模式">
|
||||||
<div class="c-nav-group">
|
<div class="c-nav-group">
|
||||||
<NavButton icon="home" label="首页" @click="router.push('/home')" />
|
<NavButton icon="home" label="首页" @click="goHome" />
|
||||||
<NavButton icon="trash" label="清空" @click="collectStore.reset()" />
|
<NavButton icon="trash" label="清空" @click="collectStore.reset()" />
|
||||||
<NavButton
|
<NavButton
|
||||||
icon="check-circle"
|
icon="check-circle"
|
||||||
label="提交"
|
label="提交"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:active="true"
|
:active="true"
|
||||||
|
:disabled="!canSubmit"
|
||||||
@click="onSubmit"
|
@click="onSubmit"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -20,13 +21,12 @@
|
|||||||
数据导入地址
|
数据导入地址
|
||||||
</h3>
|
</h3>
|
||||||
<div class="m-path-box">
|
<div class="m-path-box">
|
||||||
<span class="m-path-text">{{ collectStore.destPath }}</span>
|
<span class="m-path-text">{{ collectStore.destPath || '未选择目录' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="m-path-btn" :disabled="!canUse" @click="addPath">
|
<button type="button" class="m-path-btn" @click="addPath">
|
||||||
<AppIcon name="plus" size="sm" />
|
<AppIcon name="plus" size="sm" />
|
||||||
添加路径
|
添加路径
|
||||||
</button>
|
</button>
|
||||||
<p v-if="usbProgress >= 0" class="usb-progress-hint">USB 进度: {{ usbProgress }}%</p>
|
|
||||||
</section>
|
</section>
|
||||||
<div class="m-divider-v" />
|
<div class="m-divider-v" />
|
||||||
<section class="m-config-panel">
|
<section class="m-config-panel">
|
||||||
@@ -53,9 +53,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onUnmounted, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { notify, notifyRequireInit } from '@/composables/useNotify'
|
||||||
import AppShell from '@/layouts/AppShell.vue'
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
import AppHeader from '@/components/AppHeader.vue'
|
import AppHeader from '@/components/AppHeader.vue'
|
||||||
import AppFooter from '@/components/AppFooter.vue'
|
import AppFooter from '@/components/AppFooter.vue'
|
||||||
@@ -63,78 +63,58 @@ import NavButton from '@/components/NavButton.vue'
|
|||||||
import AppIcon from '@/components/AppIcon.vue'
|
import AppIcon from '@/components/AppIcon.vue'
|
||||||
import { useCollectStore } from '@/stores/collect'
|
import { useCollectStore } from '@/stores/collect'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import {
|
import { dialogOpenDirectory, dllUsbCopy, pollUsbStart } from '@/api/cardsoon'
|
||||||
dialogOpenDirectory,
|
|
||||||
dllUsbCopy,
|
|
||||||
onUsbPollTick,
|
|
||||||
pollUsbStart,
|
|
||||||
pollUsbStop
|
|
||||||
} from '@/api/cardsoon'
|
|
||||||
import type { UsbPollPayload } from '@/types/ipc'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const collectStore = useCollectStore()
|
const collectStore = useCollectStore()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
const canUse = computed(() => appStore.initialized)
|
const canUse = computed(() => appStore.initialized)
|
||||||
const usbProgress = ref(-1)
|
const canSubmit = computed(
|
||||||
let unsub: (() => void) | null = null
|
() => canUse.value && appStore.mode !== 'distributing' && appStore.mode !== 'usbCopying'
|
||||||
|
)
|
||||||
|
|
||||||
async function addPath(): Promise<void> {
|
async function addPath(): Promise<void> {
|
||||||
const r = await dialogOpenDirectory()
|
const r = await dialogOpenDirectory()
|
||||||
if (r.ok && r.data?.paths[0]) collectStore.destPath = r.data.paths[0]
|
if (!r.ok) {
|
||||||
|
notify.error(r.message || '打开目录选择失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const picked = r.data?.paths[0]
|
||||||
|
if (picked) collectStore.destPath = picked
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit(): Promise<void> {
|
async function onSubmit(): Promise<void> {
|
||||||
if (!canUse.value) {
|
if (!canUse.value) {
|
||||||
ElMessage.warning('系统未初始化')
|
notifyRequireInit('开始 USB 收集')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (appStore.mode === 'distributing') {
|
if (appStore.mode === 'distributing') {
|
||||||
ElMessage.warning('请先停止数据分发任务')
|
notify.warning('请先停止数据分发任务')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const r = await dllUsbCopy(collectStore.destPath, collectStore.cardOutput)
|
const dest = collectStore.destPath.trim()
|
||||||
|
if (!dest) {
|
||||||
|
notify.warning('请先选择数据导入目录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const r = await dllUsbCopy(dest, collectStore.cardOutput)
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
ElMessage.error(r.message || '可能已有任务在执行')
|
notify.error(r.message || '可能已有任务在执行')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
appStore.setMode('usbCopying')
|
appStore.setMode('usbCopying')
|
||||||
await pollUsbStart()
|
await pollUsbStart()
|
||||||
unsub = onUsbPollTick((p) => {
|
await router.push('/collect/running')
|
||||||
const payload = p as UsbPollPayload
|
|
||||||
if (payload.taskStatus === 1) usbProgress.value = payload.progress
|
|
||||||
if (payload.success) {
|
|
||||||
ElMessage.success('USB 收集完成')
|
|
||||||
cleanup()
|
|
||||||
router.push('/home')
|
|
||||||
}
|
|
||||||
if (payload.failed) {
|
|
||||||
ElMessage.error('USB 收集失败')
|
|
||||||
cleanup()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanup(): void {
|
function goHome(): void {
|
||||||
unsub?.()
|
if (appStore.mode === 'usbCopying') {
|
||||||
unsub = null
|
notify.warning('数据收集进行中,请先在任务页停止')
|
||||||
pollUsbStop()
|
return
|
||||||
appStore.setMode('ready')
|
}
|
||||||
usbProgress.value = -1
|
router.push('/home')
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
cleanup()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style src="@/styles/pages/page1.css"></style>
|
<style src="@/styles/pages/page1.css"></style>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.usb-progress-hint {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--cs-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
<AppHeader mode="数据分发模式">
|
<AppHeader mode="数据分发模式">
|
||||||
<div class="c-nav-group">
|
<div class="c-nav-group">
|
||||||
<NavButton icon="home" label="首页" @click="router.push('/home')" />
|
<NavButton icon="home" label="首页" @click="router.push('/home')" />
|
||||||
<NavButton icon="trash" label="清空" @click="formStore.reset()" />
|
<NavButton icon="trash" label="清空" @click="onClear" />
|
||||||
<NavButton
|
<NavButton
|
||||||
icon="check-circle"
|
icon="check-circle"
|
||||||
label="提交"
|
label="提交"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:active="true"
|
:active="true"
|
||||||
|
:disabled="!canSubmit"
|
||||||
@click="onSubmit"
|
@click="onSubmit"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -18,10 +19,9 @@
|
|||||||
<div class="c-panel__header">
|
<div class="c-panel__header">
|
||||||
<span class="c-panel__title">路径配置</span>
|
<span class="c-panel__title">路径配置</span>
|
||||||
<div class="c-nav-group">
|
<div class="c-nav-group">
|
||||||
<button type="button" class="c-button-cs" :disabled="!canUse" @click="addPath">
|
<button type="button" class="c-button-cs" @click="addPath">
|
||||||
添加路径
|
添加路径
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="c-button-cs" @click="settingsOpen = true">设置</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="m-path-hint">
|
<div class="m-path-hint">
|
||||||
@@ -36,20 +36,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="toolbar-item">
|
<div class="toolbar-item">
|
||||||
<span>拷贝类型</span>
|
<span>拷贝类型</span>
|
||||||
<select v-model="formStore.copyType" class="c-select">
|
<AppSelect v-model="formStore.copyType" :items="COPY_TYPE_OPTIONS" />
|
||||||
<option :value="0">文件拷贝</option>
|
|
||||||
<option :value="1">镜像刻录</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-row">
|
<div class="toolbar-row">
|
||||||
<div class="toolbar-item">
|
<div class="toolbar-item">
|
||||||
<span>格式化类型</span>
|
<span>格式化类型</span>
|
||||||
<select v-model="formStore.formatType" class="c-select">
|
<AppSelect v-model="formStore.formatType" :items="FORMAT_TYPE_OPTIONS" />
|
||||||
<option value="none">不格式化</option>
|
|
||||||
<option value="fat">快速格式化</option>
|
|
||||||
<option value="ntfs">完全格式化</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<label class="c-checkbox-item">
|
<label class="c-checkbox-item">
|
||||||
<input v-model="formStore.dongleEnabled" type="checkbox" />
|
<input v-model="formStore.dongleEnabled" type="checkbox" />
|
||||||
@@ -92,95 +85,95 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="c-preview-area">
|
<div class="c-preview-area">
|
||||||
<div class="c-card-small">
|
<div class="c-card-small c-card-small--slot">
|
||||||
<div class="c-card-small__row">
|
<img
|
||||||
<span class="c-card-small__label">检查项: 胸部平扫 & 下腹部平扫 CT</span>
|
v-if="formStore.templatePreview?.frontImageUrl"
|
||||||
</div>
|
class="c-card-small__img"
|
||||||
<div class="c-card-small__row">
|
:src="formStore.templatePreview.frontImageUrl"
|
||||||
<span class="c-card-small__label">病人: 张三丰</span>
|
alt="FRONT"
|
||||||
</div>
|
/>
|
||||||
<div class="c-card-small__row">
|
<span v-else class="c-card-side-label">FRONT</span>
|
||||||
<span class="c-card-small__label">编号: 38894545</span>
|
|
||||||
</div>
|
|
||||||
<div class="c-card-small__row c-card-small__row--barcode">
|
|
||||||
<span class="c-card-small__label">条形码:</span>
|
|
||||||
<div class="c-barcode-mock" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="c-card-small c-card-small--back">
|
<div class="c-card-small c-card-small--slot c-card-small--back">
|
||||||
<span>BACKSIDE PREVIEW</span>
|
<img
|
||||||
|
v-if="formStore.templatePreview?.backImageUrl"
|
||||||
|
class="c-card-small__img"
|
||||||
|
:src="formStore.templatePreview.backImageUrl"
|
||||||
|
alt="BACK"
|
||||||
|
/>
|
||||||
|
<span v-else class="c-card-side-label">BACK</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="c-panel__body">
|
<div class="c-panel__body">
|
||||||
<div class="m-data-section">
|
<div v-if="hasTemplatePreview" class="m-data-section">
|
||||||
<table class="c-data-table-mini">
|
<table class="c-data-table-mini">
|
||||||
<tr>
|
<tr v-for="(row, idx) in formStore.templatePreview!.fields" :key="idx">
|
||||||
<td>IMAGE [正面]</td>
|
<td>{{ row.label }}</td>
|
||||||
<td class="c-path-cell">
|
<td>
|
||||||
{{ previewImagePath }}
|
<span class="c-field-value" :title="row.value">{{ row.value || '—' }}</span>
|
||||||
<span class="c-path-update" @click="pickTemplate">..</span>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td>CHECK_DATE [背面]</td>
|
|
||||||
<td>2023.03.34</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>STUDY_ID [背面]</td>
|
|
||||||
<td>ZJ8341C40234</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>PATIENT_NAME [背面]</td>
|
|
||||||
<td>张三丰</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="m-dynamic-fields" />
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<DistributeSettingsModal v-model="settingsOpen" />
|
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { notify, notifyRequireInit } from '@/composables/useNotify'
|
||||||
import AppShell from '@/layouts/AppShell.vue'
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
import AppHeader from '@/components/AppHeader.vue'
|
import AppHeader from '@/components/AppHeader.vue'
|
||||||
import AppFooter from '@/components/AppFooter.vue'
|
import AppFooter from '@/components/AppFooter.vue'
|
||||||
import NavButton from '@/components/NavButton.vue'
|
import NavButton from '@/components/NavButton.vue'
|
||||||
import AppIcon from '@/components/AppIcon.vue'
|
import AppIcon from '@/components/AppIcon.vue'
|
||||||
import DistributeSettingsModal from '@/components/DistributeSettingsModal.vue'
|
import AppSelect from '@/components/AppSelect.vue'
|
||||||
|
import { COPY_TYPE_OPTIONS, FORMAT_TYPE_OPTIONS } from '@/constants/selectOptions'
|
||||||
|
import { CARD_CAPACITY_BYTES, CARD_CAPACITY_GB } from '@/constants/cardCapacity'
|
||||||
import { useDistributeFormStore } from '@/stores/distributeForm'
|
import { useDistributeFormStore } from '@/stores/distributeForm'
|
||||||
import { useJobStore } from '@/stores/job'
|
import { useJobStore } from '@/stores/job'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { validateJobConfig } from '@/utils/validateJobConfig'
|
import { validateJobConfig } from '@/utils/validateJobConfig'
|
||||||
import { buildJobConfig } from '@/utils/buildJobConfig'
|
import { buildJobConfig } from '@/utils/buildJobConfig'
|
||||||
import { dialogOpenDirectory, dialogOpenSoon, dllJobCreate, fsPathExists } from '@/api/cardsoon'
|
import { formatBytesAsGb, formatBytesCompact } from '@/utils/formatBytes'
|
||||||
|
import { dialogOpenDirectory, dialogOpenSoon, dllJobCreate, fsDirSize, fsParseSoon, fsPathExists } from '@/api/cardsoon'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const formStore = useDistributeFormStore()
|
const formStore = useDistributeFormStore()
|
||||||
const jobStore = useJobStore()
|
const jobStore = useJobStore()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const settingsOpen = ref(false)
|
|
||||||
const canUse = computed(() => appStore.initialized)
|
const canUse = computed(() => appStore.initialized)
|
||||||
|
const canSubmit = computed(
|
||||||
|
() => canUse.value && !jobStore.submitting && appStore.mode !== 'usbCopying'
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalLoadedBytes = computed(() =>
|
||||||
|
formStore.pathList.reduce((sum, item) => sum + (item.sizeBytes || 0), 0)
|
||||||
|
)
|
||||||
|
|
||||||
const loadPercent = computed(() => {
|
const loadPercent = computed(() => {
|
||||||
if (!formStore.pathList.length) return 0
|
if (!formStore.pathList.length) return 0
|
||||||
return Math.min(85, 40 + formStore.pathList.length * 22)
|
return Math.min(100, Math.round((totalLoadedBytes.value / CARD_CAPACITY_BYTES) * 100))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasTemplatePreview = computed(
|
||||||
|
() => !!formStore.templatePreview && formStore.templatePreview.fields.length > 0
|
||||||
|
)
|
||||||
|
|
||||||
const loadProgressText = computed(() => {
|
const loadProgressText = computed(() => {
|
||||||
if (!formStore.pathList.length) return '已加载: 0 GB / 2 GB (0%)'
|
const loadedGb = formatBytesAsGb(totalLoadedBytes.value)
|
||||||
return `已加载: 1.7 GB / 2 GB (${loadPercent.value}%)`
|
return `已加载: ${loadedGb} GB / ${CARD_CAPACITY_GB} GB (${loadPercent.value}%)`
|
||||||
})
|
})
|
||||||
|
|
||||||
const previewImagePath = computed(
|
function onClear(): void {
|
||||||
() => formStore.templateFile || 'D:\\images\\template.jpg'
|
formStore.reset()
|
||||||
)
|
notify.info('已清空,已恢复初始状态')
|
||||||
|
}
|
||||||
|
|
||||||
const dongleHint = computed(() => {
|
const dongleHint = computed(() => {
|
||||||
if (!formStore.dongleEnabled) return '(未启用)'
|
if (!formStore.dongleEnabled) return '(未启用)'
|
||||||
@@ -192,10 +185,33 @@ const dongleHint = computed(() => {
|
|||||||
|
|
||||||
async function addPath(): Promise<void> {
|
async function addPath(): Promise<void> {
|
||||||
const r = await dialogOpenDirectory()
|
const r = await dialogOpenDirectory()
|
||||||
if (!r.ok || !r.data?.paths.length) return
|
if (!r.ok) {
|
||||||
r.data.paths.forEach((p) => {
|
notify.error(r.message || '打开目录选择失败')
|
||||||
formStore.pathList.push({ path: `${p}\\*.*`, meta: '待拷贝' })
|
return
|
||||||
})
|
}
|
||||||
|
if (!r.data?.paths.length) return
|
||||||
|
for (const dir of r.data.paths) {
|
||||||
|
const idx = formStore.pathList.length
|
||||||
|
formStore.pathList.push({
|
||||||
|
path: `${dir}\\*.*`,
|
||||||
|
meta: '计算中…',
|
||||||
|
sizeBytes: 0
|
||||||
|
})
|
||||||
|
await refreshPathSize(idx, dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPathSize(idx: number, dir: string): Promise<void> {
|
||||||
|
const item = formStore.pathList[idx]
|
||||||
|
if (!item) return
|
||||||
|
const r = await fsDirSize([dir])
|
||||||
|
if (!r.ok || !r.data?.items[0]) {
|
||||||
|
item.meta = '大小未知'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { bytes, missing } = r.data.items[0]
|
||||||
|
item.sizeBytes = bytes
|
||||||
|
item.meta = missing ? '路径无效' : `${formatBytesCompact(bytes)} · 待拷贝`
|
||||||
}
|
}
|
||||||
|
|
||||||
function removePath(idx: number): void {
|
function removePath(idx: number): void {
|
||||||
@@ -203,29 +219,59 @@ function removePath(idx: number): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pickTemplate(): Promise<void> {
|
async function pickTemplate(): Promise<void> {
|
||||||
const r = await dialogOpenSoon()
|
if (!canUse.value) {
|
||||||
if (r.ok && r.data?.path) {
|
notifyRequireInit('选择打印模板')
|
||||||
formStore.templateFile = r.data.path
|
return
|
||||||
ElMessage.success('已选择模板')
|
|
||||||
}
|
}
|
||||||
|
const r = await dialogOpenSoon()
|
||||||
|
if (!r.ok) {
|
||||||
|
notify.error(r.message || '打开模板选择失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const soonPath = r.data?.path?.trim()
|
||||||
|
if (!soonPath) return
|
||||||
|
const parsed = await fsParseSoon(soonPath)
|
||||||
|
if (!parsed.ok || !parsed.data) {
|
||||||
|
notify.error(parsed.message || '模板解析失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formStore.templateFile = soonPath
|
||||||
|
formStore.templatePreview = parsed.data
|
||||||
|
const { fields, frontImageUrl, backImageUrl } = parsed.data
|
||||||
|
if (!fields.length && !frontImageUrl && !backImageUrl) {
|
||||||
|
notify.warning('模板已打开,但未解析到可预览内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notify.success('已加载标签模板')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit(): Promise<void> {
|
async function onSubmit(): Promise<void> {
|
||||||
if (!canUse.value) {
|
if (!canUse.value) {
|
||||||
ElMessage.warning('系统未初始化')
|
notifyRequireInit('提交分发任务')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (appStore.mode === 'usbCopying') {
|
||||||
|
notify.warning('请先等待 USB 收集完成')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (jobStore.submitting) return
|
if (jobStore.submitting) return
|
||||||
const err = validateJobConfig(formStore)
|
const err = validateJobConfig(formStore)
|
||||||
if (err) {
|
if (err) {
|
||||||
ElMessage.warning(err)
|
notify.warning(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const paths = formStore.pathList.map((x) => x.path)
|
const paths = formStore.pathList.map((x) => x.path)
|
||||||
if (paths.length) {
|
if (paths.length) {
|
||||||
const ex = await fsPathExists(paths)
|
const ex = await fsPathExists(paths)
|
||||||
if (ex.ok && ex.data?.missing.length) {
|
if (ex.ok && ex.data?.missing.length) {
|
||||||
ElMessage.error(`路径不存在: ${ex.data.missing.join(', ')}`)
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -234,7 +280,7 @@ async function onSubmit(): Promise<void> {
|
|||||||
const json = JSON.stringify(buildJobConfig(formStore))
|
const json = JSON.stringify(buildJobConfig(formStore))
|
||||||
const created = await dllJobCreate(json)
|
const created = await dllJobCreate(json)
|
||||||
if (!created.ok || !created.data?.jobId) {
|
if (!created.ok || !created.data?.jobId) {
|
||||||
ElMessage.error(created.message || '创建任务失败')
|
notify.error(created.message || '创建任务失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jobStore.setActiveJob(created.data.jobId)
|
jobStore.setActiveJob(created.data.jobId)
|
||||||
@@ -249,32 +295,37 @@ async function onSubmit(): Promise<void> {
|
|||||||
<style src="@/styles/pages/page4.css"></style>
|
<style src="@/styles/pages/page4.css"></style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.c-card-small__row--barcode {
|
.c-card-small--slot {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
.c-barcode-mock {
|
|
||||||
flex: 1;
|
|
||||||
height: 12px;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
90deg,
|
|
||||||
#000,
|
|
||||||
#000 1px,
|
|
||||||
transparent 1px,
|
|
||||||
transparent 3px
|
|
||||||
);
|
|
||||||
}
|
|
||||||
.c-card-small--back {
|
|
||||||
background: #f8f9fa;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #ccc;
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-card-small__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-card-side-label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
color: #bbb;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
.c-path-update {
|
|
||||||
cursor: pointer;
|
.c-card-small--back {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-field-value {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function onBack(): void {
|
function onBack(): void {
|
||||||
jobStore.mockJobStarted = false
|
jobStore.clearActiveJob()
|
||||||
router.push('/distribute/config')
|
router.push('/distribute/config')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<AppHeader mode="数据分发模式">
|
<AppHeader :mode="headerMode">
|
||||||
<NavButton icon="stop" label="停止" variant="stop" @click="onStop" />
|
<NavButton icon="stop" label="停止" variant="stop" @click="onStop" />
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
<main class="app-shell__main l-main-full">
|
<main class="app-shell__main l-main-full">
|
||||||
@@ -10,11 +10,17 @@
|
|||||||
<h2 class="c-status-title is-looping">{{ statusTitle }}</h2>
|
<h2 class="c-status-title is-looping">{{ statusTitle }}</h2>
|
||||||
<p class="c-status-sub">{{ statusSub }}</p>
|
<p class="c-status-sub">{{ statusSub }}</p>
|
||||||
<p class="c-status-counter">
|
<p class="c-status-counter">
|
||||||
任务已经完成<span class="ok">{{ jobStore.successCount }}</span
|
任务已经完成<span class="ok">{{ successCount }}</span>次,其中失败次数是<span
|
||||||
>次,其中失败次数是<span class="err">{{ jobStore.failCount }}</span>。
|
class="err"
|
||||||
|
>{{ failCount }}</span
|
||||||
|
>。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<WorkflowSteps :active-step="ui.workflowStep" mode="running" />
|
<WorkflowSteps
|
||||||
|
:active-step="workflowStep"
|
||||||
|
:variant="isCollect ? 'collect' : 'distribute'"
|
||||||
|
mode="running"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="m-right-panel">
|
<div class="m-right-panel">
|
||||||
<div class="m-progress-circle">
|
<div class="m-progress-circle">
|
||||||
@@ -39,60 +45,96 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { notify } from '@/composables/useNotify'
|
||||||
import AppShell from '@/layouts/AppShell.vue'
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
import AppHeader from '@/components/AppHeader.vue'
|
import AppHeader from '@/components/AppHeader.vue'
|
||||||
import AppFooter from '@/components/AppFooter.vue'
|
import AppFooter from '@/components/AppFooter.vue'
|
||||||
import NavButton from '@/components/NavButton.vue'
|
import NavButton from '@/components/NavButton.vue'
|
||||||
import WorkflowSteps from '@/components/WorkflowSteps.vue'
|
import WorkflowSteps from '@/components/WorkflowSteps.vue'
|
||||||
import { useJobStore } from '@/stores/job'
|
import { useJobStore } from '@/stores/job'
|
||||||
|
import { useCollectStore } from '@/stores/collect'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import {
|
import {
|
||||||
dllJobCancel,
|
dllJobCancel,
|
||||||
onJobPollTick,
|
onJobPollTick,
|
||||||
|
onUsbPollTick,
|
||||||
pollJobStart,
|
pollJobStart,
|
||||||
pollJobStop
|
pollJobStop,
|
||||||
|
pollUsbStop
|
||||||
} from '@/api/cardsoon'
|
} from '@/api/cardsoon'
|
||||||
import { mapJobStateToUi, shouldUseProgress } from '@/utils/job-state'
|
import { mapJobStateToUi, shouldUseProgress } from '@/utils/job-state'
|
||||||
import type { JobPollPayload } from '@/types/ipc'
|
import type { JobPollPayload, UsbPollPayload } from '@/types/ipc'
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const jobStore = useJobStore()
|
|
||||||
const appStore = useAppStore()
|
|
||||||
|
|
||||||
const CIRCLE_LEN = 283
|
const CIRCLE_LEN = 283
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const jobStore = useJobStore()
|
||||||
|
const collectStore = useCollectStore()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
const isCollect = computed(() => route.name === 'collect-running')
|
||||||
|
const headerMode = computed(() => (isCollect.value ? '数据收集模式' : '数据分发模式'))
|
||||||
|
const successCount = computed(() =>
|
||||||
|
isCollect.value ? collectStore.successCount : jobStore.successCount
|
||||||
|
)
|
||||||
|
const failCount = computed(() => (isCollect.value ? collectStore.failCount : jobStore.failCount))
|
||||||
|
|
||||||
const progress = ref(0)
|
const progress = ref(0)
|
||||||
const strokeOffset = ref(CIRCLE_LEN)
|
const strokeOffset = ref(CIRCLE_LEN)
|
||||||
const ui = ref(mapJobStateToUi(0))
|
const workflowStep = ref(1)
|
||||||
|
const waitCard = ref(false)
|
||||||
let unsub: (() => void) | null = null
|
let unsub: (() => void) | null = null
|
||||||
|
let fakeTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const displayProgress = computed(() => progress.value)
|
const displayProgress = computed(() => Math.round(progress.value))
|
||||||
const statusTitle = computed(() => (ui.value.hint === 'waitCard' ? '等待插卡' : '循环执行中'))
|
|
||||||
const statusSub = computed(() => '请插入数据卡,任务将自动连续执行')
|
|
||||||
|
|
||||||
function applyProgress(p: JobPollPayload): void {
|
const statusTitle = computed(() => (waitCard.value ? '等待插卡' : '循环执行中'))
|
||||||
ui.value = mapJobStateToUi(p.jobState)
|
const statusSub = computed(() =>
|
||||||
|
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 stopFakeProgress(): void {
|
||||||
|
if (fakeTimer) {
|
||||||
|
clearInterval(fakeTimer)
|
||||||
|
fakeTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyJobProgress(p: JobPollPayload): void {
|
||||||
|
const ui = mapJobStateToUi(p.jobState)
|
||||||
|
workflowStep.value = ui.workflowStep
|
||||||
|
waitCard.value = ui.hint === 'waitCard'
|
||||||
if (shouldUseProgress(p.jobState)) {
|
if (shouldUseProgress(p.jobState)) {
|
||||||
progress.value = Math.min(100, Math.max(0, p.progress))
|
setProgress(p.progress)
|
||||||
strokeOffset.value = CIRCLE_LEN - (CIRCLE_LEN * progress.value) / 100
|
|
||||||
}
|
}
|
||||||
if (p.queryErrorCode !== 0) {
|
if (p.queryErrorCode !== 0) {
|
||||||
ElMessage.error(`查询任务失败: ${p.queryErrorCode}`)
|
notify.error(`查询任务失败: ${p.queryErrorCode}`)
|
||||||
pollJobStop()
|
finishDistribute(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (p.failed) {
|
if (p.failed) {
|
||||||
jobStore.failCount += 1
|
jobStore.failCount += 1
|
||||||
pollJobStop()
|
|
||||||
appStore.setMode('ready')
|
|
||||||
router.push('/distribute/failed')
|
router.push('/distribute/failed')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (p.cancelled) {
|
if (p.cancelled) {
|
||||||
pollJobStop()
|
finishDistribute(false)
|
||||||
appStore.setMode('ready')
|
|
||||||
router.push('/distribute/config')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (p.finished) {
|
if (p.finished) {
|
||||||
@@ -100,28 +142,81 @@ function applyProgress(p: JobPollPayload): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (p.failed) {
|
||||||
|
collectStore.failCount += 1
|
||||||
|
notify.error('USB 收集失败')
|
||||||
|
finishCollect(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (p.success) {
|
||||||
|
collectStore.successCount += 1
|
||||||
|
setProgress(100)
|
||||||
|
workflowStep.value = 3
|
||||||
|
notify.success('USB 收集完成')
|
||||||
|
window.setTimeout(() => finishCollect(true), 600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishDistribute(toHome: boolean): void {
|
||||||
|
stopFakeProgress()
|
||||||
|
pollJobStop()
|
||||||
|
jobStore.clearActiveJob()
|
||||||
|
appStore.setMode('ready')
|
||||||
|
router.push(toHome ? '/home' : '/distribute/config')
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishCollect(toHome: boolean): void {
|
||||||
|
stopFakeProgress()
|
||||||
|
pollUsbStop()
|
||||||
|
appStore.setMode('ready')
|
||||||
|
router.push(toHome ? '/home' : '/collect')
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (isCollect.value) {
|
||||||
|
if (appStore.mode !== 'usbCopying') {
|
||||||
|
router.replace('/collect')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
workflowStep.value = 1
|
||||||
|
setProgress(0)
|
||||||
|
startFakeProgress()
|
||||||
|
unsub = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!jobStore.jobId) {
|
if (!jobStore.jobId) {
|
||||||
router.replace('/distribute/config')
|
router.replace('/distribute/config')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
appStore.setMode('distributing')
|
appStore.setMode('distributing')
|
||||||
await pollJobStart(jobStore.jobId)
|
await pollJobStart(jobStore.jobId)
|
||||||
unsub = onJobPollTick((payload) => applyProgress(payload as JobPollPayload))
|
unsub = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
unsub?.()
|
unsub?.()
|
||||||
pollJobStop()
|
stopFakeProgress()
|
||||||
if (appStore.mode === 'distributing') appStore.setMode('ready')
|
if (isCollect.value) {
|
||||||
|
if (appStore.mode === 'usbCopying') pollUsbStop()
|
||||||
|
} else if (appStore.mode === 'distributing') {
|
||||||
|
pollJobStop()
|
||||||
|
appStore.setMode('ready')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onStop(): Promise<void> {
|
async function onStop(): Promise<void> {
|
||||||
|
if (isCollect.value) {
|
||||||
|
finishCollect(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
await dllJobCancel(jobStore.jobId)
|
await dllJobCancel(jobStore.jobId)
|
||||||
await pollJobStop()
|
finishDistribute(false)
|
||||||
jobStore.mockJobStarted = false
|
|
||||||
appStore.setMode('ready')
|
|
||||||
router.push('/distribute/config')
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -130,6 +225,6 @@ async function onStop(): Promise<void> {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.m-progress-circle .fill {
|
.m-progress-circle .fill {
|
||||||
stroke-dasharray: 283;
|
stroke-dasharray: 283;
|
||||||
transition: stroke-dashoffset 0.5s ease;
|
transition: stroke-dashoffset 0.45s ease;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessage } from 'element-plus'
|
import { notify, notifyRequireInit } from '@/composables/useNotify'
|
||||||
import AppShell from '@/layouts/AppShell.vue'
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
import AppHeader from '@/components/AppHeader.vue'
|
import AppHeader from '@/components/AppHeader.vue'
|
||||||
import AppFooter from '@/components/AppFooter.vue'
|
import AppFooter from '@/components/AppFooter.vue'
|
||||||
@@ -65,42 +65,55 @@ const appStore = useAppStore()
|
|||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const canUse = computed(() => appStore.initialized)
|
const canUse = computed(() => appStore.initialized)
|
||||||
|
|
||||||
function guardInit(): boolean {
|
function guardInit(action?: string): boolean {
|
||||||
if (canUse.value) return true
|
if (canUse.value) return true
|
||||||
ElMessage.warning('系统未初始化')
|
notifyRequireInit(action)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onReset(): Promise<void> {
|
async function onReset(): Promise<void> {
|
||||||
if (!guardInit()) return
|
if (!guardInit('重置打印机')) return
|
||||||
const r = await dllPrinterReset()
|
const r = await dllPrinterReset()
|
||||||
ElMessage[r.ok ? 'success' : 'error'](r.ok ? '已发送重置指令' : r.message || '重置失败')
|
if (r.ok) notify.success('已发送重置指令')
|
||||||
|
else notify.error(r.message || '重置失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onReject(): Promise<void> {
|
async function onReject(): Promise<void> {
|
||||||
if (!guardInit()) return
|
if (!guardInit('废弃卡片')) return
|
||||||
if (!configStore.rejectApiAvailable) {
|
if (!configStore.rejectApiAvailable) {
|
||||||
ElMessage.warning('当前环境不支持废卡接口')
|
notify.warning('当前环境不支持废卡接口')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const r = await dllPrinterReject()
|
const r = await dllPrinterReject()
|
||||||
ElMessage[r.ok ? 'success' : 'error'](r.ok ? '已废弃卡片' : r.message || '操作失败')
|
if (r.ok) notify.success('已废弃卡片')
|
||||||
|
else notify.error(r.message || '操作失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onTemplate(): Promise<void> {
|
async function onTemplate(): Promise<void> {
|
||||||
if (!guardInit()) return
|
if (!guardInit('打开模板目录')) return
|
||||||
await shellOpenTemplateDir()
|
const r = await shellOpenTemplateDir()
|
||||||
|
if (!r.ok) notify.error(r.message || '打开模板目录失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
function guardBusy(): boolean {
|
||||||
|
if (appStore.mode === 'distributing') {
|
||||||
|
notify.warning('请先停止数据分发任务')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (appStore.mode === 'usbCopying') {
|
||||||
|
notify.warning('USB 收集进行中,请等待完成')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function goDistribute(): void {
|
function goDistribute(): void {
|
||||||
|
if (!guardBusy()) return
|
||||||
router.push('/distribute/config')
|
router.push('/distribute/config')
|
||||||
}
|
}
|
||||||
|
|
||||||
function goCollect(): void {
|
function goCollect(): void {
|
||||||
if (appStore.mode === 'distributing') {
|
if (!guardBusy()) return
|
||||||
ElMessage.warning('请先停止数据分发任务')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
router.push('/collect')
|
router.push('/collect')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** 去掉路径列表项末尾的 \*.* / /*.* 通配后缀,得到真实目录 */
|
||||||
|
export function cleanPathPattern(p: string): string {
|
||||||
|
return p.replace(/[\\/]\*\.\*$/i, '').trim()
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
export const DESIGN_WIDTH = 720
|
export const DESIGN_WIDTH = 720
|
||||||
export const DESIGN_HEIGHT = 360
|
export const DESIGN_HEIGHT = 360
|
||||||
|
|
||||||
/** 内容区高度 = 宽 × (360/720),与 useScale 按宽缩放后的画布高一致 */
|
/** 内容区高 = 宽×360/720,与 useScale 按宽缩放一致 */
|
||||||
export function contentHeightForWidth(contentWidth: number): number {
|
export function contentHeightForWidth(contentWidth: number): number {
|
||||||
return Math.ceil((contentWidth * DESIGN_HEIGHT) / DESIGN_WIDTH)
|
return Math.ceil((contentWidth * DESIGN_HEIGHT) / DESIGN_WIDTH)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user