优化一些bug

This commit is contained in:
24kycj
2026-05-27 00:17:13 +08:00
parent 79174e7e03
commit 22b17aed35
48 changed files with 1529 additions and 388 deletions
+9 -3
View File
@@ -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": {
+21
View File
@@ -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
View File
@@ -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()
} }
+87 -29
View File
@@ -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 = ''
} }
+4 -3
View File
@@ -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
} }
}) })
+6 -2
View File
@@ -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()}`)
} }
+14 -7
View File
@@ -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)
} }
+1 -1
View File
@@ -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
+32
View File
@@ -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
}
+85
View File
@@ -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)
+2
View File
@@ -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',
+20 -2
View File
@@ -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>
} }
+25 -15
View File
@@ -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)
}
+1 -1
View File
@@ -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)
-3
View File
@@ -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')
+18 -9
View File
@@ -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')
+5
View File
@@ -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') }
] ]
}) })
+7 -3
View File
@@ -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
} }
} }
}) })
+2 -3
View File
@@ -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: {
+16 -5
View File
@@ -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,
+2 -5
View File
@@ -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
+53
View File
@@ -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 = []
}
}
})
+6 -6
View File
@@ -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;
} }
+181 -40
View File
@@ -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;
+173
View File
@@ -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);
}
+8
View File
@@ -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
}
+4 -6
View File
@@ -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
} }
+16
View File
@@ -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
} }
+34 -54
View File
@@ -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>
+27 -14
View File
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
/** 去掉路径列表项末尾的 \*.* / /*.* 通配后缀,得到真实目录 */
export function cleanPathPattern(p: string): string {
return p.replace(/[\\/]\*\.\*$/i, '').trim()
}
+1 -1
View File
@@ -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