Compare commits
19 Commits
22b17aed35
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ab1bcbd5e | |||
| ac566846c9 | |||
| 19858f9a62 | |||
| 3918522831 | |||
| 7862ff2b2a | |||
| 82d7431e6f | |||
| 931fcf90a4 | |||
| f1b73ee3d3 | |||
| 6b135eaf7a | |||
| 10bc05f498 | |||
| 057285ed89 | |||
| 75490d8647 | |||
| 3ec168350c | |||
| a6fc966d8b | |||
| 356e6a3d19 | |||
| 3168daedf4 | |||
| ece0a06a80 | |||
| 574cb353e8 | |||
| 505e72ce28 |
@@ -2,6 +2,7 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
out/
|
out/
|
||||||
release/
|
release/
|
||||||
|
*.tsbuildinfo
|
||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { resolve } from 'path'
|
import { resolve } from 'path'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
|
||||||
|
|
||||||
|
const pkg = JSON.parse(readFileSync(resolve('package.json'), 'utf-8')) as { version: string }
|
||||||
|
|
||||||
const sharedAlias = { '@shared': resolve('src/shared') }
|
const sharedAlias = { '@shared': resolve('src/shared') }
|
||||||
|
|
||||||
@@ -19,6 +23,17 @@ export default defineConfig({
|
|||||||
...sharedAlias
|
...sharedAlias
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
plugins: [vue()]
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(pkg.version)
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
// 构建时预编译多语言消息 + 使用 vue-i18n runtime 构建,
|
||||||
|
// 避免运行时 new Function() 编译消息(会被 CSP script-src 'self' 拦截导致白屏)
|
||||||
|
VueI18nPlugin({
|
||||||
|
include: [resolve('src/renderer/src/i18n/locales/**')],
|
||||||
|
runtimeOnly: true
|
||||||
|
})
|
||||||
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
+1031
-312
File diff suppressed because it is too large
Load Diff
+49
-10
@@ -8,30 +8,46 @@
|
|||||||
"node": "16.15.0"
|
"node": "16.15.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "electron-vite dev -- --skip-dll-init",
|
"dev": "electron-vite dev",
|
||||||
"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",
|
||||||
"predist": "node scripts/check-native-dlls.js",
|
"predist": "node scripts/check-native-dlls.js && node scripts/clean-release-artifacts.js",
|
||||||
"predist:dir": "node scripts/check-native-dlls.js",
|
"predist:dir": "node scripts/check-native-dlls.js",
|
||||||
"predist:zip": "node scripts/check-native-dlls.js",
|
"predist:zip": "node scripts/check-native-dlls.js && node scripts/clean-release-artifacts.js",
|
||||||
"dist": "electron-vite build && electron-builder",
|
"dist": "electron-vite build && electron-builder",
|
||||||
"dist:dir": "electron-vite build && electron-builder --dir",
|
"dist:dir": "electron-vite build && electron-builder --dir",
|
||||||
"dist:zip": "electron-vite build && electron-builder --win zip"
|
"dist:zip": "electron-vite build && electron-builder --win zip",
|
||||||
|
"prerelease": "node scripts/check-native-dlls.js && node scripts/clean-release-artifacts.js",
|
||||||
|
"release": "electron-vite build && electron-builder --win nsis"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.4.0",
|
|
||||||
"electron-log": "^5.1.2",
|
"electron-log": "^5.1.2",
|
||||||
"electron-store": "^8.1.0",
|
"electron-store": "^8.1.0",
|
||||||
"element-plus": "^2.4.4",
|
|
||||||
"koffi": "^2.9.0",
|
"koffi": "^2.9.0",
|
||||||
"pinia": "^2.1.7",
|
"vue-i18n": "^9.14.4"
|
||||||
"vue": "^3.4.21",
|
|
||||||
"vue-router": "^4.3.0"
|
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.cardsoon.machine",
|
"appId": "com.cardsoon.machine",
|
||||||
"productName": "卡树数据卡打印系统",
|
"productName": "卡树数据卡打印系统",
|
||||||
|
"asar": true,
|
||||||
|
"asarUnpack": [
|
||||||
|
"node_modules/koffi/build/koffi/win32_x64/**"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"out/**/*",
|
||||||
|
"!src/**",
|
||||||
|
"!scripts/**",
|
||||||
|
"!resources/**",
|
||||||
|
"!release/**",
|
||||||
|
"!release-pack/**",
|
||||||
|
"!electron.vite.config.ts",
|
||||||
|
"!tsconfig*.json",
|
||||||
|
"!tsconfig.web.tsbuildinfo",
|
||||||
|
"!.nvmrc"
|
||||||
|
],
|
||||||
|
"afterPack": "scripts/prune-pack.js",
|
||||||
|
"npmRebuild": false,
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
@@ -39,6 +55,10 @@
|
|||||||
{
|
{
|
||||||
"from": "resources/native",
|
"from": "resources/native",
|
||||||
"to": "native"
|
"to": "native"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "resources/cardsoon.config.json",
|
||||||
|
"to": "cardsoon.config.json"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"extraFiles": [
|
"extraFiles": [
|
||||||
@@ -49,19 +69,38 @@
|
|||||||
],
|
],
|
||||||
"win": {
|
"win": {
|
||||||
"target": [
|
"target": [
|
||||||
"nsis"
|
"zip"
|
||||||
],
|
],
|
||||||
"signAndEditExecutable": false
|
"signAndEditExecutable": false
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
"perMachine": false
|
||||||
|
},
|
||||||
|
"mac": {
|
||||||
|
"target": [
|
||||||
|
"dmg"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
"AppImage"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@intlify/unplugin-vue-i18n": "^1.6.0",
|
||||||
"@vitejs/plugin-vue": "^4.6.2",
|
"@vitejs/plugin-vue": "^4.6.2",
|
||||||
"electron": "20.3.12",
|
"electron": "20.3.12",
|
||||||
"electron-builder": "^24.13.3",
|
"electron-builder": "^24.13.3",
|
||||||
"electron-vite": "^1.0.29",
|
"electron-vite": "^1.0.29",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
"sass": "^1.69.5",
|
"sass": "^1.69.5",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^4.5.2",
|
"vite": "^4.5.2",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0",
|
||||||
"vue-tsc": "^1.8.27"
|
"vue-tsc": "^1.8.27"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"designAppPath": "D:\\SoonProject\\SoonDesign\\build\\win-unpacked\\SoonDesign.exe",
|
||||||
|
"sharedDir": "C:\\PrintTasks",
|
||||||
|
"logLevel": 3,
|
||||||
|
"cleanTaskFile": 0
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -9,13 +9,24 @@ const required = [
|
|||||||
'dcrf32.dll',
|
'dcrf32.dll',
|
||||||
'Entry.dll',
|
'Entry.dll',
|
||||||
'libpng16.dll',
|
'libpng16.dll',
|
||||||
'zint.dll'
|
'zint.dll',
|
||||||
|
'freetype.dll',
|
||||||
|
'opencv_world490d.dll'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const opencvRelease = path.join(nativeDir, 'opencv_world490.dll')
|
||||||
|
const opencvDebug = path.join(nativeDir, 'opencv_world490d.dll')
|
||||||
|
if (fs.existsSync(opencvRelease)) {
|
||||||
|
console.warn('提示: 检测到 opencv_world490.dll,生产环境建议用 Release 版替换 Debug 版 opencv_world490d.dll 以减小体积')
|
||||||
|
} else if (fs.existsSync(opencvDebug)) {
|
||||||
|
const mb = Math.round(fs.statSync(opencvDebug).size / 1024 / 1024)
|
||||||
|
console.warn(`提示: 当前使用 Debug 版 OpenCV (${mb} MB),可从 docs/API/lib 换 Release 版 opencv_world490.dll`)
|
||||||
|
}
|
||||||
|
|
||||||
const missing = required.filter((name) => !fs.existsSync(path.join(nativeDir, name)))
|
const missing = required.filter((name) => !fs.existsSync(path.join(nativeDir, name)))
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
console.error(`resources/native 缺少: ${missing.join(', ')}`)
|
console.error(`resources/native 缺少: ${missing.join(', ')}`)
|
||||||
console.error('请从 docs/API/lib 复制 7 个 dll(不含 .lib)')
|
console.error('请从 docs/API/lib 复制完整 native 依赖(不含 .lib)')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
console.log('resources/native: 7 dll 齐全')
|
console.log(`resources/native: ${required.length} dll 齐全`)
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** 打包前清理 release 目录中的旧 zip,避免误统计体积 */
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const releaseDir = path.join(__dirname, '..', 'release')
|
||||||
|
if (!fs.existsSync(releaseDir)) process.exit(0)
|
||||||
|
|
||||||
|
for (const name of fs.readdirSync(releaseDir)) {
|
||||||
|
if (name.endsWith('.zip')) {
|
||||||
|
fs.unlinkSync(path.join(releaseDir, name))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
isNetworkPath,
|
||||||
|
extractHostName,
|
||||||
|
extractDriveLetter,
|
||||||
|
buildNetworkUrl,
|
||||||
|
collectHostsForCopyPaths,
|
||||||
|
buildNetInfo
|
||||||
|
} from '../src/shared/network-host.ts'
|
||||||
|
|
||||||
|
const cases = []
|
||||||
|
function eq(name, actual, expected) {
|
||||||
|
const ok =
|
||||||
|
Array.isArray(expected) ? JSON.stringify(actual) === JSON.stringify(expected) : actual === expected
|
||||||
|
cases.push({ name, ok, actual, expected })
|
||||||
|
if (!ok) {
|
||||||
|
console.error(`FAIL ${name}`)
|
||||||
|
console.error(` expected: ${JSON.stringify(expected)}`)
|
||||||
|
console.error(` actual: ${JSON.stringify(actual)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eq('isNetworkPath \\host', isNetworkPath('\\\\192.168.1.100\\share'), true)
|
||||||
|
eq('isNetworkPath //host', isNetworkPath('//nas/share'), true)
|
||||||
|
eq('isNetworkPath D:\\a', isNetworkPath('D:\\data'), false)
|
||||||
|
eq('isNetworkPath empty', isNetworkPath(''), false)
|
||||||
|
|
||||||
|
const unc = '\\\\192.168.1.100\\share\\a.pdf'
|
||||||
|
eq('extractHostName \\host\\share', extractHostName(unc), '192.168.1.100')
|
||||||
|
eq('extractHostName //host/share', extractHostName('//nas/share'), 'nas')
|
||||||
|
eq('extractHostName local', extractHostName('D:\\data'), '')
|
||||||
|
eq('extractHostName empty', extractHostName(''), '')
|
||||||
|
|
||||||
|
eq('extractDriveLetter Z', extractDriveLetter('Z:\\folder'), 'Z')
|
||||||
|
eq('extractDriveLetter C', extractDriveLetter('C:\\data\\sub'), 'C')
|
||||||
|
eq('extractDriveLetter unc', extractDriveLetter(unc), '')
|
||||||
|
|
||||||
|
eq('buildNetworkUrl bare', buildNetworkUrl('192.168.1.100', ''), '\\\\192.168.1.100')
|
||||||
|
eq('buildNetworkUrl share', buildNetworkUrl('192.168.1.100', 'share'), '\\\\192.168.1.100\\share')
|
||||||
|
eq('buildNetworkUrl slashes stripped', buildNetworkUrl('host', '/share/'), '\\\\host\\share')
|
||||||
|
|
||||||
|
eq(
|
||||||
|
'collectHosts unc + drive',
|
||||||
|
collectHostsForCopyPaths(['\\\\192.168.1.100\\share\\a', 'Z:\\data'], { Z: '192.168.1.200' }),
|
||||||
|
['192.168.1.100', '192.168.1.200']
|
||||||
|
)
|
||||||
|
eq('collectHosts local only', collectHostsForCopyPaths(['E:\\data'], {}), [])
|
||||||
|
|
||||||
|
const stored = new Map([['192.168.1.100', { userName: 'admin', password: 'pass', lastUsed: '' }]])
|
||||||
|
const r1 = buildNetInfo([{ hostName: '192.168.1.100' }], (h) => stored.get(h) || null)
|
||||||
|
eq('buildNetInfo single', r1, [
|
||||||
|
{ host_name: '192.168.1.100', user_name: 'admin', password: 'pass' }
|
||||||
|
])
|
||||||
|
|
||||||
|
const r2 = buildNetInfo(
|
||||||
|
[
|
||||||
|
{ hostName: '192.168.1.100', userName: 'a', password: 'p' },
|
||||||
|
{ hostName: '192.168.1.100', userName: 'b', password: 'q' }
|
||||||
|
],
|
||||||
|
() => null
|
||||||
|
)
|
||||||
|
eq('buildNetInfo dedup', r2, [{ host_name: '192.168.1.100', user_name: 'a', password: 'p' }])
|
||||||
|
|
||||||
|
let threw = false
|
||||||
|
try {
|
||||||
|
buildNetInfo([{ hostName: 'h1' }], () => null)
|
||||||
|
} catch (e) {
|
||||||
|
threw = e.message.includes('缺少网络凭据')
|
||||||
|
}
|
||||||
|
eq('buildNetInfo missing throws', threw, true)
|
||||||
|
eq('buildNetInfo empty list', buildNetInfo([], () => null), [])
|
||||||
|
|
||||||
|
const jobBody = { net_info: r1 }
|
||||||
|
const jobParsed = JSON.parse(JSON.stringify(jobBody))
|
||||||
|
eq('net_info is array in job json', Array.isArray(jobParsed.net_info), true)
|
||||||
|
|
||||||
|
const pass = cases.filter((c) => c.ok).length
|
||||||
|
const fail = cases.length - pass
|
||||||
|
console.log(`PASS ${pass} / ${cases.length}`)
|
||||||
|
if (fail > 0) process.exit(1)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/** electron-builder afterPack:仅保留 Windows x64 的 koffi 原生模块 */
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const KOFFI_KEEP = new Set(['build', 'index.js', 'indirect.js', 'package.json', 'LICENSE.txt'])
|
||||||
|
const NATIVE_PLATFORM = 'win32_x64'
|
||||||
|
|
||||||
|
exports.default = async function afterPack(context) {
|
||||||
|
const koffiRoot = path.join(
|
||||||
|
context.appOutDir,
|
||||||
|
'resources',
|
||||||
|
'app.asar.unpacked',
|
||||||
|
'node_modules',
|
||||||
|
'koffi'
|
||||||
|
)
|
||||||
|
if (!fs.existsSync(koffiRoot)) return
|
||||||
|
|
||||||
|
for (const name of fs.readdirSync(koffiRoot)) {
|
||||||
|
if (!KOFFI_KEEP.has(name)) {
|
||||||
|
fs.rmSync(path.join(koffiRoot, name), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDir = path.join(koffiRoot, 'build', 'koffi')
|
||||||
|
if (!fs.existsSync(buildDir)) return
|
||||||
|
for (const plat of fs.readdirSync(buildDir)) {
|
||||||
|
if (plat !== NATIVE_PLATFORM) {
|
||||||
|
fs.rmSync(path.join(buildDir, plat), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-28
@@ -1,15 +1,34 @@
|
|||||||
import { app, BrowserWindow, dialog, globalShortcut, screen } from 'electron'
|
import { app, BrowserWindow, dialog, globalShortcut } 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')
|
||||||
|
|
||||||
import log from 'electron-log'
|
import log from 'electron-log'
|
||||||
import { suppressKnownDllStderr } from './utils/suppress-dll-stderr'
|
|
||||||
import { setupNativeWorkingDir } from './services/native-path'
|
|
||||||
import { configStore } from './services/config-store'
|
|
||||||
|
|
||||||
|
if (app.isPackaged) {
|
||||||
|
app.disableHardwareAcceleration()
|
||||||
|
}
|
||||||
|
|
||||||
|
const gotSingleInstanceLock = app.requestSingleInstanceLock()
|
||||||
|
if (!gotSingleInstanceLock) {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
import { ensureConsoleUtf8 } from './utils/ensure-console-utf8'
|
||||||
|
import { suppressKnownDllStderr } from './utils/suppress-dll-stderr'
|
||||||
|
import { loadAppFileConfig, applyFileConfigToStore } from './services/app-config'
|
||||||
|
import { migrateTraceConfig, setTraceWebContents } from './utils/trace-bridge'
|
||||||
|
|
||||||
|
ensureConsoleUtf8()
|
||||||
suppressKnownDllStderr()
|
suppressKnownDllStderr()
|
||||||
|
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
log.error('uncaughtException', err)
|
||||||
|
dialog.showErrorBox('程序异常', err instanceof Error ? err.message : String(err))
|
||||||
|
})
|
||||||
|
|
||||||
import { registerIpcHandlers, handleBeforeQuit } from './ipc/register-handlers'
|
import { registerIpcHandlers, handleBeforeQuit } from './ipc/register-handlers'
|
||||||
|
import { ensureDllInitialized } from './services/dll-bootstrap'
|
||||||
import { setPollMainWindow } from './services/poll-manager'
|
import { setPollMainWindow } from './services/poll-manager'
|
||||||
import { DESIGN_WIDTH, DESIGN_HEIGHT, contentHeightForWidth } from '@shared/viewport'
|
import { DESIGN_WIDTH, DESIGN_HEIGHT, contentHeightForWidth } from '@shared/viewport'
|
||||||
|
|
||||||
@@ -17,25 +36,19 @@ let mainWindow: BrowserWindow | null = null
|
|||||||
|
|
||||||
const MIN_CONTENT_WIDTH = 960
|
const MIN_CONTENT_WIDTH = 960
|
||||||
|
|
||||||
/** 默认内容区:约 85% 工作区宽,高 720:360 */
|
function focusMainWindow(): void {
|
||||||
function getDefaultWindowSize(): { width: number; height: number } {
|
if (!mainWindow) return
|
||||||
const { width: sw, height: sh } = screen.getPrimaryDisplay().workAreaSize
|
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||||
let w = Math.max(1280, Math.min(Math.floor(sw * 0.85), 1600))
|
mainWindow.show()
|
||||||
let h = contentHeightForWidth(w)
|
mainWindow.focus()
|
||||||
const maxH = Math.floor(sh * 0.85)
|
|
||||||
if (h > maxH) {
|
|
||||||
h = Math.max(contentHeightForWidth(MIN_CONTENT_WIDTH), maxH)
|
|
||||||
w = Math.round((h * DESIGN_WIDTH) / DESIGN_HEIGHT)
|
|
||||||
}
|
|
||||||
return { width: w, height: h }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWindow(): void {
|
function createWindow(): void {
|
||||||
const { width, height } = getDefaultWindowSize()
|
// 目标设备屏幕为 720x360,窗口默认 1:1 显示设计稿大小
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
useContentSize: true,
|
useContentSize: true,
|
||||||
width,
|
width: DESIGN_WIDTH,
|
||||||
height,
|
height: DESIGN_HEIGHT,
|
||||||
minWidth: MIN_CONTENT_WIDTH,
|
minWidth: MIN_CONTENT_WIDTH,
|
||||||
minHeight: contentHeightForWidth(MIN_CONTENT_WIDTH),
|
minHeight: contentHeightForWidth(MIN_CONTENT_WIDTH),
|
||||||
show: false,
|
show: false,
|
||||||
@@ -49,6 +62,7 @@ function createWindow(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
setPollMainWindow(mainWindow)
|
setPollMainWindow(mainWindow)
|
||||||
|
setTraceWebContents(mainWindow.webContents)
|
||||||
|
|
||||||
mainWindow.on('ready-to-show', () => {
|
mainWindow.on('ready-to-show', () => {
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
@@ -68,9 +82,10 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 内容区 720:360,与 useScale 一致
|
|
||||||
mainWindow.on('resize', () => {
|
mainWindow.on('resize', () => {
|
||||||
if (!mainWindow) return
|
if (!mainWindow) return
|
||||||
|
// 最大化时让窗口填满屏幕,不强制 2:1 内容比例
|
||||||
|
if (mainWindow.isMaximized()) return
|
||||||
const [cw, ch] = mainWindow.getContentSize()
|
const [cw, ch] = mainWindow.getContentSize()
|
||||||
const wantH = contentHeightForWidth(cw)
|
const wantH = contentHeightForWidth(cw)
|
||||||
if (Math.abs(ch - wantH) > 2) {
|
if (Math.abs(ch - wantH) > 2) {
|
||||||
@@ -79,9 +94,32 @@ function createWindow(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mainWindow.on('closed', () => {
|
mainWindow.on('closed', () => {
|
||||||
|
setTraceWebContents(null)
|
||||||
mainWindow = null
|
mainWindow = null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||||
|
log.error('render-process-gone', details)
|
||||||
|
dialog.showErrorBox(
|
||||||
|
'界面进程异常退出',
|
||||||
|
`reason=${details.reason} exitCode=${details.exitCode}\n请查看 %APPDATA%\\cardsoon-machine\\logs\\main.log`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 页面加载失败(dev server 未就绪 / 生产文件缺失)时记录并提示,避免静默白屏
|
||||||
|
mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
|
||||||
|
log.error('renderer did-fail-load', { errorCode, errorDescription, validatedURL })
|
||||||
|
if (errorCode !== -3) {
|
||||||
|
mainWindow?.webContents.executeJavaScript(
|
||||||
|
`document.getElementById('app').innerHTML = '<pre style="margin:16px;padding:16px;background:#fff5f5;border:1px solid #feb2b2;border-radius:8px;color:#c53030;font-size:13px;white-space:pre-wrap;">[main:did-fail-load] ${errorCode} ${errorDescription}\\n${validatedURL}</pre>'`
|
||||||
|
).catch(() => undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.webContents.on('preload-error', (_event, preloadPath, error) => {
|
||||||
|
log.error('preload-error', { preloadPath, error: String(error) })
|
||||||
|
})
|
||||||
|
|
||||||
if (process.env.ELECTRON_RENDERER_URL) {
|
if (process.env.ELECTRON_RENDERER_URL) {
|
||||||
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
|
||||||
} else {
|
} else {
|
||||||
@@ -89,17 +127,27 @@ function createWindow(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
if (gotSingleInstanceLock) {
|
||||||
try {
|
app.on('second-instance', () => {
|
||||||
if (app.isPackaged) {
|
focusMainWindow()
|
||||||
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()
|
app.whenReady().then(async () => {
|
||||||
|
if (!gotSingleInstanceLock) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
migrateTraceConfig()
|
||||||
|
loadAppFileConfig()
|
||||||
|
applyFileConfigToStore()
|
||||||
|
log.info('app startup', { packaged: app.isPackaged, execPath: process.execPath })
|
||||||
registerIpcHandlers()
|
registerIpcHandlers()
|
||||||
|
try {
|
||||||
|
const r = await ensureDllInitialized()
|
||||||
|
if (r.warning) log.warn(r.warning)
|
||||||
|
} catch (e) {
|
||||||
|
log.error('startup DLL init failed', e)
|
||||||
|
}
|
||||||
createWindow()
|
createWindow()
|
||||||
if (!app.isPackaged) {
|
if (!app.isPackaged) {
|
||||||
globalShortcut.register('CommandOrControl+Shift+I', () => {
|
globalShortcut.register('CommandOrControl+Shift+I', () => {
|
||||||
|
|||||||
@@ -1,33 +1,22 @@
|
|||||||
import { app, dialog, ipcMain, shell } from 'electron'
|
import { dialog, shell } from 'electron'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
|
||||||
import log from 'electron-log'
|
import log from 'electron-log'
|
||||||
import { CS_FAIL, CS_OK } from '../constants'
|
import { CS_FAIL, CS_OK } from '../constants'
|
||||||
import { assertNotBusy, assertReady, mainAppState } from '../services/app-state'
|
import { assertNotBusy, assertReady, mainAppState } from '../services/app-state'
|
||||||
import { configStore } from '../services/config-store'
|
import { configStore } from '../services/config-store'
|
||||||
import {
|
import { startUsbPoll, startJobPoll, stopAllPolls, stopJobPoll, stopUsbPoll, stopCardPositionPoll, startCardPositionPoll, getPollMainWindow, isJobPollActive } from '../services/poll-manager'
|
||||||
getActiveJobId,
|
import { parsePrinterInfoFromDll, statusTextFromCheckstatus, type PrinterStatusSnapshot } from '@shared/printer-info'
|
||||||
startJobPoll,
|
|
||||||
startUsbPoll,
|
|
||||||
stopAllPolls,
|
|
||||||
stopJobPoll,
|
|
||||||
stopUsbPoll,
|
|
||||||
getPollMainWindow
|
|
||||||
} from '../services/poll-manager'
|
|
||||||
import { cleanPathPattern, getDirectorySizeBytes } from '../utils/dir-size'
|
import { cleanPathPattern, getDirectorySizeBytes } from '../utils/dir-size'
|
||||||
|
import { getDesignAppPath } from '../services/app-config'
|
||||||
|
import { openDesignApp } from '../services/open-design-app'
|
||||||
|
import { writeJobCsv, type JobCsvRow } from '../utils/job-csv'
|
||||||
import { parseSoonTemplate } from '../utils/parse-soon'
|
import { parseSoonTemplate } from '../utils/parse-soon'
|
||||||
import {
|
import { stageJobPayloadJson } from '../utils/stage-job-payload'
|
||||||
dllAdminJobCancel,
|
import { resolveDriveHostMap, resolveHostsFromPaths } from '../utils/network-drive'
|
||||||
dllCopyFromUsb,
|
import { getSecrets, setSecrets, type SecretsPayload } from '../services/secrets-store'
|
||||||
dllGetPrinterErrorStr,
|
import { ensureDllInitialized, isDllInitAttempted } from '../services/dll-bootstrap'
|
||||||
dllGetPrinterInfo,
|
import { loadDllModule } from '../services/dll-loader'
|
||||||
dllInit,
|
import { tracedHandle } from './traced-handler'
|
||||||
dllPrinterReject,
|
|
||||||
dllPrinterReset,
|
|
||||||
dllRestJobEx,
|
|
||||||
isCancelApiAvailable,
|
|
||||||
isRejectApiAvailable
|
|
||||||
} from '../services/work-dll.service'
|
|
||||||
|
|
||||||
function ok<T>(data?: T) {
|
function ok<T>(data?: T) {
|
||||||
return { ok: true as const, code: CS_OK, data }
|
return { ok: true as const, code: CS_OK, data }
|
||||||
@@ -37,130 +26,288 @@ function fail(code: number, message: string) {
|
|||||||
return { ok: false as const, code, message }
|
return { ok: false as const, code, message }
|
||||||
}
|
}
|
||||||
|
|
||||||
let dllInitAttempted = false
|
function summarizeStagedPayload(json: string): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(json) as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
task_id: p.task_id,
|
||||||
|
has_copy_task: p.has_copy_task,
|
||||||
|
has_print_task: p.has_print_task,
|
||||||
|
path_file_count: Array.isArray(p.path_file) ? p.path_file.length : 0,
|
||||||
|
json_file: p.json_file,
|
||||||
|
udf_file: p.udf_file
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { parseError: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBool(v: unknown): boolean {
|
||||||
|
return v === true || v === 'true' || v === 1 || v === '1' || String(v).toLowerCase() === 'true'
|
||||||
|
}
|
||||||
|
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
ipcMain.handle('dll:init', (_e, params) => {
|
tracedHandle('dll:init', async (_e, params) => {
|
||||||
if (dllInitAttempted) {
|
|
||||||
return fail(CS_FAIL, '请勿重复初始化,请完全退出应用后重新启动再试')
|
|
||||||
}
|
|
||||||
stopAllPolls()
|
stopAllPolls()
|
||||||
try {
|
try {
|
||||||
const sharedDir = params?.sharedDir || (configStore.get('sharedDir') as string)
|
const r = await ensureDllInitialized(params)
|
||||||
fs.mkdirSync(sharedDir, { recursive: true })
|
return ok({ code: r.code, warning: r.warning })
|
||||||
log.info(`SAPI_Init starting, sharedDir=${sharedDir}`)
|
} catch (err) {
|
||||||
const code = dllInit({
|
return fail(CS_FAIL, String(err))
|
||||||
sharedDir,
|
|
||||||
keepCombinedImage: params?.keepCombinedImage,
|
|
||||||
stopOnFailure: params?.stopOnFailure,
|
|
||||||
cleanTaskFile: params?.cleanTaskFile,
|
|
||||||
autoRetryTimes: params?.autoRetryTimes,
|
|
||||||
rejectConfig: params?.rejectConfig,
|
|
||||||
logLevel: params?.logLevel,
|
|
||||||
outBack: params?.outBack
|
|
||||||
})
|
|
||||||
log.info(`SAPI_Init finished, code=${code}`)
|
|
||||||
dllInitAttempted = true
|
|
||||||
mainAppState.initialized = true
|
|
||||||
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`)
|
})
|
||||||
|
|
||||||
|
tracedHandle('dll:printer-info', async () => {
|
||||||
|
try {
|
||||||
|
assertReady()
|
||||||
|
const dll = await loadDllModule()
|
||||||
|
const r = dll.dllGetPrinterInfo()
|
||||||
|
if (!r.json) {
|
||||||
|
const cached = configStore.get('lastPrinterStatus')
|
||||||
|
if (cached) {
|
||||||
return ok({
|
return ok({
|
||||||
warning: '打印机未连接或驱动未就绪,界面可浏览,接好设备后可在设置中重试 Init'
|
...cached,
|
||||||
|
fromCache: true,
|
||||||
|
liveError: r.code <= 0 ? '未连接打印机' : `GetPrinterInfo code=${r.code}`
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
return fail(0, '未连接打印机')
|
||||||
|
}
|
||||||
|
const snapshot = parsePrinterInfoFromDll(r.json)
|
||||||
|
configStore.set('lastPrinterStatus', snapshot)
|
||||||
|
return ok({ ...r.json, snapshot })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
mainAppState.initialized = false
|
const cached = configStore.get('lastPrinterStatus')
|
||||||
log.error('dll:init', err)
|
if (cached) {
|
||||||
|
return ok({ ...cached, fromCache: true, liveError: String(err) })
|
||||||
|
}
|
||||||
return fail(CS_FAIL, String(err))
|
return fail(CS_FAIL, String(err))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('dll:printer-info', () => {
|
tracedHandle('dll:printer-status', async () => {
|
||||||
|
if (!mainAppState.initialized) {
|
||||||
|
const cached = configStore.get('lastPrinterStatus')
|
||||||
|
return ok({
|
||||||
|
statusText: cached?.statusText ?? '未初始化',
|
||||||
|
fromCache: true
|
||||||
|
})
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
assertReady()
|
assertReady()
|
||||||
const r = dllGetPrinterInfo()
|
const dll = await loadDllModule()
|
||||||
if (!r.json) return fail(0, '未连接打印机')
|
if (!dll.isPrinterCheckstatusApiAvailable()) {
|
||||||
return ok(r.json)
|
return fail(CS_FAIL, '当前 DLL 不支持打印机状态查询')
|
||||||
|
}
|
||||||
|
const code = dll.dllPrinterCheckstatus()
|
||||||
|
const parsed = statusTextFromCheckstatus(code)
|
||||||
|
const cached = configStore.get('lastPrinterStatus')
|
||||||
|
if (parsed.ok) {
|
||||||
|
const snapshot: PrinterStatusSnapshot = {
|
||||||
|
ribbonType: cached?.ribbonType ?? '—',
|
||||||
|
ribbonAmount: cached?.ribbonAmount ?? '—',
|
||||||
|
statusText: parsed.statusText,
|
||||||
|
serialNo: cached?.serialNo ?? '—',
|
||||||
|
printerName: cached?.printerName ?? '—',
|
||||||
|
isSingleSide: cached?.isSingleSide ?? false
|
||||||
|
}
|
||||||
|
configStore.set('lastPrinterStatus', snapshot)
|
||||||
|
return ok({ statusText: parsed.statusText, statusCode: code })
|
||||||
|
}
|
||||||
|
if (cached) {
|
||||||
|
return ok({
|
||||||
|
statusText: parsed.statusText,
|
||||||
|
fromCache: true,
|
||||||
|
liveError: parsed.liveError
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return fail(code, parsed.liveError || parsed.statusText)
|
||||||
|
} catch (err) {
|
||||||
|
const cached = configStore.get('lastPrinterStatus')
|
||||||
|
if (cached) {
|
||||||
|
return ok({ statusText: cached.statusText, fromCache: true, liveError: String(err) })
|
||||||
|
}
|
||||||
|
return fail(CS_FAIL, String(err))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
tracedHandle('dll:printer-reset', async () => {
|
||||||
|
try {
|
||||||
|
assertReady()
|
||||||
|
const dll = await loadDllModule()
|
||||||
|
const code = dll.dllPrinterReset()
|
||||||
|
// 物理动作指令:负数为错误,0 或正数(状态/警告码)均视为指令已被打印机接受
|
||||||
|
if (code < 0) {
|
||||||
|
log.warn('SAPI_PrinterResetprinter returned non-OK code', code)
|
||||||
|
return fail(code, '重置失败')
|
||||||
|
}
|
||||||
|
return ok({ code })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return fail(CS_FAIL, String(err))
|
return fail(CS_FAIL, String(err))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('dll:printer-reset', () => {
|
tracedHandle('dll:printer-reject', async () => {
|
||||||
try {
|
try {
|
||||||
assertReady()
|
assertReady()
|
||||||
const code = dllPrinterReset()
|
const dll = await loadDllModule()
|
||||||
return code === CS_OK ? ok() : fail(code, '重置失败')
|
if (!dll.isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE')
|
||||||
|
const code = dll.dllPrinterReject()
|
||||||
|
if (code < 0) {
|
||||||
|
log.warn('SAPI_PrinterMovetoreject returned non-OK code', code)
|
||||||
|
return fail(code, '废卡失败')
|
||||||
|
}
|
||||||
|
return ok({ code })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return fail(CS_FAIL, String(err))
|
return fail(CS_FAIL, String(err))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('dll:printer-reject', () => {
|
// 读卡:移动卡片到读取区(SAPI_PrinterMovetousbreader)
|
||||||
|
tracedHandle('dll:printer-read-card', async () => {
|
||||||
try {
|
try {
|
||||||
assertReady()
|
assertReady()
|
||||||
if (!isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE')
|
const dll = await loadDllModule()
|
||||||
const code = dllPrinterReject()
|
if (!dll.isUsbReaderApiAvailable()) return fail(CS_FAIL, 'USB_READER_API_UNAVAILABLE')
|
||||||
return code === CS_OK ? ok() : fail(code, '废卡失败')
|
const code = dll.dllPrinterMoveToUsbReader()
|
||||||
|
if (code < 0) {
|
||||||
|
log.warn('SAPI_PrinterMovetousbreader returned non-OK code', code)
|
||||||
|
return fail(code, '读卡失败')
|
||||||
|
}
|
||||||
|
return ok({ code })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return fail(CS_FAIL, String(err))
|
return fail(CS_FAIL, String(err))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('dll:printer-error-str', (_e, errorNo?: number) => {
|
// 退卡:移动卡片到出卡区(SAPI_PrinterMovetohopper)
|
||||||
|
tracedHandle('dll:printer-eject-card', async () => {
|
||||||
try {
|
try {
|
||||||
assertReady()
|
assertReady()
|
||||||
return ok({ text: dllGetPrinterErrorStr(errorNo ?? -1) })
|
const dll = await loadDllModule()
|
||||||
|
if (!dll.isHopperApiAvailable()) return fail(CS_FAIL, 'HOPPER_API_UNAVAILABLE')
|
||||||
|
const code = dll.dllPrinterMoveToHopper()
|
||||||
|
if (code < 0) {
|
||||||
|
log.warn('SAPI_PrinterMovetohopper returned non-OK code', code)
|
||||||
|
return fail(code, '退卡失败')
|
||||||
|
}
|
||||||
|
// 卡已物理退出但返回正数状态码时,不再误报失败
|
||||||
|
return ok({ code })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return fail(CS_FAIL, String(err))
|
return fail(CS_FAIL, String(err))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('dll:job-create', (_e, json: string) => {
|
tracedHandle('dll:printer-error-str', async (_e, errorNo?: number) => {
|
||||||
|
if (!mainAppState.initialized) return ok({ text: '' })
|
||||||
|
try {
|
||||||
|
const dll = await loadDllModule()
|
||||||
|
return ok({ text: dll.dllGetPrinterErrorStr(errorNo ?? -1) })
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('dll:printer-error-str', err)
|
||||||
|
return ok({ text: '' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
tracedHandle('dll:job-create', async (_e, json: string, opts?: { resubmit?: boolean }) => {
|
||||||
try {
|
try {
|
||||||
assertReady()
|
assertReady()
|
||||||
|
if (opts?.resubmit) {
|
||||||
|
if (mainAppState.mode !== 'distributing') {
|
||||||
|
return fail(CS_FAIL, '当前不在分发任务会话中')
|
||||||
|
}
|
||||||
|
stopCardPositionPoll()
|
||||||
|
} else {
|
||||||
assertNotBusy()
|
assertNotBusy()
|
||||||
const r = dllRestJobEx(json)
|
}
|
||||||
if (r.code !== CS_OK) return fail(r.code, 'RestJobEx 失败')
|
const dll = await loadDllModule()
|
||||||
|
const sharedDir = configStore.get('sharedDir') as string
|
||||||
|
let staged: { json: string; taskDir: string }
|
||||||
|
try {
|
||||||
|
staged = stageJobPayloadJson(json, sharedDir, dll)
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
return fail(CS_FAIL, msg)
|
||||||
|
}
|
||||||
|
log.info('RestJobEx staging', {
|
||||||
|
taskDir: staged.taskDir,
|
||||||
|
summary: summarizeStagedPayload(staged.json)
|
||||||
|
})
|
||||||
|
const r = dll.dllRestJobEx(staged.json)
|
||||||
|
if (r.code !== CS_OK) {
|
||||||
|
log.warn('RestJobEx rejected', { code: r.code, json: staged.json.slice(0, 800) })
|
||||||
|
const errText = dll.dllGetPrinterErrorStr(r.code)
|
||||||
|
let detail = errText ? `${errText} (code=${r.code})` : `RestJobEx 失败 (code=${r.code})`
|
||||||
|
if (r.code === -1 && !errText) {
|
||||||
|
detail +=
|
||||||
|
':请确认模板路径、变量 CSV(udf_file)及拷贝路径有效,且打印机/任务目录已就绪'
|
||||||
|
}
|
||||||
|
return fail(r.code, detail)
|
||||||
|
}
|
||||||
|
if (!r.jobId?.trim()) {
|
||||||
|
return fail(CS_FAIL, 'RestJobEx 未返回 jobId')
|
||||||
|
}
|
||||||
mainAppState.mode = 'distributing'
|
mainAppState.mode = 'distributing'
|
||||||
mainAppState.activeJobId = r.jobId
|
mainAppState.activeJobId = r.jobId
|
||||||
return ok({ jobId: r.jobId })
|
return ok({ jobId: r.jobId })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
|
const msg = String(err)
|
||||||
return fail(CS_FAIL, String(err))
|
if (msg.includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
|
||||||
|
if (msg.includes('NOT_INITIALIZED')) {
|
||||||
|
return fail(CS_FAIL, '系统未初始化,请重启应用')
|
||||||
|
}
|
||||||
|
return fail(CS_FAIL, msg)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('dll:job-cancel', (_e, jobId: string) => {
|
tracedHandle('dll:job-cancel', async (_e, jobId: string) => {
|
||||||
try {
|
const id = String(jobId || mainAppState.activeJobId || '').trim()
|
||||||
assertReady()
|
|
||||||
const id = jobId || mainAppState.activeJobId
|
|
||||||
stopJobPoll(true)
|
stopJobPoll(true)
|
||||||
let code = CS_OK
|
|
||||||
if (isCancelApiAvailable()) {
|
|
||||||
code = dllAdminJobCancel(id)
|
|
||||||
} else {
|
|
||||||
log.info('dll:job-cancel: SAPI_AdminJobCancel not in DLL, poll stopped only')
|
|
||||||
}
|
|
||||||
mainAppState.mode = 'ready'
|
mainAppState.mode = 'ready'
|
||||||
mainAppState.activeJobId = ''
|
mainAppState.activeJobId = ''
|
||||||
|
if (!id) return ok()
|
||||||
|
if (!mainAppState.initialized) return ok()
|
||||||
|
try {
|
||||||
|
const dll = await loadDllModule()
|
||||||
|
if (!dll.isCancelApiAvailable()) return ok()
|
||||||
|
const code = dll.dllAdminJobCancel(id)
|
||||||
return code === CS_OK ? ok() : fail(code, '取消失败')
|
return code === CS_OK ? ok() : fail(code, '取消失败')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return fail(CS_FAIL, String(err))
|
log.warn('dll:job-cancel', err)
|
||||||
|
return ok()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('dll:usb-copy', (_e, req: { destFolder: string; cardOutput: number }) => {
|
tracedHandle('dll:usb-copy', async (_e, req: { destFolder: string; cardOutput: number; resubmit?: boolean }) => {
|
||||||
try {
|
try {
|
||||||
assertReady()
|
assertReady()
|
||||||
|
if (req.resubmit) {
|
||||||
|
if (mainAppState.mode !== 'usbCopying') {
|
||||||
|
return fail(CS_FAIL, '当前不在数据收集会话中')
|
||||||
|
}
|
||||||
|
stopCardPositionPoll()
|
||||||
|
} else {
|
||||||
assertNotBusy()
|
assertNotBusy()
|
||||||
const code = dllCopyFromUsb(req.destFolder, req.cardOutput)
|
}
|
||||||
|
const destFolder = String(req.destFolder || '').trim()
|
||||||
|
if (!destFolder) return fail(CS_FAIL, '请先选择数据导入目录')
|
||||||
|
fs.mkdirSync(destFolder, { recursive: true })
|
||||||
|
|
||||||
|
const dll = await loadDllModule()
|
||||||
|
if (dll.isUsbReaderApiAvailable()) {
|
||||||
|
const moveCode = dll.dllPrinterMoveToUsbReader()
|
||||||
|
if (moveCode !== CS_OK) {
|
||||||
|
log.warn('MoveToUsbReader before copy', { moveCode })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = dll.dllCopyFromUsb(destFolder, req.cardOutput)
|
||||||
if (code !== CS_OK) {
|
if (code !== CS_OK) {
|
||||||
return fail(code, '可能已有任务在执行')
|
const errText = dll.dllGetPrinterErrorStr(code)
|
||||||
|
return fail(code, errText || '启动 USB 收集失败')
|
||||||
}
|
}
|
||||||
mainAppState.mode = 'usbCopying'
|
mainAppState.mode = 'usbCopying'
|
||||||
|
startUsbPoll()
|
||||||
return ok()
|
return ok()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
|
if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
|
||||||
@@ -168,28 +315,49 @@ export function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('poll:job-start', (_e, jobId: string) => {
|
tracedHandle('poll:job-start', (_e, jobId: string) => {
|
||||||
startJobPoll(jobId)
|
startJobPoll(jobId)
|
||||||
return ok()
|
return ok()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('poll:job-stop', () => {
|
tracedHandle('poll:job-stop', (_e, opts?: { resetMode?: boolean }) => {
|
||||||
stopJobPoll(true)
|
stopJobPoll(opts?.resetMode !== false)
|
||||||
return ok()
|
return ok()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('poll:usb-start', () => {
|
tracedHandle('poll:usb-start', () => {
|
||||||
startUsbPoll()
|
startUsbPoll()
|
||||||
return ok()
|
return ok()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('poll:usb-stop', () => {
|
tracedHandle('poll:usb-stop', (_e, opts?: { resetMode?: boolean }) => {
|
||||||
stopUsbPoll()
|
stopUsbPoll(opts?.resetMode !== false)
|
||||||
mainAppState.mode = 'ready'
|
|
||||||
return ok()
|
return ok()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('dialog:open-directory', async () => {
|
tracedHandle('poll:card-position-start', async (_e, sessionMode?: 'distributing' | 'usbCopying') => {
|
||||||
|
try {
|
||||||
|
assertReady()
|
||||||
|
if (sessionMode === 'distributing' || sessionMode === 'usbCopying') {
|
||||||
|
mainAppState.mode = sessionMode
|
||||||
|
}
|
||||||
|
const dll = await loadDllModule()
|
||||||
|
if (!dll.isCardPositionApiAvailable()) {
|
||||||
|
return fail(CS_FAIL, '当前 DLL 不支持卡位查询,无法自动续做')
|
||||||
|
}
|
||||||
|
startCardPositionPoll()
|
||||||
|
return ok()
|
||||||
|
} catch (err) {
|
||||||
|
return fail(CS_FAIL, String(err))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
tracedHandle('poll:card-position-stop', () => {
|
||||||
|
stopCardPositionPoll()
|
||||||
|
return ok()
|
||||||
|
})
|
||||||
|
|
||||||
|
tracedHandle('dialog:open-directory', async () => {
|
||||||
const win = getPollMainWindow()
|
const win = getPollMainWindow()
|
||||||
const r = await dialog.showOpenDialog(win ?? undefined, {
|
const r = await dialog.showOpenDialog(win ?? undefined, {
|
||||||
properties: ['openDirectory', 'multiSelections']
|
properties: ['openDirectory', 'multiSelections']
|
||||||
@@ -198,7 +366,7 @@ export function registerIpcHandlers(): void {
|
|||||||
return ok({ paths: r.filePaths })
|
return ok({ paths: r.filePaths })
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('dialog:open-file', async (_e, filters?: { name: string; extensions: string[] }[]) => {
|
tracedHandle('dialog:open-file', async (_e, filters?: { name: string; extensions: string[] }[]) => {
|
||||||
const win = getPollMainWindow()
|
const win = getPollMainWindow()
|
||||||
const r = await dialog.showOpenDialog(win ?? undefined, {
|
const r = await dialog.showOpenDialog(win ?? undefined, {
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
@@ -208,7 +376,7 @@ export function registerIpcHandlers(): void {
|
|||||||
return ok({ path: r.filePaths[0] })
|
return ok({ path: r.filePaths[0] })
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('fs:path-exists', (_e, paths: string[]) => {
|
tracedHandle('fs:path-exists', (_e, paths: string[]) => {
|
||||||
const missing = paths
|
const missing = paths
|
||||||
.map((raw) => ({ raw, dir: cleanPathPattern(raw) }))
|
.map((raw) => ({ raw, dir: cleanPathPattern(raw) }))
|
||||||
.filter(({ dir }) => !dir || !fs.existsSync(dir))
|
.filter(({ dir }) => !dir || !fs.existsSync(dir))
|
||||||
@@ -216,7 +384,7 @@ export function registerIpcHandlers(): void {
|
|||||||
return ok({ missing })
|
return ok({ missing })
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('fs:dir-size', (_e, paths: string[]) => {
|
tracedHandle('fs:dir-size', (_e, paths: string[]) => {
|
||||||
const items = paths.map((raw) => {
|
const items = paths.map((raw) => {
|
||||||
const dir = cleanPathPattern(raw)
|
const dir = cleanPathPattern(raw)
|
||||||
if (!fs.existsSync(dir)) return { path: raw, bytes: 0, missing: true as const }
|
if (!fs.existsSync(dir)) return { path: raw, bytes: 0, missing: true as const }
|
||||||
@@ -231,7 +399,43 @@ export function registerIpcHandlers(): void {
|
|||||||
return ok({ items })
|
return ok({ items })
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('fs:parse-soon', (_e, filePath: string) => {
|
tracedHandle('fs:resolve-network-hosts', (_e, paths: string[]) => {
|
||||||
|
const list = Array.isArray(paths) ? paths.map((p) => String(p || '')) : []
|
||||||
|
const driveHostMap = resolveDriveHostMap(list)
|
||||||
|
const hosts = resolveHostsFromPaths(list)
|
||||||
|
return ok({ hosts, driveHostMap })
|
||||||
|
})
|
||||||
|
|
||||||
|
tracedHandle('secrets:get', () => {
|
||||||
|
return ok(getSecrets())
|
||||||
|
})
|
||||||
|
|
||||||
|
tracedHandle('secrets:set', (_e, patch: Partial<SecretsPayload>) => {
|
||||||
|
if (!patch || typeof patch !== 'object') return ok(getSecrets())
|
||||||
|
const toMerge: Partial<SecretsPayload> = {}
|
||||||
|
if (patch.networkCredentials !== undefined) {
|
||||||
|
toMerge.networkCredentials = patch.networkCredentials
|
||||||
|
}
|
||||||
|
if (patch.dongleAuthCode !== undefined) {
|
||||||
|
toMerge.dongleAuthCode = patch.dongleAuthCode
|
||||||
|
}
|
||||||
|
return ok(setSecrets(toMerge))
|
||||||
|
})
|
||||||
|
|
||||||
|
tracedHandle(
|
||||||
|
'fs:write-job-csv',
|
||||||
|
(_e, payload: { taskId: string; rows: JobCsvRow[] }) => {
|
||||||
|
try {
|
||||||
|
const sharedDir = configStore.get('sharedDir') as string
|
||||||
|
const csvPath = writeJobCsv(sharedDir, payload.taskId, payload.rows || [])
|
||||||
|
return ok({ path: csvPath })
|
||||||
|
} catch (err) {
|
||||||
|
return fail(CS_FAIL, err instanceof Error ? err.message : String(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
tracedHandle('fs:parse-soon', (_e, filePath: string) => {
|
||||||
try {
|
try {
|
||||||
const soonPath = String(filePath || '').trim()
|
const soonPath = String(filePath || '').trim()
|
||||||
if (!soonPath) return fail(CS_FAIL, '模板路径为空')
|
if (!soonPath) return fail(CS_FAIL, '模板路径为空')
|
||||||
@@ -244,28 +448,27 @@ export function registerIpcHandlers(): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('config:get', () => {
|
tracedHandle('config:get', () => {
|
||||||
const payload: {
|
const payload: {
|
||||||
sharedDir: string
|
sharedDir: string
|
||||||
templateDir: string
|
templateDir: string
|
||||||
skipDllInit?: boolean
|
traceEnabled: boolean
|
||||||
|
lastPrinterStatus?: PrinterStatusSnapshot
|
||||||
|
dllInitialized: boolean
|
||||||
} = {
|
} = {
|
||||||
sharedDir: configStore.get('sharedDir'),
|
sharedDir: configStore.get('sharedDir'),
|
||||||
templateDir: configStore.get('templateDir')
|
templateDir: configStore.get('templateDir'),
|
||||||
}
|
traceEnabled: configStore.get('traceEnabled', true),
|
||||||
if (!app.isPackaged) {
|
lastPrinterStatus: configStore.get('lastPrinterStatus'),
|
||||||
payload.skipDllInit = configStore.get('skipDllInit', false)
|
dllInitialized: mainAppState.initialized
|
||||||
}
|
}
|
||||||
return ok(payload)
|
return ok(payload)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('config:set', (_e, patch: Record<string, unknown>) => {
|
tracedHandle('config:set', (_e, patch: Record<string, unknown>) => {
|
||||||
Object.entries(patch).forEach(([k, v]) => {
|
Object.entries(patch).forEach(([k, v]) => {
|
||||||
if (k === 'skipDllInit') {
|
if (k === 'traceEnabled' || k === 'dllTraceEnabled') {
|
||||||
if (app.isPackaged) return
|
configStore.set('traceEnabled', parseBool(v))
|
||||||
const b =
|
|
||||||
v === true || v === 'true' || v === 1 || v === '1' || String(v).toLowerCase() === 'true'
|
|
||||||
configStore.set(k, b)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
configStore.set(k, v as string)
|
configStore.set(k, v as string)
|
||||||
@@ -273,26 +476,37 @@ export function registerIpcHandlers(): void {
|
|||||||
return ok(configStore.store)
|
return ok(configStore.store)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('shell:open-path', (_e, target: string) => {
|
tracedHandle('shell:open-path', (_e, target: string) => {
|
||||||
const dir = target || (configStore.get('templateDir') as string)
|
const dir = target || (configStore.get('templateDir') as string)
|
||||||
fs.mkdirSync(dir, { recursive: true })
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
shell.openPath(dir)
|
shell.openPath(dir)
|
||||||
return ok()
|
return ok()
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('dll:reject-available', () => ok({ available: isRejectApiAvailable() }))
|
tracedHandle('design:open', async () => {
|
||||||
|
const r = await openDesignApp(getDesignAppPath())
|
||||||
|
if (!r.ok) return fail(CS_FAIL, r.message)
|
||||||
|
return ok()
|
||||||
|
})
|
||||||
|
|
||||||
|
tracedHandle('dll:reject-available', async () => {
|
||||||
|
const dll = await loadDllModule()
|
||||||
|
return ok({ available: dll.isRejectApiAvailable() })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleBeforeQuit(): Promise<void> {
|
export async function handleBeforeQuit(): Promise<void> {
|
||||||
|
const dll = isDllInitAttempted() ? await loadDllModule().catch(() => null) : null
|
||||||
const shouldCancel =
|
const shouldCancel =
|
||||||
mainAppState.mode === 'distributing' &&
|
mainAppState.mode === 'distributing' &&
|
||||||
!!mainAppState.activeJobId &&
|
!!mainAppState.activeJobId &&
|
||||||
isCancelApiAvailable()
|
isJobPollActive() &&
|
||||||
|
!!dll?.isCancelApiAvailable()
|
||||||
const cancelJobId = mainAppState.activeJobId
|
const cancelJobId = mainAppState.activeJobId
|
||||||
stopAllPolls()
|
stopAllPolls()
|
||||||
if (shouldCancel && cancelJobId) {
|
if (shouldCancel && cancelJobId && dll) {
|
||||||
try {
|
try {
|
||||||
dllAdminJobCancel(cancelJobId)
|
dll.dllAdminJobCancel(cancelJobId)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warn('before-quit cancel', e)
|
log.warn('before-quit cancel', e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { ipcMain, type IpcMainInvokeEvent } from 'electron'
|
||||||
|
import { emitTrace } from '../utils/trace-bridge'
|
||||||
|
|
||||||
|
type IpcHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown | Promise<unknown>
|
||||||
|
|
||||||
|
function summarizeArgs(args: unknown[]): Record<string, unknown> {
|
||||||
|
if (args.length === 0) return {}
|
||||||
|
const a = args[0]
|
||||||
|
if (typeof a === 'string') {
|
||||||
|
if (a.length > 240) return { text: `${a.slice(0, 240)}…`, bytes: a.length }
|
||||||
|
return { arg0: a }
|
||||||
|
}
|
||||||
|
if (typeof a === 'object' && a !== null) return { ...(a as Record<string, unknown>) }
|
||||||
|
return { arg0: a }
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeResult(r: unknown): Record<string, unknown> {
|
||||||
|
if (!r || typeof r !== 'object') return { value: r }
|
||||||
|
const o = r as { ok?: boolean; code?: number; message?: string; data?: unknown }
|
||||||
|
const out: Record<string, unknown> = {}
|
||||||
|
if (o.ok !== undefined) out.ok = o.ok
|
||||||
|
if (o.code !== undefined) out.code = o.code
|
||||||
|
if (o.message) out.message = o.message
|
||||||
|
if (o.data !== undefined && o.data !== null && typeof o.data === 'object') {
|
||||||
|
const d = o.data as Record<string, unknown>
|
||||||
|
for (const k of ['warning', 'skipped', 'jobId', 'path', 'paths', 'missing', 'available']) {
|
||||||
|
if (k in d) out[k] = d[k]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tracedHandle(channel: string, handler: IpcHandler): void {
|
||||||
|
ipcMain.handle(channel, async (event, ...args) => {
|
||||||
|
const start = Date.now()
|
||||||
|
emitTrace(`[ipc] ${channel} →`, summarizeArgs(args))
|
||||||
|
try {
|
||||||
|
const result = await handler(event, ...args)
|
||||||
|
emitTrace(`[ipc] ${channel} ←`, { ms: Date.now() - start, ...summarizeResult(result) })
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
emitTrace(`[ipc] ${channel} ✗`, { ms: Date.now() - start, error: String(e) }, 'error')
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import log from 'electron-log'
|
||||||
|
import { LOG_FATAL_FLAG } from '../constants'
|
||||||
|
import { configStore } from './config-store'
|
||||||
|
import { getProcessExecDir } from './native-path'
|
||||||
|
|
||||||
|
export const APP_CONFIG_FILENAME = 'cardsoon.config.json'
|
||||||
|
|
||||||
|
const DEFAULT_SHARED_DIR = path.join('C:', 'PrintTasks')
|
||||||
|
|
||||||
|
export interface AppFileConfig {
|
||||||
|
designAppPath: string
|
||||||
|
sharedDir: string
|
||||||
|
logLevel: number
|
||||||
|
cleanTaskFile: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults: AppFileConfig = {
|
||||||
|
designAppPath: '',
|
||||||
|
sharedDir: DEFAULT_SHARED_DIR,
|
||||||
|
logLevel: LOG_FATAL_FLAG,
|
||||||
|
cleanTaskFile: false
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached: AppFileConfig | null = null
|
||||||
|
|
||||||
|
function pickRaw(raw: Record<string, unknown>, ...keys: string[]): unknown {
|
||||||
|
for (const key of keys) {
|
||||||
|
const hit = Object.entries(raw).find(([name]) => name.toLowerCase() === key.toLowerCase())
|
||||||
|
if (hit && hit[1] != null && String(hit[1]).trim() !== '') return hit[1]
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCleanTaskFile(raw: unknown): boolean {
|
||||||
|
if (raw === undefined || raw === null) return defaults.cleanTaskFile
|
||||||
|
if (typeof raw === 'boolean') return raw
|
||||||
|
if (typeof raw === 'number') return raw !== 0
|
||||||
|
const s = String(raw).trim().toLowerCase()
|
||||||
|
if (s === '0' || s === 'false') return false
|
||||||
|
if (s === '1' || s === 'true') return true
|
||||||
|
return defaults.cleanTaskFile
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLogLevel(raw: unknown): number {
|
||||||
|
if (raw === undefined || raw === null) return defaults.logLevel
|
||||||
|
const n = Number(raw)
|
||||||
|
return Number.isFinite(n) ? Math.round(n) : defaults.logLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSharedDir(raw: unknown): string {
|
||||||
|
const s = String(raw ?? '').trim()
|
||||||
|
return s || DEFAULT_SHARED_DIR
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundledConfigPath(): string {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
return path.join(process.resourcesPath, APP_CONFIG_FILENAME)
|
||||||
|
}
|
||||||
|
return path.join(app.getAppPath(), 'resources', APP_CONFIG_FILENAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
function configSearchPaths(): string[] {
|
||||||
|
const besideExe = path.join(getProcessExecDir(), APP_CONFIG_FILENAME)
|
||||||
|
const bundled = bundledConfigPath()
|
||||||
|
if (besideExe === bundled) return [besideExe]
|
||||||
|
return [besideExe, bundled]
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConfigFile(filePath: string): AppFileConfig {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
designAppPath: String(pickRaw(raw, 'designAppPath') ?? '').trim(),
|
||||||
|
sharedDir: parseSharedDir(pickRaw(raw, 'sharedDir', 'shareddir')),
|
||||||
|
logLevel: parseLogLevel(pickRaw(raw, 'logLevel', 'loglevel')),
|
||||||
|
cleanTaskFile: parseCleanTaskFile(pickRaw(raw, 'cleanTaskFile', 'cleantaskfile'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAppFileConfig(): AppFileConfig {
|
||||||
|
if (cached) return cached
|
||||||
|
|
||||||
|
for (const filePath of configSearchPaths()) {
|
||||||
|
if (!fs.existsSync(filePath)) continue
|
||||||
|
try {
|
||||||
|
cached = parseConfigFile(filePath)
|
||||||
|
log.info(`Loaded ${APP_CONFIG_FILENAME} from ${filePath}`, {
|
||||||
|
sharedDir: cached.sharedDir,
|
||||||
|
logLevel: cached.logLevel,
|
||||||
|
cleanTaskFile: cached.cleanTaskFile
|
||||||
|
})
|
||||||
|
return cached
|
||||||
|
} catch (e) {
|
||||||
|
log.warn(`Skip invalid ${APP_CONFIG_FILENAME}: ${filePath}`, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cached = { ...defaults }
|
||||||
|
log.warn(
|
||||||
|
`${APP_CONFIG_FILENAME} not found (checked: ${configSearchPaths().join(', ')}), using defaults`
|
||||||
|
)
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFileConfigToStore(): void {
|
||||||
|
const cfg = loadAppFileConfig()
|
||||||
|
configStore.set('sharedDir', cfg.sharedDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDesignAppPath(): string {
|
||||||
|
return loadAppFileConfig().designAppPath
|
||||||
|
}
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
import Store from 'electron-store'
|
import Store from 'electron-store'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import type { PrinterStatusSnapshot } from '@shared/printer-info'
|
||||||
|
|
||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
sharedDir: string
|
sharedDir: string
|
||||||
templateDir: string
|
templateDir: string
|
||||||
/** G2 门禁 false:启动即 SAPI_Init;仅调试可改 true */
|
traceEnabled: boolean
|
||||||
skipDllInit: boolean
|
lastPrinterStatus?: PrinterStatusSnapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultShared = path.join('C:', 'PrintTasks')
|
|
||||||
|
|
||||||
export const configStore = new Store<AppConfig>({
|
export const configStore = new Store<AppConfig>({
|
||||||
name: 'cardsoon-config',
|
name: 'cardsoon-config',
|
||||||
defaults: {
|
defaults: {
|
||||||
sharedDir: defaultShared,
|
sharedDir: path.join('C:', 'PrintTasks'),
|
||||||
templateDir: path.join(app.getPath('userData'), 'Cardsoon', 'templates'),
|
templateDir: path.join(app.getPath('userData'), 'Cardsoon', 'templates'),
|
||||||
// 正式版始终 Init;仅开发时可通过 --skip-dll-init 临时跳过
|
traceEnabled: true
|
||||||
skipDllInit: false
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import log from 'electron-log'
|
||||||
|
import { CS_OK } from '../constants'
|
||||||
|
import { mainAppState } from './app-state'
|
||||||
|
import { configStore } from './config-store'
|
||||||
|
import { loadAppFileConfig } from './app-config'
|
||||||
|
import { loadDllModule } from './dll-loader'
|
||||||
|
import type { InitParams } from './work-dll.service'
|
||||||
|
|
||||||
|
let dllInitAttempted = false
|
||||||
|
|
||||||
|
export function isDllInitAttempted(): boolean {
|
||||||
|
return dllInitAttempted
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureDllInitialized(
|
||||||
|
params?: Partial<InitParams>
|
||||||
|
): Promise<{ code: number; warning?: string }> {
|
||||||
|
if (dllInitAttempted) {
|
||||||
|
return { code: CS_OK }
|
||||||
|
}
|
||||||
|
const fileCfg = loadAppFileConfig()
|
||||||
|
const sharedDir =
|
||||||
|
params?.sharedDir?.trim() || fileCfg.sharedDir || (configStore.get('sharedDir') as string)
|
||||||
|
try {
|
||||||
|
const dll = await loadDllModule()
|
||||||
|
fs.mkdirSync(sharedDir, { recursive: true })
|
||||||
|
const code = dll.dllInit({
|
||||||
|
sharedDir,
|
||||||
|
keepCombinedImage: params?.keepCombinedImage,
|
||||||
|
stopOnFailure: params?.stopOnFailure,
|
||||||
|
cleanTaskFile: params?.cleanTaskFile ?? fileCfg.cleanTaskFile,
|
||||||
|
autoRetryTimes: params?.autoRetryTimes,
|
||||||
|
rejectConfig: params?.rejectConfig,
|
||||||
|
logLevel: params?.logLevel ?? fileCfg.logLevel,
|
||||||
|
outBack: params?.outBack
|
||||||
|
})
|
||||||
|
dllInitAttempted = true
|
||||||
|
mainAppState.initialized = true
|
||||||
|
configStore.set('sharedDir', sharedDir)
|
||||||
|
log.info('DLL initialized', { sharedDir, code })
|
||||||
|
if (code === CS_OK) return { code }
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
warning: '打印机未连接或驱动未就绪,可继续配置任务,接好设备后重启应用'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
mainAppState.initialized = false
|
||||||
|
dllInitAttempted = false
|
||||||
|
log.error('DLL init failed', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { setupNativeWorkingDir } from './native-path'
|
||||||
|
|
||||||
|
type DllModule = typeof import('./work-dll.service')
|
||||||
|
|
||||||
|
let dllMod: DllModule | null = null
|
||||||
|
let nativeReady = false
|
||||||
|
|
||||||
|
function ensureNativeEnv(): void {
|
||||||
|
if (nativeReady) return
|
||||||
|
setupNativeWorkingDir()
|
||||||
|
nativeReady = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDllModule(): Promise<DllModule> {
|
||||||
|
ensureNativeEnv()
|
||||||
|
if (!dllMod) {
|
||||||
|
dllMod = await import('./work-dll.service')
|
||||||
|
}
|
||||||
|
return dllMod
|
||||||
|
}
|
||||||
@@ -88,5 +88,10 @@ export function setupNativeWorkingDir(): void {
|
|||||||
if (!process.env.PATH?.toLowerCase().includes(nativeDir.toLowerCase())) {
|
if (!process.env.PATH?.toLowerCase().includes(nativeDir.toLowerCase())) {
|
||||||
process.env.PATH = `${pathHead}${path.delimiter}${process.env.PATH || ''}`
|
process.env.PATH = `${pathHead}${path.delimiter}${process.env.PATH || ''}`
|
||||||
}
|
}
|
||||||
log.debug(`Native DLL search path: ${nativeDir}; cwd kept at ${process.cwd()}`)
|
try {
|
||||||
|
process.chdir(execDir)
|
||||||
|
} catch (e) {
|
||||||
|
log.warn(`chdir to ${execDir} failed`, e)
|
||||||
|
}
|
||||||
|
log.debug(`Native DLL search path: ${nativeDir}; cwd=${process.cwd()}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { shell } from 'electron'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
function validateDesignAppPath(exePath: string): { ok: true } | { ok: false; message: string } {
|
||||||
|
const p = exePath.trim()
|
||||||
|
if (!p) {
|
||||||
|
return { ok: false, message: '请在 cardsoon.config.json 中配置 designAppPath' }
|
||||||
|
}
|
||||||
|
const resolved = path.resolve(p)
|
||||||
|
if (!fs.existsSync(resolved)) {
|
||||||
|
return { ok: false, message: `设计软件不存在: ${resolved}` }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openDesignApp(
|
||||||
|
exePath: string
|
||||||
|
): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||||
|
const check = validateDesignAppPath(exePath)
|
||||||
|
if (!check.ok) return check
|
||||||
|
|
||||||
|
const target = path.resolve(exePath.trim())
|
||||||
|
const err = await shell.openPath(target)
|
||||||
|
if (err) {
|
||||||
|
// 用户在 UAC/系统弹窗中点击"否"或被系统拒绝时,shell.openPath 返回通用错误字符串
|
||||||
|
// 不把原始 "Failed to Open Path" 直接抛给用户,给出可读说明
|
||||||
|
return { ok: false, message: '打开设计软件失败,可能已被取消或需要管理员权限' }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
@@ -1,11 +1,21 @@
|
|||||||
import { BrowserWindow } from 'electron'
|
import { BrowserWindow } from 'electron'
|
||||||
import log from 'electron-log'
|
import log from 'electron-log'
|
||||||
import { POLL_INTERVAL_MS } from '../constants'
|
import { normalizeCardPositionQuery } from '@shared/card-position'
|
||||||
|
import { POLL_INTERVAL_MS, CS_OK } from '../constants'
|
||||||
|
import {
|
||||||
|
USB_TASK_COMPLETED,
|
||||||
|
USB_TASK_FAILED,
|
||||||
|
clampUsbCopyProgress,
|
||||||
|
usbTaskStatusHint
|
||||||
|
} from '@shared/usb-copy-state'
|
||||||
import { mainAppState } from './app-state'
|
import { mainAppState } from './app-state'
|
||||||
import { dllGetJobStateById, dllGetUsbCopyState } from './work-dll.service'
|
import { emitTrace } from '../utils/trace-bridge'
|
||||||
|
import { loadDllModule } from './dll-loader'
|
||||||
|
|
||||||
let jobTimer: ReturnType<typeof setInterval> | null = null
|
let jobTimer: ReturnType<typeof setInterval> | null = null
|
||||||
let usbTimer: ReturnType<typeof setInterval> | null = null
|
let usbTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let cardTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let usbPollGen = 0
|
||||||
let jobId = ''
|
let jobId = ''
|
||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
|
||||||
@@ -34,29 +44,52 @@ export function stopJobPoll(resetMode = false): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopUsbPoll(): void {
|
export function stopUsbPoll(resetMode = false): void {
|
||||||
|
usbPollGen += 1
|
||||||
if (usbTimer) {
|
if (usbTimer) {
|
||||||
clearInterval(usbTimer)
|
clearInterval(usbTimer)
|
||||||
usbTimer = null
|
usbTimer = null
|
||||||
}
|
}
|
||||||
|
if (resetMode && mainAppState.mode === 'usbCopying') {
|
||||||
|
mainAppState.mode = 'ready'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopCardPositionPoll(): void {
|
||||||
|
if (cardTimer) {
|
||||||
|
clearInterval(cardTimer)
|
||||||
|
cardTimer = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopAllPolls(): void {
|
export function stopAllPolls(): void {
|
||||||
stopJobPoll(true)
|
stopJobPoll(true)
|
||||||
stopUsbPoll()
|
stopUsbPoll(true)
|
||||||
|
stopCardPositionPoll()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startJobPoll(id: string): void {
|
export function startJobPoll(id: string): void {
|
||||||
stopJobPoll(false)
|
stopJobPoll(false)
|
||||||
jobId = id
|
jobId = id
|
||||||
jobTimer = setInterval(() => {
|
jobTimer = setInterval(() => {
|
||||||
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const r = dllGetJobStateById(jobId)
|
const dll = await loadDllModule()
|
||||||
|
const r = dll.dllGetJobStateById(jobId)
|
||||||
const failed = r.jobState === 4
|
const failed = r.jobState === 4
|
||||||
const cancelled = r.jobState === 6
|
const cancelled = r.jobState === 6
|
||||||
const finished = r.jobState === 100
|
const finished = r.jobState === 100
|
||||||
const terminal = failed || cancelled
|
const terminal = failed || cancelled
|
||||||
send('job:poll-tick', {
|
// 失败当刻立即取打印机错误串(与 USB 收集轮询对齐),避免渲染层事后取到通用/过期文案
|
||||||
|
let errorMessage = ''
|
||||||
|
if (failed) {
|
||||||
|
try {
|
||||||
|
errorMessage = dll.dllGetPrinterErrorStr(-1) || ''
|
||||||
|
} catch (e) {
|
||||||
|
log.warn('GetPrinterErrorStr on job failed failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tick = {
|
||||||
jobId,
|
jobId,
|
||||||
queryErrorCode: r.queryErrorCode,
|
queryErrorCode: r.queryErrorCode,
|
||||||
jobState: r.jobState,
|
jobState: r.jobState,
|
||||||
@@ -64,50 +97,103 @@ export function startJobPoll(id: string): void {
|
|||||||
terminal,
|
terminal,
|
||||||
failed,
|
failed,
|
||||||
cancelled,
|
cancelled,
|
||||||
finished
|
finished,
|
||||||
})
|
errorMessage
|
||||||
|
}
|
||||||
|
emitTrace('[poll] job:poll-tick', tick)
|
||||||
|
send('job:poll-tick', tick)
|
||||||
if (r.queryErrorCode !== 0) {
|
if (r.queryErrorCode !== 0) {
|
||||||
log.warn('GetJobStateById query failed', r.queryErrorCode)
|
log.warn('GetJobStateById query failed', r.queryErrorCode)
|
||||||
stopJobPoll(true)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (failed || cancelled) {
|
if (failed || cancelled || finished) {
|
||||||
stopJobPoll(true)
|
stopJobPoll(false)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('job poll error', e)
|
log.error('job poll error', e)
|
||||||
stopJobPoll(true)
|
stopJobPoll(false)
|
||||||
}
|
}
|
||||||
|
})()
|
||||||
}, POLL_INTERVAL_MS)
|
}, POLL_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startUsbPoll(): void {
|
export function startUsbPoll(): void {
|
||||||
stopUsbPoll()
|
stopUsbPoll(false)
|
||||||
|
const gen = usbPollGen
|
||||||
|
void pollUsbOnce(gen).catch((e) => log.error('usb poll error', e))
|
||||||
usbTimer = setInterval(() => {
|
usbTimer = setInterval(() => {
|
||||||
try {
|
void pollUsbOnce(gen).catch((e) => {
|
||||||
const r = dllGetUsbCopyState()
|
log.error('usb poll error', e)
|
||||||
const failed = r.taskStatus === 3
|
stopUsbPoll(false)
|
||||||
const success = r.taskStatus === 2
|
})
|
||||||
|
}, POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollUsbOnce(gen: number): Promise<void> {
|
||||||
|
if (gen !== usbPollGen) return
|
||||||
|
const dll = await loadDllModule()
|
||||||
|
if (gen !== usbPollGen) return
|
||||||
|
const r = dll.dllGetUsbCopyState()
|
||||||
|
if (gen !== usbPollGen) return
|
||||||
|
const copyProgress = clampUsbCopyProgress(r.progress)
|
||||||
|
const failed = r.taskStatus === USB_TASK_FAILED
|
||||||
|
const success = r.taskStatus === USB_TASK_COMPLETED
|
||||||
const terminal = failed || success
|
const terminal = failed || success
|
||||||
send('usb:poll-tick', {
|
let errorMessage = ''
|
||||||
|
if (failed) {
|
||||||
|
const errText = dll.dllGetPrinterErrorStr(-1)
|
||||||
|
errorMessage = errText || usbTaskStatusHint(USB_TASK_FAILED)
|
||||||
|
}
|
||||||
|
const tick = {
|
||||||
|
queryCode: r.queryCode,
|
||||||
taskStatus: r.taskStatus,
|
taskStatus: r.taskStatus,
|
||||||
progress: r.progress,
|
progress: copyProgress,
|
||||||
terminal,
|
terminal,
|
||||||
failed,
|
failed,
|
||||||
success
|
success,
|
||||||
})
|
errorMessage
|
||||||
|
}
|
||||||
|
emitTrace('[poll] usb:poll-tick', tick)
|
||||||
|
if (gen !== usbPollGen) return
|
||||||
|
send('usb:poll-tick', tick)
|
||||||
|
if (r.queryCode !== CS_OK) {
|
||||||
|
log.warn('GetUsbCopyState query failed', r.queryCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (terminal) {
|
if (terminal) {
|
||||||
stopUsbPoll()
|
stopUsbPoll(false)
|
||||||
mainAppState.mode = 'ready'
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollCardPositionOnce(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const dll = await loadDllModule()
|
||||||
|
if (!dll.isCardPositionApiAvailable()) return
|
||||||
|
const r = dll.dllGetPrinterCardPosition()
|
||||||
|
const tick = normalizeCardPositionQuery(r.queryCode, r.position)
|
||||||
|
emitTrace('[poll] card:position-tick', tick)
|
||||||
|
send('card:position-tick', tick)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('usb poll error', e)
|
log.error('card position poll error', e)
|
||||||
stopUsbPoll()
|
|
||||||
mainAppState.mode = 'ready'
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startCardPositionPoll(): void {
|
||||||
|
stopCardPositionPoll()
|
||||||
|
void pollCardPositionOnce()
|
||||||
|
cardTimer = setInterval(() => {
|
||||||
|
void pollCardPositionOnce()
|
||||||
}, POLL_INTERVAL_MS)
|
}, POLL_INTERVAL_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getActiveJobId(): string {
|
export function getActiveJobId(): string {
|
||||||
return jobId
|
return jobId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isJobPollActive(): boolean {
|
||||||
|
return jobTimer !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUsbPollActive(): boolean {
|
||||||
|
return usbTimer !== null
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { app, safeStorage } from 'electron'
|
||||||
|
import log from 'electron-log'
|
||||||
|
|
||||||
|
export interface StoredNetworkCredential {
|
||||||
|
userName: string
|
||||||
|
password: string
|
||||||
|
lastUsed: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretsPayload {
|
||||||
|
networkCredentials: Record<string, StoredNetworkCredential>
|
||||||
|
dongleAuthCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_NAME = 'secrets.bin'
|
||||||
|
|
||||||
|
function secretsPath(): string {
|
||||||
|
return path.join(app.getPath('userData'), FILE_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyPayload(): SecretsPayload {
|
||||||
|
return { networkCredentials: {}, dongleAuthCode: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function canEncrypt(): boolean {
|
||||||
|
try {
|
||||||
|
return safeStorage.isEncryptionAvailable()
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRaw(): SecretsPayload {
|
||||||
|
const file = secretsPath()
|
||||||
|
if (!fs.existsSync(file)) return emptyPayload()
|
||||||
|
try {
|
||||||
|
const buf = fs.readFileSync(file)
|
||||||
|
if (!buf.length) return emptyPayload()
|
||||||
|
let text: string
|
||||||
|
if (canEncrypt()) {
|
||||||
|
text = safeStorage.decryptString(buf)
|
||||||
|
} else {
|
||||||
|
text = buf.toString('utf8')
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(text) as Partial<SecretsPayload>
|
||||||
|
return {
|
||||||
|
networkCredentials:
|
||||||
|
parsed.networkCredentials && typeof parsed.networkCredentials === 'object'
|
||||||
|
? parsed.networkCredentials
|
||||||
|
: {},
|
||||||
|
dongleAuthCode: typeof parsed.dongleAuthCode === 'string' ? parsed.dongleAuthCode : ''
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.warn('[secrets-store] read failed', e)
|
||||||
|
return emptyPayload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRaw(payload: SecretsPayload): void {
|
||||||
|
const text = JSON.stringify(payload)
|
||||||
|
try {
|
||||||
|
const buf = canEncrypt() ? safeStorage.encryptString(text) : Buffer.from(text, 'utf8')
|
||||||
|
fs.writeFileSync(secretsPath(), buf)
|
||||||
|
} catch (e) {
|
||||||
|
log.warn('[secrets-store] write failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: SecretsPayload | null = null
|
||||||
|
|
||||||
|
export function getSecrets(): SecretsPayload {
|
||||||
|
if (!cache) cache = readRaw()
|
||||||
|
return {
|
||||||
|
networkCredentials: { ...cache.networkCredentials },
|
||||||
|
dongleAuthCode: cache.dongleAuthCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSecrets(patch: Partial<SecretsPayload>): SecretsPayload {
|
||||||
|
const current = getSecrets()
|
||||||
|
if (patch.networkCredentials !== undefined) {
|
||||||
|
current.networkCredentials = { ...patch.networkCredentials }
|
||||||
|
}
|
||||||
|
if (patch.dongleAuthCode !== undefined) {
|
||||||
|
current.dongleAuthCode = patch.dongleAuthCode
|
||||||
|
}
|
||||||
|
cache = current
|
||||||
|
writeRaw(current)
|
||||||
|
return getSecrets()
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import koffi from 'koffi'
|
import koffi from 'koffi'
|
||||||
import log from 'electron-log'
|
import log from 'electron-log'
|
||||||
|
import { normalizeCardPositionQuery } from '@shared/card-position'
|
||||||
import { CS_OK, JOB_ID_BUF_SIZE, LOG_FATAL_FLAG } from '../constants'
|
import { CS_OK, JOB_ID_BUF_SIZE, LOG_FATAL_FLAG } from '../constants'
|
||||||
|
import { emitTrace, isTraceEnabled } from '../utils/trace-bridge'
|
||||||
import { getNativeDir } from './native-path'
|
import { getNativeDir } from './native-path'
|
||||||
|
|
||||||
export interface InitParams {
|
export interface InitParams {
|
||||||
@@ -22,6 +24,8 @@ let SAPI_Init: any = null
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let SAPI_GetPrinterInfo: any = null
|
let SAPI_GetPrinterInfo: any = null
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let SAPI_FreePrinterInfo: any = null
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let SAPI_GetPrinterErrorStr: any = null
|
let SAPI_GetPrinterErrorStr: any = null
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let SAPI_RestJobEx: any = null
|
let SAPI_RestJobEx: any = null
|
||||||
@@ -37,11 +41,78 @@ let SAPI_GetUsbCopyState: any = null
|
|||||||
let SAPI_PrinterResetprinter: any = null
|
let SAPI_PrinterResetprinter: any = null
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let SAPI_PrinterMovetoreject: any = null
|
let SAPI_PrinterMovetoreject: any = null
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let SAPI_PrinterMovetousbreader: any = null
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let SAPI_PrinterMovetohopper: any = null
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let SAPI_GetPrinterCardPosition: any = null
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let SAPI_PrinterCheckstatus: any = null
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let SAPI_UploadFile: any = null
|
||||||
let hasRejectApi = false
|
let hasRejectApi = false
|
||||||
|
let hasCardPositionApi = false
|
||||||
|
let hasCheckstatusApi = false
|
||||||
let hasCancelApi = false
|
let hasCancelApi = false
|
||||||
|
let hasUploadApi = false
|
||||||
|
let hasUsbReaderApi = false
|
||||||
|
let hasHopperApi = false
|
||||||
let loggedCancelMissing = false
|
let loggedCancelMissing = false
|
||||||
let loggedRejectMissing = false
|
let loggedRejectMissing = false
|
||||||
|
|
||||||
|
function tracePayload(r: unknown): Record<string, unknown> {
|
||||||
|
if (r === null || r === undefined) return {}
|
||||||
|
if (typeof r !== 'object') return { value: r }
|
||||||
|
return { ...(r as Record<string, unknown>) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function traceCall<T>(name: string, args: Record<string, unknown> | undefined, fn: () => T): T {
|
||||||
|
if (!isTraceEnabled()) return fn()
|
||||||
|
const tag = `[dll] ${name}`
|
||||||
|
const start = Date.now()
|
||||||
|
emitTrace(`${tag} →`, args)
|
||||||
|
try {
|
||||||
|
const r = fn()
|
||||||
|
emitTrace(`${tag} ←`, { ms: Date.now() - start, ...tracePayload(r) })
|
||||||
|
return r
|
||||||
|
} catch (e) {
|
||||||
|
emitTrace(`${tag} ✗`, { ms: Date.now() - start, error: String(e) }, 'error')
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function freePrinterInfoPtr(ptr: number): void {
|
||||||
|
if (!ptr) return
|
||||||
|
// DLL 堆分配的缓冲区必须用 DLL 导出的释放函数;koffi.free 会触发 STATUS_HEAP_CORRUPTION
|
||||||
|
if (SAPI_FreePrinterInfo) {
|
||||||
|
try {
|
||||||
|
SAPI_FreePrinterInfo(ptr)
|
||||||
|
} catch (e) {
|
||||||
|
log.warn('SAPI_FreePrinterInfo failed', e)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.warn('SAPI_FreePrinterInfo unavailable; printer info buffer not freed')
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPrinterJsonFromOutPtr(len: number, outPtr: Buffer): { code: number; json?: Record<string, unknown> } {
|
||||||
|
if (len <= 0) return { code: len }
|
||||||
|
const ptr = koffi.decode(outPtr, 0, 'void *') as number
|
||||||
|
if (!ptr) return { code: len }
|
||||||
|
try {
|
||||||
|
const jsonStr = koffi.decode(ptr, 'char', len) as string
|
||||||
|
if (!jsonStr?.trim()) return { code: len }
|
||||||
|
try {
|
||||||
|
return { code: len, json: JSON.parse(jsonStr) as Record<string, unknown> }
|
||||||
|
} catch {
|
||||||
|
return { code: len }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
freePrinterInfoPtr(ptr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadLibrary(): void {
|
function loadLibrary(): void {
|
||||||
if (lib) return
|
if (lib) return
|
||||||
const dllPath = path.join(getNativeDir(), 'workDll.dll')
|
const dllPath = path.join(getNativeDir(), 'workDll.dll')
|
||||||
@@ -58,6 +129,22 @@ function loadLibrary(): void {
|
|||||||
SAPI_GetUsbCopyState = lib.func('int __stdcall SAPI_GetUsbCopyState(_Out_ int *, _Out_ int *)')
|
SAPI_GetUsbCopyState = lib.func('int __stdcall SAPI_GetUsbCopyState(_Out_ int *, _Out_ int *)')
|
||||||
SAPI_PrinterResetprinter = lib.func('int __stdcall SAPI_PrinterResetprinter()')
|
SAPI_PrinterResetprinter = lib.func('int __stdcall SAPI_PrinterResetprinter()')
|
||||||
|
|
||||||
|
try {
|
||||||
|
SAPI_FreePrinterInfo = lib.func('void __stdcall SAPI_FreePrinterInfo(void *)')
|
||||||
|
} catch {
|
||||||
|
SAPI_FreePrinterInfo = null
|
||||||
|
log.warn('SAPI_FreePrinterInfo not in workDll')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SAPI_UploadFile = lib.func('int __stdcall SAPI_UploadFile(str, str, str)')
|
||||||
|
hasUploadApi = true
|
||||||
|
} catch {
|
||||||
|
SAPI_UploadFile = null
|
||||||
|
hasUploadApi = false
|
||||||
|
log.warn('SAPI_UploadFile not in workDll')
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
SAPI_AdminJobCancel = lib.func('int __stdcall SAPI_AdminJobCancel(str)')
|
SAPI_AdminJobCancel = lib.func('int __stdcall SAPI_AdminJobCancel(str)')
|
||||||
hasCancelApi = true
|
hasCancelApi = true
|
||||||
@@ -66,7 +153,7 @@ function loadLibrary(): void {
|
|||||||
hasCancelApi = false
|
hasCancelApi = false
|
||||||
if (!loggedCancelMissing) {
|
if (!loggedCancelMissing) {
|
||||||
loggedCancelMissing = true
|
loggedCancelMissing = true
|
||||||
log.info('SAPI_AdminJobCancel not in workDll (optional); stop uses poll-stop only')
|
emitTrace('[dll] SAPI_AdminJobCancel not in workDll (optional)')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +164,55 @@ function loadLibrary(): void {
|
|||||||
hasRejectApi = false
|
hasRejectApi = false
|
||||||
if (!loggedRejectMissing) {
|
if (!loggedRejectMissing) {
|
||||||
loggedRejectMissing = true
|
loggedRejectMissing = true
|
||||||
log.info('SAPI_PrinterMovetoreject not in workDll (optional); reject card disabled')
|
emitTrace('[dll] SAPI_PrinterMovetoreject not in workDll (optional)')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SAPI_PrinterMovetousbreader = lib.func('int __stdcall SAPI_PrinterMovetousbreader()')
|
||||||
|
hasUsbReaderApi = true
|
||||||
|
} catch {
|
||||||
|
SAPI_PrinterMovetousbreader = null
|
||||||
|
hasUsbReaderApi = false
|
||||||
|
emitTrace('[dll] SAPI_PrinterMovetousbreader not in workDll (optional)')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SAPI_PrinterMovetohopper = lib.func('int __stdcall SAPI_PrinterMovetohopper()')
|
||||||
|
hasHopperApi = true
|
||||||
|
} catch {
|
||||||
|
SAPI_PrinterMovetohopper = null
|
||||||
|
hasHopperApi = false
|
||||||
|
emitTrace('[dll] SAPI_PrinterMovetohopper not in workDll (optional)')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SAPI_GetPrinterCardPosition = lib.func('int __stdcall SAPI_GetPrinterCardPosition(_Out_ int *)')
|
||||||
|
hasCardPositionApi = true
|
||||||
|
} catch {
|
||||||
|
SAPI_GetPrinterCardPosition = null
|
||||||
|
hasCardPositionApi = false
|
||||||
|
emitTrace('[dll] SAPI_GetPrinterCardPosition not in workDll (optional)')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
SAPI_PrinterCheckstatus = lib.func('int __stdcall SAPI_PrinterCheckstatus()')
|
||||||
|
hasCheckstatusApi = true
|
||||||
|
} catch {
|
||||||
|
SAPI_PrinterCheckstatus = null
|
||||||
|
hasCheckstatusApi = false
|
||||||
|
emitTrace('[dll] SAPI_PrinterCheckstatus not in workDll (optional)')
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('workDll loaded', {
|
||||||
|
upload: hasUploadApi,
|
||||||
|
cancel: hasCancelApi,
|
||||||
|
reject: hasRejectApi,
|
||||||
|
usbReader: hasUsbReaderApi,
|
||||||
|
hopper: hasHopperApi,
|
||||||
|
cardPosition: hasCardPositionApi,
|
||||||
|
checkstatus: hasCheckstatusApi
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRejectApiAvailable(): boolean {
|
export function isRejectApiAvailable(): boolean {
|
||||||
@@ -92,47 +225,108 @@ export function isCancelApiAvailable(): boolean {
|
|||||||
return hasCancelApi
|
return hasCancelApi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isUploadApiAvailable(): boolean {
|
||||||
|
loadLibrary()
|
||||||
|
return hasUploadApi
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUsbReaderApiAvailable(): boolean {
|
||||||
|
loadLibrary()
|
||||||
|
return hasUsbReaderApi
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHopperApiAvailable(): boolean {
|
||||||
|
loadLibrary()
|
||||||
|
return hasHopperApi
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCardPositionApiAvailable(): boolean {
|
||||||
|
loadLibrary()
|
||||||
|
return hasCardPositionApi
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPrinterCheckstatusApiAvailable(): boolean {
|
||||||
|
loadLibrary()
|
||||||
|
return hasCheckstatusApi
|
||||||
|
}
|
||||||
|
|
||||||
export function dllInit(params: InitParams): number {
|
export function dllInit(params: InitParams): number {
|
||||||
|
return traceCall(
|
||||||
|
'SAPI_Init',
|
||||||
|
{
|
||||||
|
sharedDir: params.sharedDir,
|
||||||
|
keepCombinedImage: params.keepCombinedImage ?? true,
|
||||||
|
stopOnFailure: params.stopOnFailure ?? false,
|
||||||
|
cleanTaskFile: params.cleanTaskFile ?? false,
|
||||||
|
autoRetryTimes: params.autoRetryTimes ?? 0,
|
||||||
|
rejectConfig: params.rejectConfig ?? false,
|
||||||
|
logLevel: params.logLevel ?? LOG_FATAL_FLAG,
|
||||||
|
outBack: params.outBack ?? false
|
||||||
|
},
|
||||||
|
() => {
|
||||||
loadLibrary()
|
loadLibrary()
|
||||||
return SAPI_Init!(
|
return SAPI_Init!(
|
||||||
params.sharedDir,
|
params.sharedDir,
|
||||||
params.keepCombinedImage ?? true,
|
params.keepCombinedImage ?? true,
|
||||||
params.stopOnFailure ?? false,
|
params.stopOnFailure ?? false,
|
||||||
params.cleanTaskFile ?? true,
|
params.cleanTaskFile ?? false,
|
||||||
params.autoRetryTimes ?? 0,
|
params.autoRetryTimes ?? 0,
|
||||||
params.rejectConfig ?? false,
|
params.rejectConfig ?? false,
|
||||||
params.logLevel ?? LOG_FATAL_FLAG,
|
params.logLevel ?? LOG_FATAL_FLAG,
|
||||||
params.outBack ?? false
|
params.outBack ?? false
|
||||||
) as number
|
) as number
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dllGetPrinterInfo(): { code: number; json?: Record<string, unknown> } {
|
function dllGetPrinterInfoInternal(
|
||||||
|
apiName: 'SAPI_GetPrinterInfo',
|
||||||
|
fn: (outPtr: Buffer) => number
|
||||||
|
): { code: number; json?: Record<string, unknown> } {
|
||||||
|
return traceCall(apiName, undefined, () => {
|
||||||
loadLibrary()
|
loadLibrary()
|
||||||
const outPtr = koffi.alloc('void *', 8)
|
const outPtr = koffi.alloc('void *', 8)
|
||||||
try {
|
try {
|
||||||
const len = SAPI_GetPrinterInfo!(outPtr) as number
|
const len = fn(outPtr) as number
|
||||||
if (len <= 0) return { code: len }
|
return readPrinterJsonFromOutPtr(len, outPtr)
|
||||||
const ptr = koffi.decode(outPtr, 0, 'void *') as number
|
|
||||||
const jsonStr = koffi.decode(ptr, 'char', len) as string
|
|
||||||
koffi.free(ptr)
|
|
||||||
return { code: len, json: JSON.parse(jsonStr) as Record<string, unknown> }
|
|
||||||
} finally {
|
} finally {
|
||||||
koffi.free(outPtr)
|
koffi.free(outPtr)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dllGetPrinterInfo(): { code: number; json?: Record<string, unknown> } {
|
||||||
|
return dllGetPrinterInfoInternal('SAPI_GetPrinterInfo', (p) => SAPI_GetPrinterInfo!(p))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dllGetPrinterErrorStr(errorNo = -1): string {
|
export function dllGetPrinterErrorStr(errorNo = -1): string {
|
||||||
|
return traceCall('SAPI_GetPrinterErrorStr', { errorNo }, () => {
|
||||||
loadLibrary()
|
loadLibrary()
|
||||||
const s = SAPI_GetPrinterErrorStr!(errorNo) as string
|
const s = SAPI_GetPrinterErrorStr!(errorNo) as string
|
||||||
return s || ''
|
return s || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dllUploadFile(userDir: string, fileName: string, fileText: string): number {
|
||||||
|
return traceCall(
|
||||||
|
'SAPI_UploadFile',
|
||||||
|
{ userDir, fileName, bytes: Buffer.byteLength(fileText ?? '', 'utf8') },
|
||||||
|
() => {
|
||||||
|
loadLibrary()
|
||||||
|
if (!SAPI_UploadFile) throw new Error('UPLOAD_API_UNAVAILABLE')
|
||||||
|
return SAPI_UploadFile(userDir, fileName, fileText) as number
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dllRestJobEx(json: string): { code: number; jobId: string } {
|
export function dllRestJobEx(json: string): { code: number; jobId: string } {
|
||||||
|
return traceCall('SAPI_RestJobEx', { jsonBytes: Buffer.byteLength(json ?? '', 'utf8') }, () => {
|
||||||
loadLibrary()
|
loadLibrary()
|
||||||
const buf = Buffer.alloc(JOB_ID_BUF_SIZE)
|
const buf = Buffer.alloc(JOB_ID_BUF_SIZE)
|
||||||
const code = SAPI_RestJobEx!(json, buf, JOB_ID_BUF_SIZE) as number
|
const code = SAPI_RestJobEx!(json, buf, JOB_ID_BUF_SIZE) as number
|
||||||
const jobId = buf.toString('utf8').replace(/\0.*$/, '').trim()
|
const jobId = buf.toString('utf8').replace(/\0.*$/, '').trim()
|
||||||
return { code, jobId }
|
return { code, jobId }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dllGetJobStateById(jobId: string): {
|
export function dllGetJobStateById(jobId: string): {
|
||||||
@@ -140,6 +334,7 @@ export function dllGetJobStateById(jobId: string): {
|
|||||||
jobState: number
|
jobState: number
|
||||||
progress: number
|
progress: number
|
||||||
} {
|
} {
|
||||||
|
return traceCall('SAPI_GetJobStateById', { jobId }, () => {
|
||||||
loadLibrary()
|
loadLibrary()
|
||||||
const jobState = [0]
|
const jobState = [0]
|
||||||
const copyScheduler = [0]
|
const copyScheduler = [0]
|
||||||
@@ -149,34 +344,87 @@ export function dllGetJobStateById(jobId: string): {
|
|||||||
jobState: jobState[0],
|
jobState: jobState[0],
|
||||||
progress: copyScheduler[0]
|
progress: copyScheduler[0]
|
||||||
}
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dllAdminJobCancel(jobId: string): number {
|
export function dllAdminJobCancel(jobId: string): number {
|
||||||
|
return traceCall('SAPI_AdminJobCancel', { jobId }, () => {
|
||||||
loadLibrary()
|
loadLibrary()
|
||||||
if (!SAPI_AdminJobCancel) throw new Error('CANCEL_API_UNAVAILABLE')
|
if (!SAPI_AdminJobCancel) throw new Error('CANCEL_API_UNAVAILABLE')
|
||||||
return SAPI_AdminJobCancel(jobId) as number
|
return SAPI_AdminJobCancel(jobId) as number
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dllCopyFromUsb(destFolder: string, cardOutput: number): number {
|
export function dllCopyFromUsb(destFolder: string, cardOutput: number): number {
|
||||||
|
return traceCall('SAPI_CopyFromUsb', { destFolder, cardOutput }, () => {
|
||||||
loadLibrary()
|
loadLibrary()
|
||||||
return SAPI_CopyFromUsb!(destFolder, cardOutput) as number
|
return SAPI_CopyFromUsb!(destFolder, cardOutput) as number
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dllGetUsbCopyState(): { taskStatus: number; progress: number } {
|
export function dllGetUsbCopyState(): {
|
||||||
|
queryCode: number
|
||||||
|
taskStatus: number
|
||||||
|
progress: number
|
||||||
|
} {
|
||||||
|
return traceCall('SAPI_GetUsbCopyState', undefined, () => {
|
||||||
loadLibrary()
|
loadLibrary()
|
||||||
const taskStatus = [0]
|
const taskStatus = [0]
|
||||||
const progress = [0]
|
const copyProgress = [0]
|
||||||
SAPI_GetUsbCopyState!(taskStatus, progress)
|
const queryCode = SAPI_GetUsbCopyState!(taskStatus, copyProgress) as number
|
||||||
return { taskStatus: taskStatus[0], progress: progress[0] }
|
return {
|
||||||
|
queryCode,
|
||||||
|
taskStatus: taskStatus[0],
|
||||||
|
progress: copyProgress[0]
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dllPrinterReset(): number {
|
export function dllPrinterReset(): number {
|
||||||
|
return traceCall('SAPI_PrinterResetprinter', undefined, () => {
|
||||||
loadLibrary()
|
loadLibrary()
|
||||||
return SAPI_PrinterResetprinter!() as number
|
return SAPI_PrinterResetprinter!() as number
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dllPrinterMoveToUsbReader(): number {
|
||||||
|
return traceCall('SAPI_PrinterMovetousbreader', undefined, () => {
|
||||||
|
loadLibrary()
|
||||||
|
if (!SAPI_PrinterMovetousbreader) throw new Error('USB_READER_API_UNAVAILABLE')
|
||||||
|
return SAPI_PrinterMovetousbreader() as number
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dllPrinterMoveToHopper(): number {
|
||||||
|
return traceCall('SAPI_PrinterMovetohopper', undefined, () => {
|
||||||
|
loadLibrary()
|
||||||
|
if (!SAPI_PrinterMovetohopper) throw new Error('HOPPER_API_UNAVAILABLE')
|
||||||
|
return SAPI_PrinterMovetohopper() as number
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dllPrinterReject(): number {
|
export function dllPrinterReject(): number {
|
||||||
|
return traceCall('SAPI_PrinterMovetoreject', undefined, () => {
|
||||||
loadLibrary()
|
loadLibrary()
|
||||||
if (!SAPI_PrinterMovetoreject) throw new Error('REJECT_API_UNAVAILABLE')
|
if (!SAPI_PrinterMovetoreject) throw new Error('REJECT_API_UNAVAILABLE')
|
||||||
return SAPI_PrinterMovetoreject() as number
|
return SAPI_PrinterMovetoreject() as number
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dllGetPrinterCardPosition(): { queryCode: number; position: number } {
|
||||||
|
return traceCall('SAPI_GetPrinterCardPosition', undefined, () => {
|
||||||
|
loadLibrary()
|
||||||
|
if (!SAPI_GetPrinterCardPosition) throw new Error('CARD_POSITION_API_UNAVAILABLE')
|
||||||
|
const out = [0]
|
||||||
|
const ret = SAPI_GetPrinterCardPosition!(out) as number
|
||||||
|
return normalizeCardPositionQuery(ret, out[0])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dllPrinterCheckstatus(): number {
|
||||||
|
return traceCall('SAPI_PrinterCheckstatus', undefined, () => {
|
||||||
|
loadLibrary()
|
||||||
|
if (!SAPI_PrinterCheckstatus) throw new Error('CHECKSTATUS_API_UNAVAILABLE')
|
||||||
|
return SAPI_PrinterCheckstatus!() as number
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import koffi from 'koffi'
|
||||||
|
|
||||||
|
/** Windows 控制台默认多为 GBK,DLL 日志为 UTF-8,会导致中文乱码。 */
|
||||||
|
export function ensureConsoleUtf8(): void {
|
||||||
|
if (process.platform !== 'win32') return
|
||||||
|
try {
|
||||||
|
const kernel32 = koffi.load('kernel32.dll')
|
||||||
|
const SetConsoleOutputCP = kernel32.func('bool __stdcall SetConsoleOutputCP(uint32)')
|
||||||
|
const SetConsoleCP = kernel32.func('bool __stdcall SetConsoleCP(uint32)')
|
||||||
|
SetConsoleOutputCP(65001)
|
||||||
|
SetConsoleCP(65001)
|
||||||
|
} catch {
|
||||||
|
/* ignore: no console or API unavailable */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (process.stdout.setDefaultEncoding) process.stdout.setDefaultEncoding('utf8')
|
||||||
|
if (process.stderr.setDefaultEncoding) process.stderr.setDefaultEncoding('utf8')
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export interface JobCsvRow {
|
||||||
|
originName: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSoonCsvText(rows: JobCsvRow[]): string {
|
||||||
|
const line1 = rows.map((r) => r.originName).join(',')
|
||||||
|
const line2 = rows.map((r) => r.value).join(',')
|
||||||
|
return `${line1}\n${line2}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeJobCsv(sharedDir: string, taskId: string, rows: JobCsvRow[]): string {
|
||||||
|
const dir = path.join(sharedDir, taskId)
|
||||||
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
|
const csvPath = path.join(dir, 'temp.csv')
|
||||||
|
fs.writeFileSync(csvPath, buildSoonCsvText(rows), 'utf8')
|
||||||
|
return csvPath
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { execSync } from 'child_process'
|
||||||
|
import {
|
||||||
|
extractDriveLetter,
|
||||||
|
extractHostName,
|
||||||
|
isNetworkPath
|
||||||
|
} from '@shared/network-host'
|
||||||
|
|
||||||
|
function resolveUncForDrive(letter: string): string | null {
|
||||||
|
const L = (letter || '').trim().toUpperCase()
|
||||||
|
if (!L || L.length !== 1) return null
|
||||||
|
if (process.platform !== 'win32') return null
|
||||||
|
try {
|
||||||
|
const out = execSync(`net use ${L}:`, { encoding: 'utf8', windowsHide: true })
|
||||||
|
const m = /Remote\s+(\S+)/i.exec(out) || /远程\s+(\S+)/i.exec(out)
|
||||||
|
const unc = m?.[1]?.trim()
|
||||||
|
if (!unc || !unc.startsWith('\\\\')) return null
|
||||||
|
return unc
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveHostsFromPaths(paths: string[]): string[] {
|
||||||
|
const hosts = new Set<string>()
|
||||||
|
for (const raw of paths) {
|
||||||
|
const p = (raw || '').trim()
|
||||||
|
if (!p) continue
|
||||||
|
if (isNetworkPath(p)) {
|
||||||
|
const h = extractHostName(p)
|
||||||
|
if (h) hosts.add(h)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const letter = extractDriveLetter(p)
|
||||||
|
if (!letter) continue
|
||||||
|
const unc = resolveUncForDrive(letter)
|
||||||
|
if (!unc) continue
|
||||||
|
const h = extractHostName(unc)
|
||||||
|
if (h) hosts.add(h)
|
||||||
|
}
|
||||||
|
return Array.from(hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDriveHostMap(paths: string[]): Record<string, string> {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
for (const raw of paths) {
|
||||||
|
const p = (raw || '').trim()
|
||||||
|
if (!p || isNetworkPath(p)) continue
|
||||||
|
const letter = extractDriveLetter(p)
|
||||||
|
if (!letter || map[letter]) continue
|
||||||
|
const unc = resolveUncForDrive(letter)
|
||||||
|
if (!unc) continue
|
||||||
|
const h = extractHostName(unc)
|
||||||
|
if (h) map[letter] = h
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
@@ -4,12 +4,29 @@ import { pathToFileURL } from 'url'
|
|||||||
export interface TemplateFieldRow {
|
export interface TemplateFieldRow {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
|
originName: string
|
||||||
|
/** soon frontData/backData 的 type:1=图片,3/4/5=文本可编辑 */
|
||||||
|
fieldType: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParsedSoonTemplate {
|
export interface ParsedSoonTemplate {
|
||||||
frontImageUrl: string
|
frontImageUrl: string
|
||||||
backImageUrl: string
|
backImageUrl: string
|
||||||
fields: TemplateFieldRow[]
|
fields: TemplateFieldRow[]
|
||||||
|
/** soon 模板 flag:1 双面 / 2 正面 / 3 背面 */
|
||||||
|
templateFlag: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOON_FIELD_TYPES = new Set([1, 3, 4, 5])
|
||||||
|
|
||||||
|
/** soon 文件元数据 flag,用于与任务 print_flag 校验 */
|
||||||
|
export function readSoonTemplateFlag(raw: Record<string, unknown>): number {
|
||||||
|
const flag = Number(raw.flag)
|
||||||
|
if (flag === 1 || flag === 2 || flag === 3) return flag
|
||||||
|
const hasBack =
|
||||||
|
!!String(raw.backDisplayPic ?? '').trim() ||
|
||||||
|
(Array.isArray(raw.backData) && raw.backData.length > 0)
|
||||||
|
return hasBack ? 1 : 2
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickArray(obj: Record<string, unknown>, key: string): Record<string, unknown>[] {
|
function pickArray(obj: Record<string, unknown>, key: string): Record<string, unknown>[] {
|
||||||
@@ -38,18 +55,54 @@ function sideLabel(side: 'front' | 'back'): string {
|
|||||||
return side === 'front' ? '正面' : '背面'
|
return side === 'front' ? '正面' : '背面'
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAssetPath(soonPath: string, ref: string): string {
|
function toImageUrl(soonPath: string, ref: string): string {
|
||||||
if (!ref) return ''
|
if (!ref) return ''
|
||||||
|
if (/^(data:|https?:|file:)/i.test(ref)) return ref
|
||||||
const clean = ref.replace(/^file:\/\//i, '')
|
const clean = ref.replace(/^file:\/\//i, '')
|
||||||
const abs = path.isAbsolute(clean) ? clean : path.join(path.dirname(soonPath), clean)
|
const abs = path.isAbsolute(clean) ? clean : path.join(path.dirname(soonPath), clean)
|
||||||
return pathToFileURL(abs).href
|
return pathToFileURL(abs).href
|
||||||
}
|
}
|
||||||
|
|
||||||
function toFieldLabel(name: string, side: 'front' | 'back'): string {
|
function resolveAssetPath(soonPath: string, ref: string): string {
|
||||||
return `${name} [${sideLabel(side)}]`
|
return toImageUrl(soonPath, ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
|
function toFieldLabel(name: string, side: 'front' | 'back'): string {
|
||||||
|
return `${name}[${sideLabel(side)}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSoonWorkerDisk(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
|
||||||
|
const fields: TemplateFieldRow[] = []
|
||||||
|
|
||||||
|
const appendSide = (arr: unknown, side: '正面' | '背面') => {
|
||||||
|
if (!Array.isArray(arr)) return
|
||||||
|
for (const item of arr) {
|
||||||
|
if (!item || typeof item !== 'object') continue
|
||||||
|
const o = item as Record<string, unknown>
|
||||||
|
const type = Number(o.type)
|
||||||
|
if (!SOON_FIELD_TYPES.has(type)) continue
|
||||||
|
const name = String(o.name ?? '').trim()
|
||||||
|
if (!name) continue
|
||||||
|
const value = o.DefaultText == null ? '' : String(o.DefaultText)
|
||||||
|
fields.push({ label: `${name}[${side}]`, value, originName: name, fieldType: type })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendSide(raw.frontData, '正面')
|
||||||
|
appendSide(raw.backData, '背面')
|
||||||
|
|
||||||
|
const frontPic = String(raw.frontDisplayPic ?? '').trim()
|
||||||
|
const backPic = String(raw.backDisplayPic ?? '').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
frontImageUrl: toImageUrl(soonPath, frontPic),
|
||||||
|
backImageUrl: toImageUrl(soonPath, backPic),
|
||||||
|
fields,
|
||||||
|
templateFlag: readSoonTemplateFlag(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSoonLegacy(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
|
||||||
const imgs = pickArray(raw, 'Img')
|
const imgs = pickArray(raw, 'Img')
|
||||||
const texts = pickArray(raw, 'Text')
|
const texts = pickArray(raw, 'Text')
|
||||||
|
|
||||||
@@ -66,7 +119,7 @@ export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>
|
|||||||
if (side === 'front') {
|
if (side === 'front') {
|
||||||
if (!frontImageUrl) frontImageUrl = url
|
if (!frontImageUrl) frontImageUrl = url
|
||||||
const name = pickStr(item, ['name', 'field', 'key']) || 'IMAGE'
|
const name = pickStr(item, ['name', 'field', 'key']) || 'IMAGE'
|
||||||
fields.push({ label: toFieldLabel(name, 'front'), value: fileRef })
|
fields.push({ label: toFieldLabel(name, 'front'), value: fileRef, originName: name, fieldType: 1 })
|
||||||
} else if (!backImageUrl) {
|
} else if (!backImageUrl) {
|
||||||
backImageUrl = url
|
backImageUrl = url
|
||||||
}
|
}
|
||||||
@@ -78,8 +131,25 @@ export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>
|
|||||||
const value = pickStr(item, ['value', 'text', 'default', 'content', 'data'])
|
const value = pickStr(item, ['value', 'text', 'default', 'content', 'data'])
|
||||||
let side = sideOf(item)
|
let side = sideOf(item)
|
||||||
if (!side) side = /image|img|front/i.test(name) ? 'front' : 'back'
|
if (!side) side = /image|img|front/i.test(name) ? 'front' : 'back'
|
||||||
fields.push({ label: toFieldLabel(name, side), value })
|
fields.push({ label: toFieldLabel(name, side), value, originName: name, fieldType: 5 })
|
||||||
})
|
})
|
||||||
|
|
||||||
return { frontImageUrl, backImageUrl, fields }
|
return {
|
||||||
|
frontImageUrl,
|
||||||
|
backImageUrl,
|
||||||
|
fields,
|
||||||
|
templateFlag: readSoonTemplateFlag(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
|
||||||
|
if (
|
||||||
|
Array.isArray(raw.frontData) ||
|
||||||
|
Array.isArray(raw.backData) ||
|
||||||
|
raw.frontDisplayPic != null ||
|
||||||
|
raw.backDisplayPic != null
|
||||||
|
) {
|
||||||
|
return parseSoonWorkerDisk(soonPath, raw)
|
||||||
|
}
|
||||||
|
return parseSoonLegacy(soonPath, raw)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import log from 'electron-log'
|
||||||
|
import { CS_OK } from '../constants'
|
||||||
|
import { cleanPathPattern } from '@shared/path-pattern'
|
||||||
|
import { getDirectorySizeBytes } from './dir-size'
|
||||||
|
|
||||||
|
export type JobStageDll = {
|
||||||
|
dllUploadFile: (userDir: string, fileName: string, fileText: string) => number
|
||||||
|
isUploadApiAvailable: () => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyIfExists(src: string, dest: string): void {
|
||||||
|
if (!fs.existsSync(src)) return
|
||||||
|
fs.mkdirSync(path.dirname(dest), { recursive: true })
|
||||||
|
fs.copyFileSync(src, dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadText(
|
||||||
|
dll: JobStageDll,
|
||||||
|
userDir: string,
|
||||||
|
fileName: string,
|
||||||
|
text: string
|
||||||
|
): boolean {
|
||||||
|
if (!dll.isUploadApiAvailable()) return false
|
||||||
|
const code = dll.dllUploadFile(userDir, fileName, fileText)
|
||||||
|
if (code !== CS_OK) {
|
||||||
|
log.warn('SAPI_UploadFile failed', { userDir, fileName, code })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageSoonAssets(taskDir: string, soonSrc: string, soonDest: string): void {
|
||||||
|
copyIfExists(soonSrc, soonDest)
|
||||||
|
let raw: Record<string, unknown>
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(fs.readFileSync(soonDest, 'utf8')) as Record<string, unknown>
|
||||||
|
} catch (e) {
|
||||||
|
log.warn('stageSoonAssets: parse soon failed', e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const soonDir = path.dirname(soonSrc)
|
||||||
|
for (const key of ['frontDisplayPic', 'backDisplayPic']) {
|
||||||
|
const ref = raw[key]
|
||||||
|
if (typeof ref !== 'string' || !ref.trim()) continue
|
||||||
|
const clean = ref.replace(/^file:\/\//i, '').trim()
|
||||||
|
const assetSrc = path.isAbsolute(clean) ? clean : path.join(soonDir, clean)
|
||||||
|
const assetDest = path.join(taskDir, path.basename(clean))
|
||||||
|
copyIfExists(assetSrc, assetDest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCopyPaths(payload: Record<string, unknown>): void {
|
||||||
|
if (!Array.isArray(payload.path_file)) {
|
||||||
|
throw new Error('拷贝任务缺少 path_file')
|
||||||
|
}
|
||||||
|
const normalized: string[] = []
|
||||||
|
for (const entry of payload.path_file) {
|
||||||
|
if (typeof entry !== 'string' || !entry.trim()) continue
|
||||||
|
const target = cleanPathPattern(entry)
|
||||||
|
if (!target || !fs.existsSync(target)) {
|
||||||
|
throw new Error(`拷贝路径不存在: ${entry}`)
|
||||||
|
}
|
||||||
|
let st: fs.Stats
|
||||||
|
try {
|
||||||
|
st = fs.statSync(target)
|
||||||
|
} catch {
|
||||||
|
throw new Error(`拷贝路径不可访问: ${target}`)
|
||||||
|
}
|
||||||
|
if (st.isFile()) {
|
||||||
|
normalized.push(target)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!st.isDirectory()) {
|
||||||
|
throw new Error(`拷贝路径无效: ${target}`)
|
||||||
|
}
|
||||||
|
if (getDirectorySizeBytes(target) <= 0) {
|
||||||
|
throw new Error(`拷贝路径下没有可拷贝的文件: ${target}`)
|
||||||
|
}
|
||||||
|
normalized.push(target)
|
||||||
|
}
|
||||||
|
if (normalized.length === 0) {
|
||||||
|
throw new Error('拷贝路径下没有可拷贝的文件')
|
||||||
|
}
|
||||||
|
payload.path_file = normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeRestJobPayload(payload: Record<string, unknown>): void {
|
||||||
|
const hasCopy = payload.has_copy_task === true
|
||||||
|
const hasPrint = payload.has_print_task === true
|
||||||
|
if (!hasCopy && !hasPrint) {
|
||||||
|
throw new Error('任务需包含拷贝或打印')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPrint) {
|
||||||
|
if (typeof payload.json_file !== 'string' || !payload.json_file.trim()) {
|
||||||
|
throw new Error('打印任务缺少 json_file')
|
||||||
|
}
|
||||||
|
if (!payload.udf_file) delete payload.udf_file
|
||||||
|
} else {
|
||||||
|
delete payload.json_file
|
||||||
|
delete payload.udf_file
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasCopy) {
|
||||||
|
const paths = payload.path_file
|
||||||
|
if (
|
||||||
|
!Array.isArray(paths) ||
|
||||||
|
paths.length === 0 ||
|
||||||
|
!paths.every((p) => typeof p === 'string' && p.trim())
|
||||||
|
) {
|
||||||
|
throw new Error('拷贝任务缺少 path_file')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete payload.path_file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stageJobPayloadJson(
|
||||||
|
json: string,
|
||||||
|
sharedDir: string,
|
||||||
|
dll: JobStageDll
|
||||||
|
): { json: string; taskDir: string } {
|
||||||
|
const payload = JSON.parse(json) as Record<string, unknown>
|
||||||
|
const taskId = String(payload.task_id || '').trim()
|
||||||
|
if (!taskId) throw new Error('task_id 缺失')
|
||||||
|
|
||||||
|
const taskDir = path.join(sharedDir, taskId)
|
||||||
|
fs.mkdirSync(taskDir, { recursive: true })
|
||||||
|
const userDir = taskId
|
||||||
|
|
||||||
|
const udfFile = payload.udf_file
|
||||||
|
if (typeof udfFile === 'string' && udfFile.trim() && fs.existsSync(udfFile.trim())) {
|
||||||
|
const csvText = fs.readFileSync(udfFile.trim(), 'utf8')
|
||||||
|
if (!uploadText(dll, userDir, 'temp.csv', csvText)) {
|
||||||
|
copyIfExists(udfFile.trim(), path.join(taskDir, 'temp.csv'))
|
||||||
|
}
|
||||||
|
payload.udf_file = path.join(taskDir, 'temp.csv')
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonFile = payload.json_file
|
||||||
|
if (typeof jsonFile === 'string' && jsonFile.trim()) {
|
||||||
|
const src = jsonFile.trim()
|
||||||
|
const base = path.basename(src)
|
||||||
|
const dest = path.join(taskDir, base)
|
||||||
|
const soonText = fs.readFileSync(src, 'utf8')
|
||||||
|
uploadText(dll, userDir, base, soonText)
|
||||||
|
stageSoonAssets(taskDir, src, dest)
|
||||||
|
payload.json_file = dest
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.has_copy_task === true) {
|
||||||
|
normalizeCopyPaths(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeRestJobPayload(payload)
|
||||||
|
|
||||||
|
return { json: JSON.stringify(payload), taskDir }
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { WebContents } from 'electron'
|
||||||
|
import { configStore } from '../services/config-store'
|
||||||
|
|
||||||
|
export interface TracePayload {
|
||||||
|
level: 'info' | 'error'
|
||||||
|
message: string
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
let target: WebContents | null = null
|
||||||
|
|
||||||
|
export function setTraceWebContents(wc: WebContents | null): void {
|
||||||
|
target = wc && !wc.isDestroyed() ? wc : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTraceEnabled(): boolean {
|
||||||
|
return configStore.get('traceEnabled', true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitTrace(
|
||||||
|
message: string,
|
||||||
|
data?: Record<string, unknown>,
|
||||||
|
level: 'info' | 'error' = 'info'
|
||||||
|
): void {
|
||||||
|
if (!isTraceEnabled()) return
|
||||||
|
if (!target || target.isDestroyed()) return
|
||||||
|
target.send('app:trace', { level, message, data } satisfies TracePayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 升级旧配置:曾写入 dllTraceEnabled:false 会导致一直无日志 */
|
||||||
|
export function migrateTraceConfig(): void {
|
||||||
|
if (configStore.get('_traceV2') === true) return
|
||||||
|
configStore.set('traceEnabled', true)
|
||||||
|
configStore.set('_traceV2', true)
|
||||||
|
if (configStore.has('dllTraceEnabled')) {
|
||||||
|
configStore.delete('dllTraceEnabled')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,11 @@ const channels = {
|
|||||||
invoke: [
|
invoke: [
|
||||||
'dll:init',
|
'dll:init',
|
||||||
'dll:printer-info',
|
'dll:printer-info',
|
||||||
|
'dll:printer-status',
|
||||||
'dll:printer-reset',
|
'dll:printer-reset',
|
||||||
'dll:printer-reject',
|
'dll:printer-reject',
|
||||||
|
'dll:printer-read-card',
|
||||||
|
'dll:printer-eject-card',
|
||||||
'dll:printer-error-str',
|
'dll:printer-error-str',
|
||||||
'dll:job-create',
|
'dll:job-create',
|
||||||
'dll:job-cancel',
|
'dll:job-cancel',
|
||||||
@@ -14,17 +17,24 @@ const channels = {
|
|||||||
'poll:job-stop',
|
'poll:job-stop',
|
||||||
'poll:usb-start',
|
'poll:usb-start',
|
||||||
'poll:usb-stop',
|
'poll:usb-stop',
|
||||||
|
'poll:card-position-start',
|
||||||
|
'poll:card-position-stop',
|
||||||
'dialog:open-directory',
|
'dialog:open-directory',
|
||||||
'dialog:open-file',
|
'dialog:open-file',
|
||||||
'fs:path-exists',
|
'fs:path-exists',
|
||||||
'fs:dir-size',
|
'fs:dir-size',
|
||||||
'fs:parse-soon',
|
'fs:parse-soon',
|
||||||
|
'fs:resolve-network-hosts',
|
||||||
|
'fs:write-job-csv',
|
||||||
|
'secrets:get',
|
||||||
|
'secrets:set',
|
||||||
'config:get',
|
'config:get',
|
||||||
'config:set',
|
'config:set',
|
||||||
'shell:open-path',
|
'shell:open-path',
|
||||||
|
'design:open',
|
||||||
'dll:reject-available'
|
'dll:reject-available'
|
||||||
] as const,
|
] as const,
|
||||||
on: ['job:poll-tick', 'usb:poll-tick'] as const
|
on: ['job:poll-tick', 'usb:poll-tick', 'card:position-tick', 'app:trace'] as const
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardsoonApi = {
|
const cardsoonApi = {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta
|
<meta
|
||||||
http-equiv="Content-Security-Policy"
|
http-equiv="Content-Security-Policy"
|
||||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:"
|
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: file: blob:"
|
||||||
/>
|
/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>卡树数据卡打印系统</title>
|
<title>卡树数据卡打印系统</title>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAppBootstrap } from '@/composables/useAppBootstrap'
|
import { useAppBootstrap } from '@/composables/useAppBootstrap'
|
||||||
|
import { usePrinterStatusPoll } from '@/composables/usePrinterStatusPoll'
|
||||||
|
|
||||||
useAppBootstrap()
|
useAppBootstrap()
|
||||||
|
usePrinterStatusPoll()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { InitParamsDTO, IpcResult, JobPollPayload, UsbPollPayload } from '@/types/ipc'
|
import { parsePrinterInfoFromDll } from '@shared/printer-info'
|
||||||
|
import type { InitParamsDTO, IpcResult, JobPollPayload, UsbPollPayload, CardPositionPollPayload } from '@/types/ipc'
|
||||||
import type { PrinterStatusDisplay } from '@/types/printer'
|
import type { PrinterStatusDisplay } from '@/types/printer'
|
||||||
|
|
||||||
function api() {
|
function api() {
|
||||||
@@ -13,17 +14,16 @@ export async function dllPrinterInfo(): Promise<IpcResult<Record<string, unknown
|
|||||||
return api().invoke('dll:printer-info') as Promise<IpcResult<Record<string, unknown>>>
|
return api().invoke('dll:printer-info') as Promise<IpcResult<Record<string, unknown>>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function dllPrinterStatus(): Promise<
|
||||||
|
IpcResult<{ statusText: string; statusCode?: number; fromCache?: boolean; liveError?: string }>
|
||||||
|
> {
|
||||||
|
return api().invoke('dll:printer-status') as Promise<
|
||||||
|
IpcResult<{ statusText: string; statusCode?: number; fromCache?: boolean; liveError?: string }>
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
export function parsePrinterInfo(json: Record<string, unknown>): PrinterStatusDisplay {
|
export function parsePrinterInfo(json: Record<string, unknown>): PrinterStatusDisplay {
|
||||||
const list = (json.printerList as Record<string, unknown>[]) || []
|
return parsePrinterInfoFromDll(json)
|
||||||
const p = list[0] || {}
|
|
||||||
const serial =
|
|
||||||
p.szPrinterSerial ?? p.PrinterSerial ?? p.SerialNo ?? p.PrinterName ?? '—'
|
|
||||||
return {
|
|
||||||
ribbonType: String(p.RibbonType ?? '—'),
|
|
||||||
statusText: String(p.PrinterType ?? '—'),
|
|
||||||
serialNo: String(serial),
|
|
||||||
printedCount: Number(p.PrintedCount ?? 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dllPrinterReset(): Promise<IpcResult> {
|
export async function dllPrinterReset(): Promise<IpcResult> {
|
||||||
@@ -34,6 +34,16 @@ export async function dllPrinterReject(): Promise<IpcResult> {
|
|||||||
return api().invoke('dll:printer-reject') as Promise<IpcResult>
|
return api().invoke('dll:printer-reject') as Promise<IpcResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读卡:移动卡片到读取区(SAPI_PrinterMovetousbreader) */
|
||||||
|
export async function dllPrinterReadCard(): Promise<IpcResult> {
|
||||||
|
return api().invoke('dll:printer-read-card') as Promise<IpcResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退卡:移动卡片到出卡区(SAPI_PrinterMovetohopper) */
|
||||||
|
export async function dllPrinterEjectCard(): Promise<IpcResult> {
|
||||||
|
return api().invoke('dll:printer-eject-card') as Promise<IpcResult>
|
||||||
|
}
|
||||||
|
|
||||||
export async function dllRejectAvailable(): Promise<IpcResult<{ available: boolean }>> {
|
export async function dllRejectAvailable(): Promise<IpcResult<{ available: boolean }>> {
|
||||||
return api().invoke('dll:reject-available') as Promise<IpcResult<{ available: boolean }>>
|
return api().invoke('dll:reject-available') as Promise<IpcResult<{ available: boolean }>>
|
||||||
}
|
}
|
||||||
@@ -42,32 +52,45 @@ export async function dllPrinterErrorStr(errorNo = -1): Promise<IpcResult<{ text
|
|||||||
return api().invoke('dll:printer-error-str', errorNo) as Promise<IpcResult<{ text: string }>>
|
return api().invoke('dll:printer-error-str', errorNo) as Promise<IpcResult<{ text: string }>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dllJobCreate(json: string): Promise<IpcResult<{ jobId: string }>> {
|
export async function dllJobCreate(
|
||||||
return api().invoke('dll:job-create', json) as Promise<IpcResult<{ jobId: string }>>
|
json: string,
|
||||||
|
opts?: { resubmit?: boolean }
|
||||||
|
): Promise<IpcResult<{ jobId: string }>> {
|
||||||
|
return api().invoke('dll:job-create', json, opts) as Promise<IpcResult<{ jobId: string }>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dllJobCancel(jobId: string): Promise<IpcResult> {
|
export async function dllJobCancel(jobId: string): Promise<IpcResult> {
|
||||||
return api().invoke('dll:job-cancel', jobId) as Promise<IpcResult>
|
return api().invoke('dll:job-cancel', jobId) as Promise<IpcResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function dllUsbCopy(destFolder: string, cardOutput: number): Promise<IpcResult> {
|
export async function dllUsbCopy(
|
||||||
return api().invoke('dll:usb-copy', { destFolder, cardOutput }) as Promise<IpcResult>
|
destFolder: string,
|
||||||
|
cardOutput: number,
|
||||||
|
opts?: { resubmit?: boolean }
|
||||||
|
): Promise<IpcResult> {
|
||||||
|
return api().invoke('dll:usb-copy', { destFolder, cardOutput, resubmit: opts?.resubmit }) as Promise<IpcResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pollJobStart(jobId: string): Promise<IpcResult> {
|
export async function pollJobStart(jobId: string): Promise<IpcResult> {
|
||||||
return api().invoke('poll:job-start', jobId) as Promise<IpcResult>
|
return api().invoke('poll:job-start', jobId) as Promise<IpcResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pollJobStop(): Promise<IpcResult> {
|
export async function pollJobStop(opts?: { resetMode?: boolean }): Promise<IpcResult> {
|
||||||
return api().invoke('poll:job-stop') as Promise<IpcResult>
|
return api().invoke('poll:job-stop', opts) as Promise<IpcResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pollUsbStart(): Promise<IpcResult> {
|
export async function pollUsbStop(opts?: { resetMode?: boolean }): Promise<IpcResult> {
|
||||||
return api().invoke('poll:usb-start') as Promise<IpcResult>
|
return api().invoke('poll:usb-stop', opts) as Promise<IpcResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pollUsbStop(): Promise<IpcResult> {
|
export async function pollCardPositionStart(
|
||||||
return api().invoke('poll:usb-stop') as Promise<IpcResult>
|
sessionMode?: 'distributing' | 'usbCopying'
|
||||||
|
): Promise<IpcResult> {
|
||||||
|
return api().invoke('poll:card-position-start', sessionMode) as Promise<IpcResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollCardPositionStop(): Promise<IpcResult> {
|
||||||
|
return api().invoke('poll:card-position-stop') as Promise<IpcResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onJobPollTick(cb: (p: JobPollPayload) => void): () => void {
|
export function onJobPollTick(cb: (p: JobPollPayload) => void): () => void {
|
||||||
@@ -78,6 +101,10 @@ export function onUsbPollTick(cb: (p: UsbPollPayload) => void): () => void {
|
|||||||
return api().on('usb:poll-tick', cb as (...args: unknown[]) => void)
|
return api().on('usb:poll-tick', cb as (...args: unknown[]) => void)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function onCardPositionTick(cb: (p: CardPositionPollPayload) => void): () => void {
|
||||||
|
return api().on('card:position-tick', cb as (...args: unknown[]) => void)
|
||||||
|
}
|
||||||
|
|
||||||
export async function dialogOpenDirectory(): Promise<IpcResult<{ paths: string[] }>> {
|
export async function dialogOpenDirectory(): Promise<IpcResult<{ paths: string[] }>> {
|
||||||
return api().invoke('dialog:open-directory') as Promise<IpcResult<{ paths: string[] }>>
|
return api().invoke('dialog:open-directory') as Promise<IpcResult<{ paths: string[] }>>
|
||||||
}
|
}
|
||||||
@@ -88,6 +115,12 @@ export async function dialogOpenSoon(): Promise<IpcResult<{ path: string }>> {
|
|||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function dialogOpenImage(): Promise<IpcResult<{ path: string }>> {
|
||||||
|
return api().invoke('dialog:open-file', [
|
||||||
|
{ name: 'Image', extensions: ['jpg', 'jpeg', 'png', 'bmp', 'gif', 'webp'] }
|
||||||
|
]) as Promise<IpcResult<{ path: string }>>
|
||||||
|
}
|
||||||
|
|
||||||
export async function fsPathExists(paths: string[]): Promise<IpcResult<{ missing: string[] }>> {
|
export async function fsPathExists(paths: string[]): Promise<IpcResult<{ missing: string[] }>> {
|
||||||
return api().invoke('fs:path-exists', paths) as Promise<IpcResult<{ missing: string[] }>>
|
return api().invoke('fs:path-exists', paths) as Promise<IpcResult<{ missing: string[] }>>
|
||||||
}
|
}
|
||||||
@@ -100,22 +133,76 @@ export async function fsDirSize(
|
|||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fsWriteJobCsv(payload: {
|
||||||
|
taskId: string
|
||||||
|
rows: { originName: string; value: string }[]
|
||||||
|
}): Promise<IpcResult<{ path: string }>> {
|
||||||
|
return api().invoke('fs:write-job-csv', payload) as Promise<IpcResult<{ path: string }>>
|
||||||
|
}
|
||||||
|
|
||||||
export async function fsParseSoon(filePath: string): Promise<
|
export async function fsParseSoon(filePath: string): Promise<
|
||||||
IpcResult<{ frontImageUrl: string; backImageUrl: string; fields: { label: string; value: string }[] }>
|
IpcResult<{
|
||||||
|
frontImageUrl: string
|
||||||
|
backImageUrl: string
|
||||||
|
fields: { label: string; value: string; originName: string; fieldType: number }[]
|
||||||
|
templateFlag: number
|
||||||
|
}>
|
||||||
> {
|
> {
|
||||||
return api().invoke('fs:parse-soon', filePath) as Promise<
|
return api().invoke('fs:parse-soon', filePath) as Promise<
|
||||||
IpcResult<{ frontImageUrl: string; backImageUrl: string; fields: { label: string; value: string }[] }>
|
IpcResult<{
|
||||||
|
frontImageUrl: string
|
||||||
|
backImageUrl: string
|
||||||
|
fields: { label: string; value: string; originName: string; fieldType: number }[]
|
||||||
|
templateFlag: number
|
||||||
|
}>
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fsResolveNetworkHosts(
|
||||||
|
paths: string[]
|
||||||
|
): Promise<IpcResult<{ hosts: string[]; driveHostMap: Record<string, string> }>> {
|
||||||
|
return api().invoke('fs:resolve-network-hosts', paths) as Promise<
|
||||||
|
IpcResult<{ hosts: string[]; driveHostMap: Record<string, string> }>
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecretsPayloadDTO {
|
||||||
|
networkCredentials?: Record<string, { userName: string; password: string; lastUsed: string }>
|
||||||
|
dongleAuthCode?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function secretsGet(): Promise<IpcResult<SecretsPayloadDTO>> {
|
||||||
|
return api().invoke('secrets:get') as Promise<IpcResult<SecretsPayloadDTO>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function secretsSet(patch: SecretsPayloadDTO): Promise<IpcResult<SecretsPayloadDTO>> {
|
||||||
|
return api().invoke('secrets:set', patch) as Promise<IpcResult<SecretsPayloadDTO>>
|
||||||
|
}
|
||||||
|
|
||||||
export async function configGet(): Promise<
|
export async function configGet(): Promise<
|
||||||
IpcResult<{ sharedDir: string; templateDir: string; skipDllInit?: boolean }>
|
IpcResult<{
|
||||||
|
sharedDir: string
|
||||||
|
templateDir: string
|
||||||
|
traceEnabled: boolean
|
||||||
|
lastPrinterStatus?: PrinterStatusDisplay
|
||||||
|
dllInitialized: boolean
|
||||||
|
}>
|
||||||
> {
|
> {
|
||||||
return api().invoke('config:get') as Promise<
|
return api().invoke('config:get') as Promise<
|
||||||
IpcResult<{ sharedDir: string; templateDir: string; skipDllInit?: boolean }>
|
IpcResult<{
|
||||||
|
sharedDir: string
|
||||||
|
templateDir: string
|
||||||
|
traceEnabled: boolean
|
||||||
|
lastPrinterStatus?: PrinterStatusDisplay
|
||||||
|
dllInitialized: boolean
|
||||||
|
}>
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function openDesignApp(): Promise<IpcResult> {
|
||||||
|
return api().invoke('design:open') as Promise<IpcResult>
|
||||||
|
}
|
||||||
|
|
||||||
export async function configSet(patch: Record<string, unknown>): 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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ export const ICON_NAMES = [
|
|||||||
'plus',
|
'plus',
|
||||||
'exchange',
|
'exchange',
|
||||||
'stop',
|
'stop',
|
||||||
'warning'
|
'warning',
|
||||||
|
'id-card',
|
||||||
|
'eject',
|
||||||
|
'eye',
|
||||||
|
'eye-slash'
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type IconName = (typeof ICON_NAMES)[number]
|
export type IconName = (typeof ICON_NAMES)[number]
|
||||||
@@ -34,5 +38,9 @@ export const ICON_FA_CLASS: Record<IconName, string> = {
|
|||||||
plus: 'fas fa-plus',
|
plus: 'fas fa-plus',
|
||||||
exchange: 'fas fa-exchange-alt',
|
exchange: 'fas fa-exchange-alt',
|
||||||
stop: 'fas fa-stop',
|
stop: 'fas fa-stop',
|
||||||
warning: 'fas fa-exclamation-triangle'
|
warning: 'fas fa-exclamation-triangle',
|
||||||
|
'id-card': 'fas fa-id-card',
|
||||||
|
eject: 'fas fa-eject',
|
||||||
|
eye: 'fas fa-eye',
|
||||||
|
'eye-slash': 'fas fa-eye-slash'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<footer class="c-footer">
|
<footer class="c-footer">
|
||||||
<div>版本V1.0</div>
|
<div class="c-footer__version">{{ t('footer.version', { version: appVersion }) }}</div>
|
||||||
<div>www.cardsoon.com</div>
|
<div>{{ t('footer.website') }}</div>
|
||||||
<div>版权所有 © 2026 卡树科技</div>
|
<div>{{ t('footer.copyright') }}</div>
|
||||||
</footer>
|
</footer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 构建时由 electron.vite.config.ts 从 package.json version 注入
|
||||||
|
const appVersion = __APP_VERSION__
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -5,16 +5,24 @@
|
|||||||
<div class="c-header__center">
|
<div class="c-header__center">
|
||||||
<div v-if="mode" class="c-mode-badge c-mode-badge--home">{{ mode }}</div>
|
<div v-if="mode" class="c-mode-badge c-mode-badge--home">{{ mode }}</div>
|
||||||
<div class="c-status-capsule">
|
<div class="c-status-capsule">
|
||||||
<span>色带: <b>{{ status.ribbonType }}</b></span>
|
<span>{{ t('header.ribbon') }}: <b>{{ status.ribbonType }}</b></span>
|
||||||
<span
|
<span>{{ t('header.ribbonAmount') }}: <b>{{ status.ribbonAmount }}</b></span>
|
||||||
>状态: <b :class="statusTone">{{ status.statusText }}</b></span
|
<span>{{ t('header.status') }}: <b :class="statusTone">{{ displayStatusText }}</b></span>
|
||||||
>
|
<span>{{ t('header.serialNo') }}: <b>{{ status.serialNo }}</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">
|
||||||
|
<select
|
||||||
|
class="c-lang-select"
|
||||||
|
:value="currentLocale"
|
||||||
|
@change="onLocaleChange"
|
||||||
|
:title="t('common.select')"
|
||||||
|
>
|
||||||
|
<option v-for="opt in LOCALE_OPTIONS" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,16 +32,64 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
|
import { LOCALE_OPTIONS, persistLocale, type AppLocale } from '@/i18n'
|
||||||
|
|
||||||
defineProps<{ mode?: string }>()
|
defineProps<{ mode?: string }>()
|
||||||
|
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const status = computed(() => configStore.printer)
|
const status = computed(() => configStore.printer)
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
|
const currentLocale = computed(() => locale.value)
|
||||||
|
|
||||||
|
function onLocaleChange(e: Event): void {
|
||||||
|
const val = (e.target as HTMLSelectElement).value as AppLocale
|
||||||
|
locale.value = val
|
||||||
|
persistLocale(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打印机状态文本来自主进程(中文),在前端按已知值做多语言映射
|
||||||
|
const PRINTER_STATUS_MAP: Record<string, string> = {
|
||||||
|
空闲: 'printerStatus.idle',
|
||||||
|
忙碌: 'printerStatus.busy',
|
||||||
|
正在打印: 'printerStatus.printing',
|
||||||
|
未连接打印机: 'printerStatus.notConnected',
|
||||||
|
未初始化: 'printerStatus.notInitialized',
|
||||||
|
初始化失败: 'printerStatus.initFailed',
|
||||||
|
就绪: 'printerStatus.ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayStatusText = computed(() => {
|
||||||
|
const raw = status.value.statusText
|
||||||
|
const key = PRINTER_STATUS_MAP[raw]
|
||||||
|
return key ? t(key) : raw
|
||||||
|
})
|
||||||
|
|
||||||
const statusTone = computed(() => {
|
const statusTone = computed(() => {
|
||||||
const t = status.value.statusText
|
const toneMap: Record<string, string> = {
|
||||||
if (t.includes('未初始化') || t.includes('未连接')) return 'c-status-warn'
|
[t('printerStatus.notInitialized')]: 'c-status-warn',
|
||||||
return ''
|
[t('printerStatus.notConnected')]: 'c-status-warn',
|
||||||
|
[t('printerStatus.initFailed')]: 'c-status-warn'
|
||||||
|
}
|
||||||
|
return toneMap[displayStatusText.value] || ''
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.c-lang-select {
|
||||||
|
height: 28px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
color: #303133;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 6px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.c-lang-select:hover {
|
||||||
|
border-color: #409eff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="m-settings-modal" :class="{ 'is-open': modelValue }" :aria-hidden="!modelValue">
|
|
||||||
<div class="m-settings-modal__backdrop" />
|
|
||||||
<section
|
|
||||||
class="m-settings-modal__dialog"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="settingsModalTitle"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<header class="m-settings-modal__header">
|
|
||||||
<h2 id="settingsModalTitle">设置</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="m-settings-modal__close"
|
|
||||||
aria-label="关闭"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<AppIcon name="times" size="sm" />
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<div class="m-settings-modal__body">
|
|
||||||
<section class="m-settings-group">
|
|
||||||
<h3 class="m-settings-group__title">系统初始化</h3>
|
|
||||||
<div class="m-settings-group__panel m-settings-init">
|
|
||||||
<div class="m-settings-init__field">
|
|
||||||
<label for="settingSharedDir">任务目录 (shared_dir)</label>
|
|
||||||
<input
|
|
||||||
id="settingSharedDir"
|
|
||||||
v-model="sharedDir"
|
|
||||||
type="text"
|
|
||||||
class="c-input m-settings-shared-dir"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p v-if="appStore.initError" class="m-settings-init-error">{{ appStore.initError }}</p>
|
|
||||||
<button type="button" class="c-button-cs m-settings-init__btn" @click="onRetryInit">
|
|
||||||
重试 Init
|
|
||||||
</button>
|
|
||||||
<p class="m-settings-init-hint">每台进程仅可 Init 一次;更换目录或重连打印机请重启应用</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="m-settings-group">
|
|
||||||
<h3 class="m-settings-group__title">基础配置</h3>
|
|
||||||
<div class="m-settings-group__panel">
|
|
||||||
<div class="m-settings-row">
|
|
||||||
<label for="settingPriority">优先级</label>
|
|
||||||
<AppSelect
|
|
||||||
id="settingPriority"
|
|
||||||
v-model="form.priority"
|
|
||||||
block
|
|
||||||
:items="PRIORITY_OPTIONS"
|
|
||||||
/>
|
|
||||||
<label for="settingRibbonType">色带类型</label>
|
|
||||||
<AppSelect
|
|
||||||
id="settingRibbonType"
|
|
||||||
v-model="form.ribbonType"
|
|
||||||
block
|
|
||||||
:items="RIBBON_TYPE_OPTIONS"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="m-settings-row m-settings-row--format">
|
|
||||||
<label for="settingCopyFormat">拷贝前格式化类型</label>
|
|
||||||
<AppSelect
|
|
||||||
id="settingCopyFormat"
|
|
||||||
v-model="form.formatType"
|
|
||||||
block
|
|
||||||
:items="FORMAT_TYPE_OPTIONS"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="m-settings-group m-settings-group--advanced">
|
|
||||||
<h3 class="m-settings-group__title">高级选项</h3>
|
|
||||||
<div class="m-settings-group__panel">
|
|
||||||
<div class="m-settings-options">
|
|
||||||
<label class="m-settings-check">
|
|
||||||
<input v-model="form.generateIso" type="checkbox" />
|
|
||||||
<span>拷贝前生成 iso</span>
|
|
||||||
</label>
|
|
||||||
<label class="m-settings-check">
|
|
||||||
<input v-model="form.printCmdToHasi" type="checkbox" />
|
|
||||||
<span>打印 cmd 到 hASI 字段</span>
|
|
||||||
</label>
|
|
||||||
<label class="m-settings-check">
|
|
||||||
<input v-model="form.generateZip" type="checkbox" />
|
|
||||||
<span>生成 zip</span>
|
|
||||||
</label>
|
|
||||||
<label class="m-settings-check">
|
|
||||||
<input v-model="form.presetCopy" type="checkbox" />
|
|
||||||
<span>预设内容拷贝</span>
|
|
||||||
</label>
|
|
||||||
<label class="m-settings-check">
|
|
||||||
<input v-model="form.generateHasi" type="checkbox" />
|
|
||||||
<span>生成 hASI 文件</span>
|
|
||||||
</label>
|
|
||||||
<label class="m-settings-check">
|
|
||||||
<input v-model="form.dongleCountCheck" type="checkbox" />
|
|
||||||
<span>加密狗计数</span>
|
|
||||||
</label>
|
|
||||||
<label class="m-settings-check">
|
|
||||||
<input v-model="form.failPrintLabel" type="checkbox" />
|
|
||||||
<span>失败打印标签</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<footer class="m-settings-modal__footer">
|
|
||||||
<button type="button" class="m-settings-modal__cancel" @click="close">
|
|
||||||
<AppIcon name="times" size="sm" />
|
|
||||||
<span>取消</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="c-button-cs m-settings-modal__confirm" @click="onConfirm">
|
|
||||||
确定
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onUnmounted, ref, watch } from 'vue'
|
|
||||||
import AppIcon from '@/components/AppIcon.vue'
|
|
||||||
import AppSelect from '@/components/AppSelect.vue'
|
|
||||||
import {
|
|
||||||
FORMAT_TYPE_OPTIONS,
|
|
||||||
PRIORITY_OPTIONS,
|
|
||||||
RIBBON_TYPE_OPTIONS
|
|
||||||
} from '@/constants/selectOptions'
|
|
||||||
import { notify } from '@/composables/useNotify'
|
|
||||||
import { useAppBootstrap } from '@/composables/useAppBootstrap'
|
|
||||||
import { configGet, configSet } from '@/api/cardsoon'
|
|
||||||
import { useAppStore } from '@/stores/app'
|
|
||||||
import { useConfigStore } from '@/stores/config'
|
|
||||||
import { useDistributeFormStore } from '@/stores/distributeForm'
|
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean }>()
|
|
||||||
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
|
|
||||||
|
|
||||||
const form = useDistributeFormStore()
|
|
||||||
const appStore = useAppStore()
|
|
||||||
const configStore = useConfigStore()
|
|
||||||
const { retryInit } = useAppBootstrap()
|
|
||||||
const sharedDir = ref(configStore.sharedDir)
|
|
||||||
|
|
||||||
function close(): void {
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSettings(): Promise<void> {
|
|
||||||
const dir = sharedDir.value.trim()
|
|
||||||
if (!dir) return
|
|
||||||
configStore.setSharedDir(dir)
|
|
||||||
await configSet({ sharedDir: dir })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onConfirm(): Promise<void> {
|
|
||||||
await saveSettings()
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onRetryInit(): Promise<void> {
|
|
||||||
await saveSettings()
|
|
||||||
if (appStore.initialized) {
|
|
||||||
notify.info('系统已初始化,无需重复 Init')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await retryInit()
|
|
||||||
if (appStore.initialized) notify.success('Init 成功,系统已就绪')
|
|
||||||
else notify.error(appStore.initError || 'Init 失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent): void {
|
|
||||||
if (e.key === 'Escape') close()
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
async (open) => {
|
|
||||||
if (open) {
|
|
||||||
sharedDir.value = configStore.sharedDir
|
|
||||||
const cfg = await configGet()
|
|
||||||
if (cfg.data?.sharedDir) sharedDir.value = cfg.data.sharedDir
|
|
||||||
window.addEventListener('keydown', onKeydown)
|
|
||||||
} else {
|
|
||||||
window.removeEventListener('keydown', onKeydown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style src="@/styles/pages/page4.css"></style>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.m-settings-init {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-init__field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-init__field label {
|
|
||||||
font-size: 10px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-shared-dir {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
height: 20px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-init__btn {
|
|
||||||
align-self: flex-start;
|
|
||||||
height: 24px;
|
|
||||||
font-size: 12px;
|
|
||||||
padding: 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-row--format {
|
|
||||||
grid-template-columns: 120px 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-init-error {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 10px;
|
|
||||||
line-height: 1.3;
|
|
||||||
color: #dc3545;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-init-hint {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 10px;
|
|
||||||
color: #6c757d;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<transition name="npd-fade">
|
||||||
|
<div v-if="visible" class="npd-mask" @mousedown.self="onCancel">
|
||||||
|
<div class="npd-dialog" role="dialog" aria-modal="true" aria-labelledby="npd-title">
|
||||||
|
<div class="npd-header">
|
||||||
|
<span id="npd-title" class="npd-title">{{ t('networkDialog.title') }}</span>
|
||||||
|
<button type="button" class="npd-close" :aria-label="t('common.close')" @click="onCancel">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="npd-body">
|
||||||
|
<p class="npd-hint">{{ t('networkDialog.hint') }}</p>
|
||||||
|
|
||||||
|
<label class="npd-field">
|
||||||
|
<span class="npd-label">{{ t('networkDialog.host') }}<span class="npd-req">*</span></span>
|
||||||
|
<input
|
||||||
|
v-model.trim="host"
|
||||||
|
type="text"
|
||||||
|
class="c-input"
|
||||||
|
:placeholder="t('networkDialog.hostPlaceholder')"
|
||||||
|
:class="{ 'is-invalid': touched && !hostValid }"
|
||||||
|
@blur="touched = true"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="npd-field">
|
||||||
|
<span class="npd-label">{{ t('networkDialog.share') }}</span>
|
||||||
|
<input
|
||||||
|
v-model.trim="share"
|
||||||
|
type="text"
|
||||||
|
class="c-input"
|
||||||
|
:placeholder="t('networkDialog.sharePlaceholder')"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="npd-field">
|
||||||
|
<span class="npd-label">{{ t('networkDialog.userName') }}<span class="npd-req">*</span></span>
|
||||||
|
<input
|
||||||
|
v-model.trim="userName"
|
||||||
|
type="text"
|
||||||
|
class="c-input"
|
||||||
|
:placeholder="t('networkDialog.userNamePlaceholder')"
|
||||||
|
:class="{ 'is-invalid': touched && !userNameValid }"
|
||||||
|
@blur="touched = true"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="npd-field">
|
||||||
|
<span class="npd-label">{{ t('networkDialog.password') }}<span class="npd-req">*</span></span>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
class="c-input"
|
||||||
|
:placeholder="t('networkDialog.errPassword')"
|
||||||
|
:class="{ 'is-invalid': touched && !passwordValid }"
|
||||||
|
@blur="touched = true"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="previewUrl" class="npd-preview">
|
||||||
|
<span class="npd-preview-label">{{ t('networkDialog.targetUNC') }}</span>
|
||||||
|
<code class="npd-preview-path">{{ previewUrl }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="touched && errorText" class="npd-error">{{ errorText }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="npd-footer">
|
||||||
|
<button type="button" class="c-button-cs" @click="onCancel">{{ t('common.cancel') }}</button>
|
||||||
|
<button type="button" class="c-button-cs npd-primary" :disabled="!canConfirm" @click="onConfirm">
|
||||||
|
{{ t('common.confirm') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { buildNetworkUrl } from '@shared/network-host'
|
||||||
|
import { useNetworkAuthStore } from '@/stores/networkAuth'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
initialHost?: string
|
||||||
|
initialShare?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [boolean]
|
||||||
|
confirm: [{ path: string; hostName: string; userName: string; password: string }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const netStore = useNetworkAuthStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const host = ref('')
|
||||||
|
const share = ref('')
|
||||||
|
const userName = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const touched = ref(false)
|
||||||
|
|
||||||
|
const hostRe = /^[A-Za-z0-9_.-]+$/
|
||||||
|
|
||||||
|
const hostValid = computed(() => hostRe.test(host.value))
|
||||||
|
const userNameValid = computed(() => userName.value.length > 0)
|
||||||
|
const passwordValid = computed(() => password.value.length > 0)
|
||||||
|
const canConfirm = computed(() => hostValid.value && userNameValid.value && passwordValid.value)
|
||||||
|
|
||||||
|
const previewUrl = computed(() => (hostValid.value ? buildNetworkUrl(host.value, share.value) : ''))
|
||||||
|
|
||||||
|
const errorText = computed(() => {
|
||||||
|
if (!hostValid.value) return t('networkDialog.errHostInvalid')
|
||||||
|
if (!userNameValid.value) return t('networkDialog.errUserName')
|
||||||
|
if (!passwordValid.value) return t('networkDialog.errPassword')
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function reset(): void {
|
||||||
|
host.value = ''
|
||||||
|
share.value = ''
|
||||||
|
userName.value = ''
|
||||||
|
password.value = ''
|
||||||
|
touched.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel(): void {
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConfirm(): void {
|
||||||
|
touched.value = true
|
||||||
|
if (!canConfirm.value) return
|
||||||
|
const path = buildNetworkUrl(host.value, share.value)
|
||||||
|
emit('confirm', {
|
||||||
|
path,
|
||||||
|
hostName: host.value,
|
||||||
|
userName: userName.value,
|
||||||
|
password: password.value
|
||||||
|
})
|
||||||
|
reset()
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
(v) => {
|
||||||
|
if (v) {
|
||||||
|
reset()
|
||||||
|
if (props.initialHost?.trim()) host.value = props.initialHost.trim()
|
||||||
|
if (props.initialShare?.trim()) share.value = props.initialShare.trim()
|
||||||
|
const storedHost = host.value
|
||||||
|
if (storedHost) {
|
||||||
|
const cred = netStore.getCredential(storedHost)
|
||||||
|
if (cred) {
|
||||||
|
userName.value = cred.userName
|
||||||
|
password.value = cred.password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.npd-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9000;
|
||||||
|
}
|
||||||
|
.npd-dialog {
|
||||||
|
width: 460px;
|
||||||
|
max-width: 92vw;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.npd-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
.npd-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
.npd-close {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #909399;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
.npd-close:hover {
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
.npd-body {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.npd-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
.npd-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.npd-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
.npd-req {
|
||||||
|
color: #f56c6c;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
.is-invalid {
|
||||||
|
border-color: #f56c6c !important;
|
||||||
|
}
|
||||||
|
.npd-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: #f0f9ff;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.npd-preview-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
.npd-preview-path {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #409eff;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.npd-error {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
.npd-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
.npd-primary {
|
||||||
|
background: #409eff;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #409eff;
|
||||||
|
}
|
||||||
|
.npd-primary[disabled] {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.npd-fade-enter-active,
|
||||||
|
.npd-fade-leave-active {
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.npd-fade-enter-from,
|
||||||
|
.npd-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -23,20 +24,22 @@ const props = withDefaults(
|
|||||||
{ activeStep: 2, mode: 'running', variant: 'distribute' }
|
{ activeStep: 2, mode: 'running', variant: 'distribute' }
|
||||||
)
|
)
|
||||||
|
|
||||||
const distributeSteps = [
|
const { t } = useI18n()
|
||||||
{ key: 'prep', label: '任务准备' },
|
|
||||||
{ key: 'copy', label: '拷贝数据' },
|
|
||||||
{ key: 'print', label: '打印卡片' },
|
|
||||||
{ key: 'done', label: '完成' }
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const collectSteps = [
|
const distributeSteps = computed(() => [
|
||||||
{ key: 'prep', label: '任务准备' },
|
{ key: 'prep', label: t('workflow.taskPrep') },
|
||||||
{ key: 'copy', label: '拷贝数据' },
|
{ key: 'copy', label: t('workflow.copyData') },
|
||||||
{ key: 'done', label: '完成' }
|
{ key: 'print', label: t('workflow.printCard') },
|
||||||
] as const
|
{ key: 'done', label: t('workflow.complete') }
|
||||||
|
])
|
||||||
|
|
||||||
const steps = computed(() => (props.variant === 'collect' ? collectSteps : distributeSteps))
|
const collectSteps = computed(() => [
|
||||||
|
{ key: 'prep', label: t('workflow.taskPrep') },
|
||||||
|
{ key: 'copy', label: t('workflow.copyData') },
|
||||||
|
{ key: 'done', label: t('workflow.complete') }
|
||||||
|
])
|
||||||
|
|
||||||
|
const steps = computed(() => (props.variant === 'collect' ? collectSteps.value : distributeSteps.value))
|
||||||
|
|
||||||
const failedStep = computed(() => props.failedStep ?? (props.mode === 'failed' ? 3 : -1))
|
const failedStep = computed(() => props.failedStep ?? (props.mode === 'failed' ? 3 : -1))
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +1,39 @@
|
|||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
|
import i18n from '@/i18n'
|
||||||
import { notify } from '@/composables/useNotify'
|
import { notify } from '@/composables/useNotify'
|
||||||
import {
|
import { refreshPrinterAfterInit } from '@/composables/usePrinterStatus'
|
||||||
configGet,
|
import { configGet, dllInit, dllRejectAvailable } from '@/api/cardsoon'
|
||||||
dllInit,
|
|
||||||
dllPrinterInfo,
|
|
||||||
dllRejectAvailable,
|
|
||||||
parsePrinterInfo
|
|
||||||
} from '@/api/cardsoon'
|
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
|
|
||||||
|
function t(key: string): string {
|
||||||
|
return i18n.global.t(key)
|
||||||
|
}
|
||||||
|
|
||||||
let bootstrapped = false
|
let bootstrapped = false
|
||||||
|
|
||||||
export function useAppBootstrap(): {
|
function placeholderStatus(configStore: ReturnType<typeof useConfigStore>, text: string): void {
|
||||||
retryInit: () => Promise<void>
|
if (configStore.printer.statusText === '—') {
|
||||||
refreshHeader: () => Promise<void>
|
configStore.setPrinter({ ...configStore.printer, statusText: text })
|
||||||
} {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppBootstrap(): void {
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
|
|
||||||
async function refreshHeader(): Promise<void> {
|
async function hydrateFromConfig() {
|
||||||
const info = await dllPrinterInfo()
|
|
||||||
if (info.ok && info.data) {
|
|
||||||
configStore.setPrinter(parsePrinterInfo(info.data))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
configStore.setPrinter({ ...configStore.printer, statusText: '未连接打印机' })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doInit(): Promise<void> {
|
|
||||||
const cfg = await configGet()
|
const cfg = await configGet()
|
||||||
const sharedDir = cfg.data?.sharedDir || ''
|
if (cfg.ok && cfg.data?.lastPrinterStatus) {
|
||||||
configStore.setSharedDir(sharedDir)
|
configStore.setPrinter(cfg.data.lastPrinterStatus)
|
||||||
|
}
|
||||||
if (import.meta.env.DEV && cfg.data?.skipDllInit === true) {
|
if (cfg.ok && cfg.data?.sharedDir) {
|
||||||
appStore.setInitialized(false, '开发模式已跳过 DLL 初始化')
|
configStore.setSharedDir(cfg.data.sharedDir)
|
||||||
configStore.setPrinter({ ...configStore.printer, statusText: '未初始化(开发)' })
|
}
|
||||||
return
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
const init = await dllInit({ sharedDir })
|
async function syncRejectApi(): Promise<void> {
|
||||||
if (!init.ok) {
|
|
||||||
appStore.setInitialized(false, init.message || 'Init 失败')
|
|
||||||
configStore.setPrinter({ ...configStore.printer, statusText: '未初始化' })
|
|
||||||
notify.error(init.message || '初始化失败,请检查任务目录权限')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
appStore.setInitialized(true)
|
|
||||||
configStore.setPrinter({ ...configStore.printer, statusText: '系统已初始化' })
|
|
||||||
const warn = (init.data as { warning?: string } | undefined)?.warning
|
|
||||||
if (warn) notify.warning(warn)
|
|
||||||
try {
|
try {
|
||||||
const rej = await dllRejectAvailable()
|
const rej = await dllRejectAvailable()
|
||||||
if (rej.ok && rej.data) configStore.rejectApiAvailable = rej.data.available
|
if (rej.ok && rej.data) configStore.rejectApiAvailable = rej.data.available
|
||||||
@@ -58,13 +42,39 @@ export function useAppBootstrap(): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function bootstrap(): Promise<void> {
|
||||||
|
const cfg = await hydrateFromConfig()
|
||||||
|
if (cfg.ok && cfg.data?.dllInitialized) {
|
||||||
|
appStore.setInitialized(true)
|
||||||
|
placeholderStatus(configStore, t('printerStatus.ready'))
|
||||||
|
await syncRejectApi()
|
||||||
|
window.setTimeout(() => void refreshPrinterAfterInit(), 1500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedDir = cfg.data?.sharedDir || ''
|
||||||
|
configStore.setSharedDir(sharedDir)
|
||||||
|
const init = await dllInit({ sharedDir })
|
||||||
|
if (!init.ok) {
|
||||||
|
appStore.setInitialized(false, init.message || t('printerStatus.initFailed'))
|
||||||
|
configStore.setPrinter({ ...configStore.printer, statusText: t('printerStatus.initFailed') })
|
||||||
|
notify.error(init.message || t('notify.initFailedHint'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appStore.setInitialized(true)
|
||||||
|
const initMeta = init.data as { warning?: string } | undefined
|
||||||
|
if (initMeta?.warning) notify.warning(initMeta.warning)
|
||||||
|
placeholderStatus(configStore, initMeta?.warning ? t('printerStatus.notConnected') : t('printerStatus.ready'))
|
||||||
|
await syncRejectApi()
|
||||||
|
window.setTimeout(() => void refreshPrinterAfterInit(), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (bootstrapped) return
|
if (bootstrapped) return
|
||||||
bootstrapped = true
|
bootstrapped = true
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
void doInit()
|
void bootstrap()
|
||||||
}, 300)
|
}, 100)
|
||||||
})
|
})
|
||||||
|
|
||||||
return { retryInit: doInit, refreshHeader }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
|
import i18n from '@/i18n'
|
||||||
import { useToastStore, type ToastType } from '@/stores/toast'
|
import { useToastStore, type ToastType } from '@/stores/toast'
|
||||||
|
|
||||||
|
function t(key: string, named?: Record<string, unknown>): string {
|
||||||
|
return i18n.global.t(key, named ?? {})
|
||||||
|
}
|
||||||
|
|
||||||
function push(type: ToastType, message: string, durationMs = 4500): void {
|
function push(type: ToastType, message: string, durationMs = 4500): void {
|
||||||
useToastStore().push(type, message, durationMs)
|
useToastStore().push(type, message, durationMs)
|
||||||
}
|
}
|
||||||
@@ -11,9 +16,11 @@ export const notify = {
|
|||||||
info: (message: string, durationMs?: number) => push('info', message, durationMs)
|
info: (message: string, durationMs?: number) => push('info', message, durationMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
const INIT_HINT = '系统未初始化,请进入「数据分发 → 设置」重试 Init'
|
|
||||||
|
|
||||||
/** 未初始化等业务拦截时的统一提示 */
|
/** 未初始化等业务拦截时的统一提示 */
|
||||||
export function notifyRequireInit(action?: string): void {
|
export function notifyRequireInit(action?: string): void {
|
||||||
notify.warning(action ? `系统未初始化,无法${action}` : INIT_HINT)
|
if (action) {
|
||||||
|
notify.warning(t('notify.notReadyAction', { action }))
|
||||||
|
} else {
|
||||||
|
notify.warning(t('notify.notReady'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { dllPrinterInfo, dllPrinterStatus, parsePrinterInfo } from '@/api/cardsoon'
|
||||||
|
import { useConfigStore } from '@/stores/config'
|
||||||
|
|
||||||
|
/** 状态是否表示打印机未连接/不可用 */
|
||||||
|
function isDisconnectedStatus(text: string): boolean {
|
||||||
|
return !text || text === '—' || text.includes('未连接') || text.includes('Not connected')
|
||||||
|
}
|
||||||
|
|
||||||
|
let prevStatusText = ''
|
||||||
|
|
||||||
|
export async function refreshLiveStatus(): Promise<void> {
|
||||||
|
const store = useConfigStore()
|
||||||
|
try {
|
||||||
|
const r = await dllPrinterStatus()
|
||||||
|
if (r.ok && r.data?.statusText) {
|
||||||
|
const newStatus = r.data.statusText
|
||||||
|
// 检测状态变化:从未连接 → 已连接时,立即刷新打印机信息(型号/isSingleSide等)
|
||||||
|
const wasDisconnected = isDisconnectedStatus(prevStatusText)
|
||||||
|
const nowConnected = !isDisconnectedStatus(newStatus)
|
||||||
|
store.setPrinter({ ...store.printer, statusText: newStatus })
|
||||||
|
if (wasDisconnected && nowConnected) {
|
||||||
|
// 打印机刚连上,立即拉取型号/色带等信息
|
||||||
|
void refreshPrinterInfo()
|
||||||
|
}
|
||||||
|
prevStatusText = newStatus
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshPrinterInfo(): Promise<void> {
|
||||||
|
const store = useConfigStore()
|
||||||
|
try {
|
||||||
|
const info = await dllPrinterInfo()
|
||||||
|
if (!info.ok || !info.data) return
|
||||||
|
const data = info.data as Record<string, unknown>
|
||||||
|
const parsed = data.snapshot
|
||||||
|
? (data.snapshot as typeof store.printer)
|
||||||
|
: parsePrinterInfo(data)
|
||||||
|
store.setPrinter({
|
||||||
|
...store.printer,
|
||||||
|
ribbonType: parsed.ribbonType,
|
||||||
|
ribbonAmount: parsed.ribbonAmount,
|
||||||
|
serialNo: parsed.serialNo,
|
||||||
|
printerName: parsed.printerName,
|
||||||
|
isSingleSide: parsed.isSingleSide
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshPrinterAfterInit(): Promise<void> {
|
||||||
|
await refreshPrinterInfo()
|
||||||
|
await refreshLiveStatus()
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
|
import { refreshLiveStatus, refreshPrinterInfo } from '@/composables/usePrinterStatus'
|
||||||
|
|
||||||
|
const STATUS_ACTIVE_MS = 1500
|
||||||
|
const STATUS_HIDDEN_MS = 8000
|
||||||
|
const INFO_MS = 30000
|
||||||
|
|
||||||
|
let statusTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let infoTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let statusInFlight = false
|
||||||
|
let infoInFlight = false
|
||||||
|
let mountedCount = 0
|
||||||
|
|
||||||
|
function statusIntervalMs(): number {
|
||||||
|
if (typeof document !== 'undefined' && document.hidden) return STATUS_HIDDEN_MS
|
||||||
|
return STATUS_ACTIVE_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tickStatus(): Promise<void> {
|
||||||
|
if (statusInFlight) return
|
||||||
|
statusInFlight = true
|
||||||
|
try {
|
||||||
|
await refreshLiveStatus()
|
||||||
|
} finally {
|
||||||
|
statusInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tickInfo(): Promise<void> {
|
||||||
|
if (infoInFlight) return
|
||||||
|
infoInFlight = true
|
||||||
|
try {
|
||||||
|
await refreshPrinterInfo()
|
||||||
|
} finally {
|
||||||
|
infoInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTimers(): void {
|
||||||
|
if (statusTimer) {
|
||||||
|
clearInterval(statusTimer)
|
||||||
|
statusTimer = null
|
||||||
|
}
|
||||||
|
if (infoTimer) {
|
||||||
|
clearInterval(infoTimer)
|
||||||
|
infoTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimers(): void {
|
||||||
|
clearTimers()
|
||||||
|
statusTimer = setInterval(() => void tickStatus(), statusIntervalMs())
|
||||||
|
infoTimer = setInterval(() => void tickInfo(), INFO_MS)
|
||||||
|
void tickStatus()
|
||||||
|
void tickInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVisibilityChange(): void {
|
||||||
|
if (mountedCount <= 0) return
|
||||||
|
startTimers()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrinterStatusPoll(): void {
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
mountedCount += 1
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
if (appStore.initialized) startTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
mountedCount = Math.max(0, mountedCount - 1)
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||||
|
if (mountedCount === 0) clearTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => appStore.initialized,
|
||||||
|
(ready) => {
|
||||||
|
if (ready && mountedCount > 0) startTimers()
|
||||||
|
else if (!ready) clearTimers()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,27 +1,40 @@
|
|||||||
|
import type { ComposerTranslation } from 'vue-i18n'
|
||||||
|
|
||||||
export interface SelectOption {
|
export interface SelectOption {
|
||||||
label: string
|
label: string
|
||||||
value: string | number
|
value: string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const COPY_TYPE_OPTIONS: SelectOption[] = [
|
type T = ComposerTranslation
|
||||||
{ label: '文件拷贝', value: 0 },
|
|
||||||
{ label: '镜像刻录', value: 1 }
|
|
||||||
]
|
|
||||||
|
|
||||||
export const FORMAT_TYPE_OPTIONS: SelectOption[] = [
|
export function copyTypeOptions(t: T): SelectOption[] {
|
||||||
{ label: '不格式化', value: 'none' },
|
return [
|
||||||
{ label: '快速格式化', value: 'fat' },
|
{ label: t('copyType.fileCopy'), value: 0 },
|
||||||
{ label: '完全格式化', value: 'ntfs' }
|
{ label: t('copyType.imageBurn'), value: 1 }
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
|
||||||
export const PRIORITY_OPTIONS: SelectOption[] = [
|
export function formatTypeOptions(t: T): SelectOption[] {
|
||||||
{ label: '低', value: 'low' },
|
return [
|
||||||
{ label: '中', value: 'mid' },
|
{ label: t('formatType.none'), value: 'none' },
|
||||||
{ label: '高', value: 'high' }
|
{ label: t('formatType.fat32'), value: 'fat32' },
|
||||||
]
|
{ label: t('formatType.exfat'), value: 'exfat' },
|
||||||
|
{ label: t('formatType.ntfs'), value: 'ntfs' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
export const RIBBON_TYPE_OPTIONS: SelectOption[] = [
|
export function priorityOptions(t: T): SelectOption[] {
|
||||||
{ label: '任何', value: 'any' },
|
return [
|
||||||
|
{ label: t('common.low'), value: 'low' },
|
||||||
|
{ label: t('common.mid'), value: 'mid' },
|
||||||
|
{ label: t('common.high'), value: 'high' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ribbonTypeOptions(t: T): SelectOption[] {
|
||||||
|
return [
|
||||||
|
{ label: t('common.any'), value: 'any' },
|
||||||
{ label: 'YMCKO', value: 'YMCKO' },
|
{ label: 'YMCKO', value: 'YMCKO' },
|
||||||
{ label: 'YMCK', value: 'YMCK' }
|
{ label: 'YMCK', value: 'YMCK' }
|
||||||
]
|
]
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -14,6 +14,8 @@ interface CardsoonApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
/** 构建时由 electron.vite.config.ts 从 package.json version 注入 */
|
||||||
|
const __APP_VERSION__: string
|
||||||
interface Window {
|
interface Window {
|
||||||
cardsoonApi: CardsoonApi
|
cardsoonApi: CardsoonApi
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import zhCN from './locales/zh-CN'
|
||||||
|
import enUS from './locales/en-US'
|
||||||
|
import zhTW from './locales/zh-TW'
|
||||||
|
|
||||||
|
export type AppLocale = 'zh-CN' | 'en-US' | 'zh-TW'
|
||||||
|
|
||||||
|
export const LOCALE_OPTIONS: { value: AppLocale; label: string }[] = [
|
||||||
|
{ value: 'zh-CN', label: '中文' },
|
||||||
|
{ value: 'en-US', label: 'English' },
|
||||||
|
{ value: 'zh-TW', label: '繁體中文' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'app-locale'
|
||||||
|
|
||||||
|
function detectInitialLocale(): AppLocale {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY) as AppLocale | null
|
||||||
|
if (saved && LOCALE_OPTIONS.some((o) => o.value === saved)) return saved
|
||||||
|
} catch {
|
||||||
|
/* localStorage may be unavailable */
|
||||||
|
}
|
||||||
|
return 'zh-CN'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistLocale(locale: AppLocale): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, locale)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: detectInitialLocale(),
|
||||||
|
fallbackLocale: 'zh-CN',
|
||||||
|
messages: {
|
||||||
|
'zh-CN': zhCN,
|
||||||
|
'en-US': enUS,
|
||||||
|
'zh-TW': zhTW
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default i18n
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
export default {
|
||||||
|
common: {
|
||||||
|
cancel: 'Cancel',
|
||||||
|
confirm: 'OK',
|
||||||
|
close: 'Close',
|
||||||
|
addPath: 'Add Path',
|
||||||
|
clear: 'Clear',
|
||||||
|
submit: 'Submit',
|
||||||
|
home: 'Home',
|
||||||
|
stop: 'Stop',
|
||||||
|
back: 'Back',
|
||||||
|
reset: 'Reset',
|
||||||
|
select: 'Select',
|
||||||
|
resetPrinter: 'Reset Printer',
|
||||||
|
discardCard: 'Discard Card',
|
||||||
|
templateDesign: 'Template Design',
|
||||||
|
low: 'Low',
|
||||||
|
mid: 'Medium',
|
||||||
|
high: 'High',
|
||||||
|
any: 'Any'
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
modeHome: 'CardSoon Data Card Printer',
|
||||||
|
modeDistribute: 'Data Distribution Mode',
|
||||||
|
modeCollect: 'Data Collection Mode',
|
||||||
|
ribbon: 'Ribbon',
|
||||||
|
ribbonAmount: 'Remaining',
|
||||||
|
status: 'Status',
|
||||||
|
serialNo: 'Serial No.'
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
version: 'Version v{version}',
|
||||||
|
website: 'www.cardsoon.com',
|
||||||
|
copyright: 'Copyright © 2026 CardSoon Technology'
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
toolsTitle: 'Tools',
|
||||||
|
tasksTitle: 'Tasks',
|
||||||
|
readCard: 'Read',
|
||||||
|
ejectCard: 'Eject',
|
||||||
|
dataDistribute: 'Data Distribution',
|
||||||
|
dataDistributeDesc: 'Distribute data onto cards',
|
||||||
|
dataCollect: 'Data Collection',
|
||||||
|
dataCollectDesc: 'Collect data from cards'
|
||||||
|
},
|
||||||
|
distributeConfig: {
|
||||||
|
pathConfig: 'Path Configuration',
|
||||||
|
addNetworkLocation: 'Add Network Location',
|
||||||
|
pathHint: 'The system will copy all items under this directory, excluding the folder itself',
|
||||||
|
netCredConfigured: 'Network credentials configured: {hosts}',
|
||||||
|
volumeLabel: 'Volume Label',
|
||||||
|
copyType: 'Copy Type',
|
||||||
|
formatType: 'Format Type',
|
||||||
|
dongle: 'Dongle',
|
||||||
|
dongleHint: '(101 = unlimited)',
|
||||||
|
dongleAuthLabel: 'Auth Code',
|
||||||
|
donglePassword: 'Enter authorization code',
|
||||||
|
showAuth: 'Show auth code',
|
||||||
|
hideAuth: 'Hide auth code',
|
||||||
|
templatePreview: 'Label Preview',
|
||||||
|
addLabel: 'Add Label',
|
||||||
|
removeLabel: 'Remove',
|
||||||
|
templateRemoved: 'Label removed',
|
||||||
|
doubleSide: 'Double-sided',
|
||||||
|
singleSideHint: 'Single-side printer detected, please select a side (front or back)',
|
||||||
|
frontSide: 'Front',
|
||||||
|
backSide: 'Back',
|
||||||
|
imageFieldNotSelected: 'No image selected',
|
||||||
|
templateLoaded: 'Label template loaded',
|
||||||
|
templateNoPreview: 'Template opened, but no previewable content found',
|
||||||
|
cleared: 'Cleared, restored to initial state',
|
||||||
|
netCredSaved: 'Network credentials saved',
|
||||||
|
loadProgress: 'Loaded: {loaded} GB / {total} GB ({percent}%)',
|
||||||
|
calculating: 'Calculating…',
|
||||||
|
sizeUnknown: 'Size unknown',
|
||||||
|
pathInvalid: 'Invalid path',
|
||||||
|
pathReady: '{size} · Pending copy',
|
||||||
|
pathPattern: '{dir}\\*.*'
|
||||||
|
},
|
||||||
|
copyType: {
|
||||||
|
fileCopy: 'File Copy',
|
||||||
|
imageBurn: 'Image Burn'
|
||||||
|
},
|
||||||
|
formatType: {
|
||||||
|
none: 'No Format',
|
||||||
|
fat32: 'Fat32',
|
||||||
|
exfat: 'exFat',
|
||||||
|
ntfs: 'NTFS'
|
||||||
|
},
|
||||||
|
dataCollect: {
|
||||||
|
importPath: 'Data Import Path',
|
||||||
|
cardOutput: 'Card Output Direction',
|
||||||
|
forwardOutput: 'Forward Output',
|
||||||
|
backwardOutput: 'Backward Output',
|
||||||
|
noDirSelected: 'No directory selected'
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
taskFailed: 'Task Failed',
|
||||||
|
taskCompleted: 'Task Completed',
|
||||||
|
collectCompleted: 'Collection Completed',
|
||||||
|
dataCollecting: 'Collecting Data',
|
||||||
|
taskRunning: 'Task Running',
|
||||||
|
waitingCard: 'Waiting for Card',
|
||||||
|
failedSubCollect: 'Please check the reader and card, insert a backup card to retry',
|
||||||
|
failedSubDistribute: 'Please check device issues, insert a backup card to retry',
|
||||||
|
completedSubCollect: 'Click Back, or insert a backup card to continue',
|
||||||
|
completedSubDistribute: 'Click Back, or insert a backup card to start next',
|
||||||
|
collectingHint: 'Reading from card and writing to import directory',
|
||||||
|
waitCardHint: 'Please insert a data card, the task will run automatically',
|
||||||
|
runningHint: 'Task in progress, please wait',
|
||||||
|
progressCounter: 'Task completed {success} times, with {fail} failures.'
|
||||||
|
},
|
||||||
|
networkDialog: {
|
||||||
|
title: 'Add Network Location',
|
||||||
|
hint: 'Credentials are used to access mapped drives or network shares. Please confirm the host is accessible and the account is valid.',
|
||||||
|
host: 'Host (IP or Hostname)',
|
||||||
|
share: 'Share Name (optional)',
|
||||||
|
userName: 'Username',
|
||||||
|
password: 'Password',
|
||||||
|
targetUNC: 'Target UNC',
|
||||||
|
hostPlaceholder: 'e.g. 192.168.1.100 or nas-server',
|
||||||
|
sharePlaceholder: 'e.g. share, leave empty to mount root only',
|
||||||
|
userNamePlaceholder: 'e.g. admin',
|
||||||
|
errHostInvalid: 'Invalid hostname/IP',
|
||||||
|
errUserName: 'Please enter username',
|
||||||
|
errPassword: 'Please enter password'
|
||||||
|
},
|
||||||
|
workflow: {
|
||||||
|
taskPrep: 'Task Preparation',
|
||||||
|
copyData: 'Copy Data',
|
||||||
|
printCard: 'Print Card',
|
||||||
|
complete: 'Complete'
|
||||||
|
},
|
||||||
|
notify: {
|
||||||
|
resetSent: 'Reset command sent',
|
||||||
|
resetFailed: 'Reset failed',
|
||||||
|
rejectUnavailable: 'Discard card API not supported in current environment',
|
||||||
|
cardRejected: 'Card discarded',
|
||||||
|
readCardSent: 'Card moved to reader',
|
||||||
|
readCardFailed: 'Read card failed',
|
||||||
|
ejectCardSent: 'Card ejected to outlet',
|
||||||
|
ejectCardFailed: 'Eject card failed',
|
||||||
|
operationFailed: 'Operation failed',
|
||||||
|
designStarted: 'Design app started',
|
||||||
|
designFailed: 'Failed to open design app, please check cardsoon.config.json',
|
||||||
|
designCancelled: 'Failed to open design app, may have been cancelled or requires admin privileges',
|
||||||
|
notReady: 'System not ready, please restart the app or check printer and task directory',
|
||||||
|
notReadyAction: 'System not ready, cannot {action}',
|
||||||
|
stopDistribute: 'Please stop the data distribution task first',
|
||||||
|
usbCollecting: 'USB collection in progress, please wait',
|
||||||
|
usbCollectInProgress: 'Data collection in progress, please stop it on the task page first',
|
||||||
|
usbCollectComplete: 'USB collection completed',
|
||||||
|
pathOpenFailed: 'Failed to open directory picker',
|
||||||
|
templateOpenFailed: 'Failed to open template picker',
|
||||||
|
templateParseFailed: 'Template parsing failed',
|
||||||
|
dirInvalid: 'Import directory invalid, cannot continue',
|
||||||
|
resubmitCollectFailed: 'Failed to resubmit collection task',
|
||||||
|
resubmitFailed: 'Resubmit failed',
|
||||||
|
pollStartFailed: 'Failed to start task polling',
|
||||||
|
pollMonitorFailed: 'Cannot monitor card position, please click Back or resubmit manually',
|
||||||
|
queryJobFailed: 'Query task failed: {code}',
|
||||||
|
queryUsbFailed: 'Query USB task failed: {code}',
|
||||||
|
cancelIssue: 'Issue occurred while cancelling task',
|
||||||
|
selectDirFirst: 'Please select a data import directory first',
|
||||||
|
selectDirFirstShort: 'Please select a data import directory first',
|
||||||
|
startUsbFailed: 'Failed to start USB collection',
|
||||||
|
usbWaitComplete: 'Please wait for USB collection to complete',
|
||||||
|
cannotEnterRunning: 'Cannot enter running page, task cancelled',
|
||||||
|
initFailedHint: 'Initialization failed, please check task directory permissions'
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
noTask: 'Please configure a copy path or print template',
|
||||||
|
invalidTemplate: 'Please select a .soon template',
|
||||||
|
pathEmpty: 'Path cannot be empty',
|
||||||
|
pathNotExist: 'Path does not exist: {paths}',
|
||||||
|
noFilesToCopy: 'No files to copy under the path',
|
||||||
|
netCredMissing: 'Please configure network location credentials first: {host}',
|
||||||
|
templateNotExist: 'Template file does not exist',
|
||||||
|
printFlagMismatch: 'Print side mismatch, please reselect',
|
||||||
|
singleSideMustPick: 'Single-side printer, please select front or back',
|
||||||
|
pickSideRequired: 'Please select a print side (front or back) before submitting',
|
||||||
|
printerNotConnected: 'Printer not connected, please connect the printer first',
|
||||||
|
printerNotReady: 'Printer not connected or in fault (e.g. card jam). Please fix the device before submitting',
|
||||||
|
dongleAuthRequired: 'Please enter authorization code',
|
||||||
|
dongleCountInvalid: 'Dongle count must be 0 or 1-101',
|
||||||
|
createJobFailed: 'Failed to create task',
|
||||||
|
csvGenFailed: 'Failed to generate print variable CSV'
|
||||||
|
},
|
||||||
|
printerStatus: {
|
||||||
|
idle: 'Idle',
|
||||||
|
busy: 'Busy',
|
||||||
|
printing: 'Printing',
|
||||||
|
notConnected: 'Printer not connected',
|
||||||
|
notInitialized: 'Not initialized',
|
||||||
|
initFailed: 'Initialization failed',
|
||||||
|
ready: 'Ready',
|
||||||
|
unknown: '—'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
export default {
|
||||||
|
common: {
|
||||||
|
cancel: '取消',
|
||||||
|
confirm: '确定',
|
||||||
|
close: '关闭',
|
||||||
|
addPath: '添加路径',
|
||||||
|
clear: '清空',
|
||||||
|
submit: '提交',
|
||||||
|
home: '首页',
|
||||||
|
stop: '停止',
|
||||||
|
back: '返回',
|
||||||
|
reset: '重置',
|
||||||
|
select: '选择',
|
||||||
|
resetPrinter: '重置打印机',
|
||||||
|
discardCard: '废弃卡片',
|
||||||
|
templateDesign: '模板设计',
|
||||||
|
low: '低',
|
||||||
|
mid: '中',
|
||||||
|
high: '高',
|
||||||
|
any: '任何'
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
modeHome: '卡树数据卡打印机软件',
|
||||||
|
modeDistribute: '数据分发模式',
|
||||||
|
modeCollect: '数据收集模式',
|
||||||
|
ribbon: '色带',
|
||||||
|
ribbonAmount: '余量',
|
||||||
|
status: '状态',
|
||||||
|
serialNo: '序列号'
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
version: '版本 v{version}',
|
||||||
|
website: 'www.cardsoon.com',
|
||||||
|
copyright: '版权所有 © 2026 卡树科技'
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
toolsTitle: '工具',
|
||||||
|
tasksTitle: '任务',
|
||||||
|
readCard: '读卡',
|
||||||
|
ejectCard: '退卡',
|
||||||
|
dataDistribute: '数据分发',
|
||||||
|
dataDistributeDesc: '分发数据到打印卡片',
|
||||||
|
dataCollect: '数据收集',
|
||||||
|
dataCollectDesc: '从卡片收集导入数据'
|
||||||
|
},
|
||||||
|
distributeConfig: {
|
||||||
|
pathConfig: '路径配置',
|
||||||
|
addNetworkLocation: '添加网络位置',
|
||||||
|
pathHint: '系统将拷贝该目录下的所有子项,但不包含文件夹本身',
|
||||||
|
netCredConfigured: '已配置网络凭据:{hosts}',
|
||||||
|
volumeLabel: '卷标',
|
||||||
|
copyType: '拷贝类型',
|
||||||
|
formatType: '格式化类型',
|
||||||
|
dongle: '加密狗',
|
||||||
|
dongleHint: '(101 为不限次数)',
|
||||||
|
dongleAuthLabel: '授权码',
|
||||||
|
donglePassword: '请输入授权码',
|
||||||
|
showAuth: '显示授权码',
|
||||||
|
hideAuth: '隐藏授权码',
|
||||||
|
templatePreview: '标签预览',
|
||||||
|
addLabel: '添加标签',
|
||||||
|
removeLabel: '移除标签',
|
||||||
|
templateRemoved: '已移除标签',
|
||||||
|
doubleSide: '双面',
|
||||||
|
singleSideHint: '当前为单面打印机,请选择打印面(正面或背面)',
|
||||||
|
frontSide: '正面',
|
||||||
|
backSide: '背面',
|
||||||
|
imageFieldNotSelected: '未选择图片',
|
||||||
|
templateLoaded: '已加载标签模板',
|
||||||
|
templateNoPreview: '模板已打开,但未解析到可预览内容',
|
||||||
|
cleared: '已清空,已恢复初始状态',
|
||||||
|
netCredSaved: '网络凭据已保存',
|
||||||
|
loadProgress: '已加载: {loaded} GB / {total} GB ({percent}%)',
|
||||||
|
calculating: '计算中…',
|
||||||
|
sizeUnknown: '大小未知',
|
||||||
|
pathInvalid: '路径无效',
|
||||||
|
pathReady: '{size} · 待拷贝',
|
||||||
|
pathPattern: '{dir}\\*.*'
|
||||||
|
},
|
||||||
|
copyType: {
|
||||||
|
fileCopy: '文件拷贝',
|
||||||
|
imageBurn: '镜像刻录'
|
||||||
|
},
|
||||||
|
formatType: {
|
||||||
|
none: '不格式化',
|
||||||
|
fat32: 'Fat32',
|
||||||
|
exfat: 'exFat',
|
||||||
|
ntfs: 'NTFS'
|
||||||
|
},
|
||||||
|
dataCollect: {
|
||||||
|
importPath: '数据导入地址',
|
||||||
|
cardOutput: '出卡方向',
|
||||||
|
forwardOutput: '向前出卡',
|
||||||
|
backwardOutput: '向后出卡',
|
||||||
|
noDirSelected: '未选择目录'
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
taskFailed: '任务失败',
|
||||||
|
taskCompleted: '任务已完成',
|
||||||
|
collectCompleted: '收集已完成',
|
||||||
|
dataCollecting: '数据收集中',
|
||||||
|
taskRunning: '任务执行中',
|
||||||
|
waitingCard: '等待插卡',
|
||||||
|
failedSubCollect: '请检查读卡器与卡片,插入备卡位可自动重试',
|
||||||
|
failedSubDistribute: '请检查设备故障,插入备卡位可自动重试',
|
||||||
|
completedSubCollect: '请点击返回,或插入备卡位继续下一张',
|
||||||
|
completedSubDistribute: '请点击返回,或插入备卡位自动开始下一张',
|
||||||
|
collectingHint: '正在从卡片读取并写入导入目录',
|
||||||
|
waitCardHint: '请插入数据卡,任务将自动执行',
|
||||||
|
runningHint: '任务执行中,请稍候',
|
||||||
|
progressCounter: '任务已经完成{success}次,其中失败次数是{fail}。'
|
||||||
|
},
|
||||||
|
networkDialog: {
|
||||||
|
title: '添加网络位置',
|
||||||
|
hint: '凭据用于访问已添加的映射盘或网络共享路径,请确认主机可访问且账号有效。',
|
||||||
|
host: '主机(IP 或主机名)',
|
||||||
|
share: '共享名(可选)',
|
||||||
|
userName: '用户名',
|
||||||
|
password: '密码',
|
||||||
|
targetUNC: '目标 UNC',
|
||||||
|
hostPlaceholder: '例如 192.168.1.100 或 nas-server',
|
||||||
|
sharePlaceholder: '例如 share,留空表示只挂载到根',
|
||||||
|
userNamePlaceholder: '例如 admin',
|
||||||
|
errHostInvalid: '主机名/IP 不合法',
|
||||||
|
errUserName: '请输入用户名',
|
||||||
|
errPassword: '请输入密码'
|
||||||
|
},
|
||||||
|
workflow: {
|
||||||
|
taskPrep: '任务准备',
|
||||||
|
copyData: '拷贝数据',
|
||||||
|
printCard: '打印卡片',
|
||||||
|
complete: '完成'
|
||||||
|
},
|
||||||
|
notify: {
|
||||||
|
resetSent: '已发送重置指令',
|
||||||
|
resetFailed: '重置失败',
|
||||||
|
rejectUnavailable: '当前环境不支持废卡接口',
|
||||||
|
cardRejected: '已废弃卡片',
|
||||||
|
readCardSent: '已送卡到读卡区',
|
||||||
|
readCardFailed: '读卡失败',
|
||||||
|
ejectCardSent: '已退卡到出卡区',
|
||||||
|
ejectCardFailed: '退卡失败',
|
||||||
|
operationFailed: '操作失败',
|
||||||
|
designStarted: '已启动设计软件',
|
||||||
|
designFailed: '打开设计软件失败,请检查 cardsoon.config.json',
|
||||||
|
designCancelled: '打开设计软件失败,可能已被取消或需要管理员权限',
|
||||||
|
notReady: '系统未就绪,请重启应用或检查打印机与任务目录',
|
||||||
|
notReadyAction: '系统未就绪,无法{action}',
|
||||||
|
stopDistribute: '请先停止数据分发任务',
|
||||||
|
usbCollecting: 'USB 收集进行中,请等待完成',
|
||||||
|
usbCollectInProgress: '数据收集进行中,请先在任务页停止',
|
||||||
|
usbCollectComplete: 'USB 收集完成',
|
||||||
|
pathOpenFailed: '打开目录选择失败',
|
||||||
|
templateOpenFailed: '打开模板选择失败',
|
||||||
|
templateParseFailed: '模板解析失败',
|
||||||
|
dirInvalid: '导入目录无效,无法继续',
|
||||||
|
resubmitCollectFailed: '重新提交收集任务失败',
|
||||||
|
resubmitFailed: '重新提交失败',
|
||||||
|
pollStartFailed: '启动任务轮询失败',
|
||||||
|
pollMonitorFailed: '无法监控卡位,请手动点击返回或重新提交',
|
||||||
|
queryJobFailed: '查询任务失败: {code}',
|
||||||
|
queryUsbFailed: '查询 USB 任务失败: {code}',
|
||||||
|
cancelIssue: '取消任务时出现问题',
|
||||||
|
selectDirFirst: '请先选择数据导入目录',
|
||||||
|
selectDirFirstShort: '请先选择数据导入目录',
|
||||||
|
startUsbFailed: '启动 USB 收集失败',
|
||||||
|
usbWaitComplete: '请先等待 USB 收集完成',
|
||||||
|
cannotEnterRunning: '无法进入运行页,已取消任务',
|
||||||
|
initFailedHint: '初始化失败,请检查任务目录权限',
|
||||||
|
waitTimeoutCollect: '等待时间较长:请检查 U 盘数据线是否连接、卡片是否插好(可把卡插反后重试)',
|
||||||
|
waitTimeoutDistribute: '等待进卡时间较长:请检查卡片是否到位、打印机是否夹卡或报错'
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
noTask: '请配置拷贝路径或打印模板',
|
||||||
|
invalidTemplate: '请选择 .soon 模板',
|
||||||
|
pathEmpty: '路径不能为空',
|
||||||
|
pathNotExist: '路径不存在: {paths}',
|
||||||
|
noFilesToCopy: '拷贝路径下没有可拷贝的文件',
|
||||||
|
netCredMissing: '请先配置网络位置凭据: {host}',
|
||||||
|
templateNotExist: '模板文件不存在',
|
||||||
|
printFlagMismatch: '打印面数不匹配,请重新选择',
|
||||||
|
singleSideMustPick: '当前为单面打印机,请选择正面或背面',
|
||||||
|
pickSideRequired: '请选择打印面(正面或背面)后再提交',
|
||||||
|
printerNotConnected: '打印机未连接,请先连接打印机后再提交',
|
||||||
|
printerNotReady: '打印机未连接或设备异常(如夹卡),请排除故障后再提交',
|
||||||
|
dongleAuthRequired: '请输入授权码',
|
||||||
|
dongleCountInvalid: '加密狗次数须为 0 或 1-101',
|
||||||
|
createJobFailed: '创建任务失败',
|
||||||
|
csvGenFailed: '生成打印变量 CSV 失败'
|
||||||
|
},
|
||||||
|
printerStatus: {
|
||||||
|
idle: '空闲',
|
||||||
|
busy: '忙碌',
|
||||||
|
printing: '正在打印',
|
||||||
|
notConnected: '未连接打印机',
|
||||||
|
notInitialized: '未初始化',
|
||||||
|
initFailed: '初始化失败',
|
||||||
|
ready: '就绪',
|
||||||
|
unknown: '—'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
export default {
|
||||||
|
common: {
|
||||||
|
cancel: '取消',
|
||||||
|
confirm: '確定',
|
||||||
|
close: '關閉',
|
||||||
|
addPath: '新增路徑',
|
||||||
|
clear: '清空',
|
||||||
|
submit: '提交',
|
||||||
|
home: '首頁',
|
||||||
|
stop: '停止',
|
||||||
|
back: '返回',
|
||||||
|
reset: '重設',
|
||||||
|
select: '選擇',
|
||||||
|
resetPrinter: '重設印表機',
|
||||||
|
discardCard: '廢棄卡片',
|
||||||
|
templateDesign: '模板設計',
|
||||||
|
low: '低',
|
||||||
|
mid: '中',
|
||||||
|
high: '高',
|
||||||
|
any: '任何'
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
modeHome: '卡樹數據卡印表機軟體',
|
||||||
|
modeDistribute: '數據分發模式',
|
||||||
|
modeCollect: '數據收集模式',
|
||||||
|
ribbon: '色帶',
|
||||||
|
ribbonAmount: '餘量',
|
||||||
|
status: '狀態',
|
||||||
|
serialNo: '序號'
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
version: '版本 v{version}',
|
||||||
|
website: 'www.cardsoon.com',
|
||||||
|
copyright: '版權所有 © 2026 卡樹科技'
|
||||||
|
},
|
||||||
|
home: {
|
||||||
|
toolsTitle: '工具',
|
||||||
|
tasksTitle: '任務',
|
||||||
|
readCard: '讀卡',
|
||||||
|
ejectCard: '退卡',
|
||||||
|
dataDistribute: '數據分發',
|
||||||
|
dataDistributeDesc: '分發數據到列印卡片',
|
||||||
|
dataCollect: '數據收集',
|
||||||
|
dataCollectDesc: '從卡片收集匯入數據'
|
||||||
|
},
|
||||||
|
distributeConfig: {
|
||||||
|
pathConfig: '路徑配置',
|
||||||
|
addNetworkLocation: '新增網路位置',
|
||||||
|
pathHint: '系統將拷貝該目錄下的所有子項,但不包含資料夾本身',
|
||||||
|
netCredConfigured: '已配置網路憑證:{hosts}',
|
||||||
|
volumeLabel: '卷標',
|
||||||
|
copyType: '拷貝類型',
|
||||||
|
formatType: '格式化類型',
|
||||||
|
dongle: '加密狗',
|
||||||
|
dongleHint: '(101 為不限次數)',
|
||||||
|
dongleAuthLabel: '授權碼',
|
||||||
|
donglePassword: '請輸入授權碼',
|
||||||
|
showAuth: '顯示授權碼',
|
||||||
|
hideAuth: '隱藏授權碼',
|
||||||
|
templatePreview: '標籤預覽',
|
||||||
|
addLabel: '新增標籤',
|
||||||
|
removeLabel: '移除標籤',
|
||||||
|
templateRemoved: '已移除標籤',
|
||||||
|
doubleSide: '雙面',
|
||||||
|
singleSideHint: '當前為單面印表機,請選擇列印面(正面或背面)',
|
||||||
|
frontSide: '正面',
|
||||||
|
backSide: '背面',
|
||||||
|
imageFieldNotSelected: '未選擇圖片',
|
||||||
|
templateLoaded: '已載入標籤模板',
|
||||||
|
templateNoPreview: '模板已開啟,但未解析到可預覽內容',
|
||||||
|
cleared: '已清空,已恢復初始狀態',
|
||||||
|
netCredSaved: '網路憑證已儲存',
|
||||||
|
loadProgress: '已載入: {loaded} GB / {total} GB ({percent}%)',
|
||||||
|
calculating: '計算中…',
|
||||||
|
sizeUnknown: '大小未知',
|
||||||
|
pathInvalid: '路徑無效',
|
||||||
|
pathReady: '{size} · 待拷貝',
|
||||||
|
pathPattern: '{dir}\\*.*'
|
||||||
|
},
|
||||||
|
copyType: {
|
||||||
|
fileCopy: '檔案拷貝',
|
||||||
|
imageBurn: '鏡像燒錄'
|
||||||
|
},
|
||||||
|
formatType: {
|
||||||
|
none: '不格式化',
|
||||||
|
fat32: 'Fat32',
|
||||||
|
exfat: 'exFat',
|
||||||
|
ntfs: 'NTFS'
|
||||||
|
},
|
||||||
|
dataCollect: {
|
||||||
|
importPath: '數據匯入地址',
|
||||||
|
cardOutput: '出卡方向',
|
||||||
|
forwardOutput: '向前出卡',
|
||||||
|
backwardOutput: '向後出卡',
|
||||||
|
noDirSelected: '未選擇目錄'
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
taskFailed: '任務失敗',
|
||||||
|
taskCompleted: '任務已完成',
|
||||||
|
collectCompleted: '收集已完成',
|
||||||
|
dataCollecting: '數據收集中',
|
||||||
|
taskRunning: '任務執行中',
|
||||||
|
waitingCard: '等待插卡',
|
||||||
|
failedSubCollect: '請檢查讀卡器與卡片,插入備卡位可自動重試',
|
||||||
|
failedSubDistribute: '請檢查設備故障,插入備卡位可自動重試',
|
||||||
|
completedSubCollect: '請點擊返回,或插入備卡位繼續下一張',
|
||||||
|
completedSubDistribute: '請點擊返回,或插入備卡位自動開始下一張',
|
||||||
|
collectingHint: '正在從卡片讀取並寫入匯入目錄',
|
||||||
|
waitCardHint: '請插入數據卡,任務將自動執行',
|
||||||
|
runningHint: '任務執行中,請稍候',
|
||||||
|
progressCounter: '任務已經完成{success}次,其中失敗次數是{fail}。'
|
||||||
|
},
|
||||||
|
networkDialog: {
|
||||||
|
title: '新增網路位置',
|
||||||
|
hint: '憑證用於存取已新增的映射盤或網路共享路徑,請確認主機可存取且帳號有效。',
|
||||||
|
host: '主機(IP 或主機名)',
|
||||||
|
share: '共享名(可選)',
|
||||||
|
userName: '使用者名稱',
|
||||||
|
password: '密碼',
|
||||||
|
targetUNC: '目標 UNC',
|
||||||
|
hostPlaceholder: '例如 192.168.1.100 或 nas-server',
|
||||||
|
sharePlaceholder: '例如 share,留空表示只掛載到根',
|
||||||
|
userNamePlaceholder: '例如 admin',
|
||||||
|
errHostInvalid: '主機名/IP 不合法',
|
||||||
|
errUserName: '請輸入使用者名稱',
|
||||||
|
errPassword: '請輸入密碼'
|
||||||
|
},
|
||||||
|
workflow: {
|
||||||
|
taskPrep: '任務準備',
|
||||||
|
copyData: '拷貝數據',
|
||||||
|
printCard: '列印卡片',
|
||||||
|
complete: '完成'
|
||||||
|
},
|
||||||
|
notify: {
|
||||||
|
resetSent: '已發送重設指令',
|
||||||
|
resetFailed: '重設失敗',
|
||||||
|
rejectUnavailable: '當前環境不支援廢卡介面',
|
||||||
|
cardRejected: '已廢棄卡片',
|
||||||
|
readCardSent: '已送卡到讀卡區',
|
||||||
|
readCardFailed: '讀卡失敗',
|
||||||
|
ejectCardSent: '已退卡到出卡區',
|
||||||
|
ejectCardFailed: '退卡失敗',
|
||||||
|
operationFailed: '操作失敗',
|
||||||
|
designStarted: '已啟動設計軟體',
|
||||||
|
designFailed: '開啟設計軟體失敗,請檢查 cardsoon.config.json',
|
||||||
|
designCancelled: '開啟設計軟體失敗,可能已被取消或需要管理員權限',
|
||||||
|
notReady: '系統未就緒,請重啟應用或檢查印表機與任務目錄',
|
||||||
|
notReadyAction: '系統未就緒,無法{action}',
|
||||||
|
stopDistribute: '請先停止數據分發任務',
|
||||||
|
usbCollecting: 'USB 收集進行中,請等待完成',
|
||||||
|
usbCollectInProgress: '數據收集進行中,請先在任務頁停止',
|
||||||
|
usbCollectComplete: 'USB 收集完成',
|
||||||
|
pathOpenFailed: '開啟目錄選擇失敗',
|
||||||
|
templateOpenFailed: '開啟模板選擇失敗',
|
||||||
|
templateParseFailed: '模板解析失敗',
|
||||||
|
dirInvalid: '匯入目錄無效,無法繼續',
|
||||||
|
resubmitCollectFailed: '重新提交收集任務失敗',
|
||||||
|
resubmitFailed: '重新提交失敗',
|
||||||
|
pollStartFailed: '啟動任務輪詢失敗',
|
||||||
|
pollMonitorFailed: '無法監控卡位,請手動點擊返回或重新提交',
|
||||||
|
queryJobFailed: '查詢任務失敗: {code}',
|
||||||
|
queryUsbFailed: '查詢 USB 任務失敗: {code}',
|
||||||
|
cancelIssue: '取消任務時出現問題',
|
||||||
|
selectDirFirst: '請先選擇數據匯入目錄',
|
||||||
|
selectDirFirstShort: '請先選擇數據匯入目錄',
|
||||||
|
startUsbFailed: '啟動 USB 收集失敗',
|
||||||
|
usbWaitComplete: '請先等待 USB 收集完成',
|
||||||
|
cannotEnterRunning: '無法進入運行頁,已取消任務',
|
||||||
|
initFailedHint: '初始化失敗,請檢查任務目錄權限'
|
||||||
|
},
|
||||||
|
validation: {
|
||||||
|
noTask: '請配置拷貝路徑或列印模板',
|
||||||
|
invalidTemplate: '請選擇 .soon 模板',
|
||||||
|
pathEmpty: '路徑不能為空',
|
||||||
|
pathNotExist: '路徑不存在: {paths}',
|
||||||
|
noFilesToCopy: '拷貝路徑下沒有可拷貝的檔案',
|
||||||
|
netCredMissing: '請先配置網路位置憑證: {host}',
|
||||||
|
templateNotExist: '模板檔案不存在',
|
||||||
|
printFlagMismatch: '列印面數不匹配,請重新選擇',
|
||||||
|
singleSideMustPick: '當前為單面印表機,請選擇正面或背面',
|
||||||
|
pickSideRequired: '請選擇列印面(正面或背面)後再提交',
|
||||||
|
printerNotConnected: '印表機未連接,請先連接印表機後再提交',
|
||||||
|
printerNotReady: '印表機未連接或設備異常(如夾卡),請排除故障後再提交',
|
||||||
|
dongleAuthRequired: '請輸入授權碼',
|
||||||
|
dongleCountInvalid: '加密狗次數須為 0 或 1-101',
|
||||||
|
createJobFailed: '建立任務失敗',
|
||||||
|
csvGenFailed: '生成列印變數 CSV 失敗'
|
||||||
|
},
|
||||||
|
printerStatus: {
|
||||||
|
idle: '空閒',
|
||||||
|
busy: '忙碌',
|
||||||
|
printing: '正在列印',
|
||||||
|
notConnected: '未連接印表機',
|
||||||
|
notInitialized: '未初始化',
|
||||||
|
initFailed: '初始化失敗',
|
||||||
|
ready: '就緒',
|
||||||
|
unknown: '—'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,115 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
import i18n from '@/i18n'
|
||||||
|
|
||||||
|
import { configGet } from '@/api/cardsoon'
|
||||||
|
|
||||||
|
import { useNetworkAuthStore } from '@/stores/networkAuth'
|
||||||
|
|
||||||
|
import { useDongleAuthStore } from '@/stores/dongleAuth'
|
||||||
|
|
||||||
|
import { useDistributeFormStore } from '@/stores/distributeForm'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|
||||||
import './styles/design-base.css'
|
import './styles/design-base.css'
|
||||||
|
|
||||||
import './styles/icons-font.css'
|
import './styles/icons-font.css'
|
||||||
|
|
||||||
import './styles/shell.css'
|
import './styles/shell.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
|
||||||
app.use(createPinia())
|
|
||||||
app.use(router)
|
// preload 未注入时(如普通浏览器调试/ preload 加载失败)不再白屏,直接跳过桌面侧能力
|
||||||
app.mount('#app')
|
const hasApi = typeof window.cardsoonApi !== 'undefined'
|
||||||
|
|
||||||
|
if (hasApi) {
|
||||||
|
window.cardsoonApi.on('app:trace', (payload) => {
|
||||||
|
const p = payload as { level: string; message: string; data?: Record<string, unknown> }
|
||||||
|
if (p.level === 'error') console.error(p.message, p.data ?? '')
|
||||||
|
else console.log(p.message, p.data ?? '')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setTrace(on: boolean): Promise<void> {
|
||||||
|
await window.cardsoonApi.invoke('config:set', { traceEnabled: on })
|
||||||
|
console.info(`[trace] 控制台日志已${on ? '开启' : '关闭'}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = window as Window & { trace?: (on?: boolean) => Promise<void>; dllTrace?: (on?: boolean) => Promise<void> }
|
||||||
|
|
||||||
|
if (hasApi) {
|
||||||
|
w.trace = async (on = true) => setTrace(on)
|
||||||
|
w.dllTrace = w.trace
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const cfg = await configGet()
|
||||||
|
const on = cfg.ok && cfg.data?.traceEnabled === true
|
||||||
|
console.info(`[trace] 控制台日志: ${on ? '已开启' : '已关闭'},执行 trace(false) 关闭`)
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渲染进程启动崩溃兜底:把白屏变成可见错误面板,
|
||||||
|
* 便于在无法打开 DevTools 的现场截图定位问题。
|
||||||
|
* 仅在 Vue 挂载前/挂载瞬间触发的错误会覆盖界面;
|
||||||
|
* 挂载完成后的运行时错误只进控制台,不破坏已有界面。
|
||||||
|
*/
|
||||||
|
let appMounted = false
|
||||||
|
|
||||||
|
function showFatalError(stage: string, err: unknown): void {
|
||||||
|
if (appMounted) {
|
||||||
|
console.error(`[renderer:${stage}]`, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const el = document.getElementById('app')
|
||||||
|
if (!el) return
|
||||||
|
const detail = err instanceof Error ? `${err.message}\n\n${err.stack ?? ''}` : String(err)
|
||||||
|
el.innerHTML = `<pre style="margin:16px;padding:16px;background:#fff5f5;border:1px solid #feb2b2;border-radius:8px;color:#c53030;font-size:13px;white-space:pre-wrap;word-break:break-all;">[renderer:${stage}]\n${detail}</pre>`
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
showFatalError('window.onerror', e.error ?? e.message)
|
||||||
|
})
|
||||||
|
window.addEventListener('unhandledrejection', (e) => {
|
||||||
|
showFatalError('unhandledrejection', e.reason)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.config.errorHandler = (err, _instance, info) => {
|
||||||
|
console.error('[vue errorHandler]', err, info)
|
||||||
|
if (!appMounted) showFatalError(`vue:${info}`, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
app.use(pinia)
|
||||||
|
|
||||||
|
app.use(i18n)
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
await useNetworkAuthStore().loadFromStorage()
|
||||||
|
|
||||||
|
const dongleStore = useDongleAuthStore()
|
||||||
|
|
||||||
|
await dongleStore.loadFromSecrets()
|
||||||
|
|
||||||
|
useDistributeFormStore().dongleAuthCode = dongleStore.authCode
|
||||||
|
})()
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
|
appMounted = true
|
||||||
|
} catch (err) {
|
||||||
|
showFatalError('bootstrap', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
|
||||||
import { useJobStore } from '@/stores/job'
|
|
||||||
|
|
||||||
const CIRCLE_LEN = 283
|
|
||||||
|
|
||||||
export function useMockJobPoll() {
|
|
||||||
const progress = ref(0)
|
|
||||||
const jobStore = useJobStore()
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
const strokeOffset = ref(CIRCLE_LEN)
|
|
||||||
|
|
||||||
function tick() {
|
|
||||||
progress.value = Math.min(100, progress.value + 8)
|
|
||||||
strokeOffset.value = CIRCLE_LEN - (CIRCLE_LEN * progress.value) / 100
|
|
||||||
if (progress.value >= 100) {
|
|
||||||
jobStore.successCount += 1
|
|
||||||
progress.value = 0
|
|
||||||
strokeOffset.value = CIRCLE_LEN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
timer = setInterval(tick, 1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (timer) clearInterval(timer)
|
|
||||||
})
|
|
||||||
|
|
||||||
return { progress, strokeOffset }
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { PrinterStatusDisplay } from '@/types/printer'
|
|
||||||
|
|
||||||
/** 阶段一 Header 展示;阶段二由 GetPrinterInfo 替换 */
|
|
||||||
export const mockPrinterStatus: PrinterStatusDisplay = {
|
|
||||||
ribbonType: 'YMCKO',
|
|
||||||
statusText: '50/300',
|
|
||||||
serialNo: 'S103B29035',
|
|
||||||
printedCount: 190
|
|
||||||
}
|
|
||||||
@@ -7,15 +7,11 @@ export function setupRouterGuards(router: Router): void {
|
|||||||
const job = useJobStore()
|
const job = useJobStore()
|
||||||
const app = useAppStore()
|
const app = useAppStore()
|
||||||
|
|
||||||
if (to.path === '/distribute/running' && !job.jobId) {
|
if (to.path === '/distribute/running' && !job.jobId && app.mode !== 'distributing') {
|
||||||
return { path: '/distribute/config' }
|
return { path: '/distribute/config' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.path === '/distribute/failed' && job.failCount === 0) {
|
if (to.path === '/collect/running' && app.mode !== 'usbCopying' && app.mode !== 'ready') {
|
||||||
return { path: '/distribute/config' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (to.path === '/collect/running' && app.mode !== 'usbCopying') {
|
|
||||||
return { path: '/collect' }
|
return { path: '/collect' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,21 +19,21 @@ export function setupRouterGuards(router: Router): void {
|
|||||||
return { path: '/home' }
|
return { path: '/home' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app.mode === 'usbCopying') {
|
if (app.mode === 'usbCopying' && to.path.startsWith('/distribute')) {
|
||||||
if (to.path.startsWith('/distribute')) return { path: '/collect/running' }
|
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' && app.mode === 'distributing') {
|
||||||
if (to.path !== '/distribute/config') {
|
if (to.path === '/distribute/config') return true
|
||||||
app.setMode('ready')
|
|
||||||
return { path: '/distribute/config' }
|
return { path: '/distribute/config' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (from.path === '/collect/running' && app.mode === 'usbCopying') {
|
||||||
|
const allowed = ['/collect/running', '/collect', '/home']
|
||||||
|
if (!allowed.includes(to.path)) return { path: '/collect/running' }
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,11 +16,6 @@ const router = createRouter({
|
|||||||
name: 'distribute-running',
|
name: 'distribute-running',
|
||||||
component: () => import('@/views/DistributeRunningView.vue')
|
component: () => import('@/views/DistributeRunningView.vue')
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/distribute/failed',
|
|
||||||
name: 'distribute-failed',
|
|
||||||
component: () => import('@/views/DistributeFailedView.vue')
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/collect/running',
|
path: '/collect/running',
|
||||||
name: 'collect-running',
|
name: 'collect-running',
|
||||||
|
|||||||
@@ -1,69 +1,162 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface TemplateFieldRow {
|
export interface TemplateFieldRow {
|
||||||
|
|
||||||
label: string
|
label: string
|
||||||
|
|
||||||
value: string
|
value: string
|
||||||
|
|
||||||
|
originName: string
|
||||||
|
|
||||||
|
fieldType: number
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface TemplatePreview {
|
export interface TemplatePreview {
|
||||||
|
|
||||||
frontImageUrl: string
|
frontImageUrl: string
|
||||||
|
|
||||||
backImageUrl: string
|
backImageUrl: string
|
||||||
|
|
||||||
fields: TemplateFieldRow[]
|
fields: TemplateFieldRow[]
|
||||||
|
|
||||||
|
/** soon 模板 flag:1 双面 / 2 正面 / 3 背面 */
|
||||||
|
|
||||||
|
templateFlag: number
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface PathListItem {
|
export interface PathListItem {
|
||||||
|
|
||||||
path: string
|
path: string
|
||||||
|
|
||||||
meta: string
|
meta: string
|
||||||
|
|
||||||
sizeBytes: number
|
sizeBytes: number
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export interface DistributeFormState {
|
export interface DistributeFormState {
|
||||||
|
|
||||||
pathList: PathListItem[]
|
pathList: PathListItem[]
|
||||||
|
|
||||||
volumeLabel: string
|
volumeLabel: string
|
||||||
|
|
||||||
templateFile: string
|
templateFile: string
|
||||||
|
|
||||||
templatePreview: TemplatePreview | null
|
templatePreview: TemplatePreview | null
|
||||||
|
|
||||||
|
/** 任务 print_flag:1 双面 / 2 仅正面 / 3 仅背面 */
|
||||||
|
|
||||||
|
printFlag: number
|
||||||
|
|
||||||
copyType: 0 | 1
|
copyType: 0 | 1
|
||||||
formatType: 'none' | 'fat' | 'ntfs'
|
|
||||||
|
formatType: 'none' | 'fat32' | 'exfat' | 'ntfs'
|
||||||
|
|
||||||
dongleEnabled: boolean
|
dongleEnabled: boolean
|
||||||
dongleMode: -1 | 0 | 255
|
|
||||||
|
/** 勾选加密狗时有效:0 默认,1-101 为次数(101=不限次数) */
|
||||||
|
|
||||||
|
dongleInstallCount: number
|
||||||
|
|
||||||
|
dongleAuthCode: string
|
||||||
|
|
||||||
priority: 'low' | 'mid' | 'high'
|
priority: 'low' | 'mid' | 'high'
|
||||||
|
|
||||||
ribbonType: 'any' | 'YMCKO' | 'YMCK'
|
ribbonType: 'any' | 'YMCKO' | 'YMCK'
|
||||||
|
|
||||||
generateIso: boolean
|
generateIso: boolean
|
||||||
|
|
||||||
generateZip: boolean
|
generateZip: boolean
|
||||||
|
|
||||||
printCmdToHasi: boolean
|
printCmdToHasi: boolean
|
||||||
|
|
||||||
presetCopy: boolean
|
presetCopy: boolean
|
||||||
|
|
||||||
generateHasi: boolean
|
generateHasi: boolean
|
||||||
|
|
||||||
dongleCountCheck: boolean
|
dongleCountCheck: boolean
|
||||||
|
|
||||||
failPrintLabel: boolean
|
failPrintLabel: boolean
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function createDefaultForm(): DistributeFormState {
|
function createDefaultForm(): DistributeFormState {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
||||||
pathList: [],
|
pathList: [],
|
||||||
|
|
||||||
volumeLabel: 'DATA_CARD',
|
volumeLabel: 'DATA_CARD',
|
||||||
|
|
||||||
templateFile: '',
|
templateFile: '',
|
||||||
|
|
||||||
templatePreview: null,
|
templatePreview: null,
|
||||||
|
|
||||||
|
printFlag: 1,
|
||||||
|
|
||||||
copyType: 0,
|
copyType: 0,
|
||||||
formatType: 'none',
|
|
||||||
dongleEnabled: true,
|
formatType: 'fat32',
|
||||||
dongleMode: -1,
|
|
||||||
|
dongleEnabled: false,
|
||||||
|
|
||||||
|
dongleInstallCount: 0,
|
||||||
|
|
||||||
|
dongleAuthCode: '',
|
||||||
|
|
||||||
priority: 'low',
|
priority: 'low',
|
||||||
|
|
||||||
ribbonType: 'any',
|
ribbonType: 'any',
|
||||||
|
|
||||||
generateIso: false,
|
generateIso: false,
|
||||||
|
|
||||||
generateZip: false,
|
generateZip: false,
|
||||||
|
|
||||||
printCmdToHasi: false,
|
printCmdToHasi: false,
|
||||||
|
|
||||||
presetCopy: false,
|
presetCopy: false,
|
||||||
|
|
||||||
generateHasi: false,
|
generateHasi: false,
|
||||||
|
|
||||||
dongleCountCheck: false,
|
dongleCountCheck: false,
|
||||||
|
|
||||||
failPrintLabel: false
|
failPrintLabel: false
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const useDistributeFormStore = defineStore('distributeForm', {
|
export const useDistributeFormStore = defineStore('distributeForm', {
|
||||||
|
|
||||||
state: (): DistributeFormState => createDefaultForm(),
|
state: (): DistributeFormState => createDefaultForm(),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
|
|
||||||
|
const preservedAuth = this.dongleAuthCode
|
||||||
|
|
||||||
Object.assign(this, createDefaultForm())
|
Object.assign(this, createDefaultForm())
|
||||||
|
|
||||||
|
this.dongleAuthCode = preservedAuth
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { secretsGet, secretsSet } from '@/api/cardsoon'
|
||||||
|
|
||||||
|
export const useDongleAuthStore = defineStore('dongleAuth', {
|
||||||
|
state: () => ({
|
||||||
|
authCode: '' as string,
|
||||||
|
loaded: false
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async loadFromSecrets(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const r = await secretsGet()
|
||||||
|
if (r.ok && r.data) {
|
||||||
|
this.authCode = r.data.dongleAuthCode || ''
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[dongleAuth] load failed', e)
|
||||||
|
}
|
||||||
|
this.loaded = true
|
||||||
|
},
|
||||||
|
async persist(authCode: string): Promise<void> {
|
||||||
|
this.authCode = authCode
|
||||||
|
try {
|
||||||
|
await secretsSet({ dongleAuthCode: authCode })
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[dongleAuth] persist failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
|
|||||||
export const useJobStore = defineStore('job', {
|
export const useJobStore = defineStore('job', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
jobId: '',
|
jobId: '',
|
||||||
|
lastJobJson: '',
|
||||||
submitting: false,
|
submitting: false,
|
||||||
successCount: 0,
|
successCount: 0,
|
||||||
failCount: 0
|
failCount: 0
|
||||||
@@ -11,11 +12,20 @@ export const useJobStore = defineStore('job', {
|
|||||||
setActiveJob(jobId: string) {
|
setActiveJob(jobId: string) {
|
||||||
this.jobId = jobId
|
this.jobId = jobId
|
||||||
},
|
},
|
||||||
|
setLastJobJson(json: string) {
|
||||||
|
this.lastJobJson = json
|
||||||
|
},
|
||||||
clearActiveJob() {
|
clearActiveJob() {
|
||||||
this.jobId = ''
|
this.jobId = ''
|
||||||
},
|
},
|
||||||
|
/** 重置次数计数器(不影响当前 jobId/lastJobJson) */
|
||||||
|
resetCounts() {
|
||||||
|
this.successCount = 0
|
||||||
|
this.failCount = 0
|
||||||
|
},
|
||||||
reset() {
|
reset() {
|
||||||
this.jobId = ''
|
this.jobId = ''
|
||||||
|
this.lastJobJson = ''
|
||||||
this.submitting = false
|
this.submitting = false
|
||||||
this.successCount = 0
|
this.successCount = 0
|
||||||
this.failCount = 0
|
this.failCount = 0
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { StoredCredential } from '@/types/network'
|
||||||
|
import { secretsGet, secretsSet } from '@/api/cardsoon'
|
||||||
|
|
||||||
|
const LEGACY_KEY = 'networkCredentials'
|
||||||
|
|
||||||
|
export const useNetworkAuthStore = defineStore('networkAuth', {
|
||||||
|
state: () => ({
|
||||||
|
credentials: {} as Record<string, StoredCredential>,
|
||||||
|
driveHosts: {} as Record<string, string>
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
hasCredentials: (state) => Object.keys(state.credentials).length > 0,
|
||||||
|
configuredHosts: (state) => Object.keys(state.credentials)
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
async loadFromStorage(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const r = await secretsGet()
|
||||||
|
if (r.ok && r.data?.networkCredentials) {
|
||||||
|
this.credentials = { ...r.data.networkCredentials }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[networkAuth] secrets load failed', e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LEGACY_KEY)
|
||||||
|
if (!raw) return
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, StoredCredential>
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
this.credentials = { ...parsed }
|
||||||
|
await this.persist()
|
||||||
|
localStorage.removeItem(LEGACY_KEY)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[networkAuth] legacy load failed', e)
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(LEGACY_KEY)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setCredential(hostName: string, userName: string, password: string): void {
|
||||||
|
const host = (hostName || '').trim()
|
||||||
|
if (!host) return
|
||||||
|
this.credentials[host] = {
|
||||||
|
userName: userName || '',
|
||||||
|
password: password || '',
|
||||||
|
lastUsed: new Date().toISOString()
|
||||||
|
}
|
||||||
|
void this.persist()
|
||||||
|
},
|
||||||
|
setDriveHost(letter: string, hostName: string): void {
|
||||||
|
const L = (letter || '').trim().toUpperCase()
|
||||||
|
const host = (hostName || '').trim()
|
||||||
|
if (!L || L.length !== 1 || !host) return
|
||||||
|
this.driveHosts[L] = host
|
||||||
|
},
|
||||||
|
getDriveHostMap(): Record<string, string> {
|
||||||
|
return { ...this.driveHosts }
|
||||||
|
},
|
||||||
|
getCredential(hostName: string): StoredCredential | null {
|
||||||
|
const host = (hostName || '').trim()
|
||||||
|
if (!host) return null
|
||||||
|
return this.credentials[host] || null
|
||||||
|
},
|
||||||
|
removeCredential(hostName: string): void {
|
||||||
|
const host = (hostName || '').trim()
|
||||||
|
if (!host) return
|
||||||
|
if (delete this.credentials[host]) {
|
||||||
|
void this.persist()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearAll(): void {
|
||||||
|
this.credentials = {}
|
||||||
|
this.driveHosts = {}
|
||||||
|
void this.persist()
|
||||||
|
},
|
||||||
|
async persist(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await secretsSet({ networkCredentials: this.credentials })
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[networkAuth] persist failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -65,3 +65,15 @@
|
|||||||
.fa-exclamation-triangle::before {
|
.fa-exclamation-triangle::before {
|
||||||
content: '\f071';
|
content: '\f071';
|
||||||
}
|
}
|
||||||
|
.fa-id-card::before {
|
||||||
|
content: '\f2c2';
|
||||||
|
}
|
||||||
|
.fa-eject::before {
|
||||||
|
content: '\f052';
|
||||||
|
}
|
||||||
|
.fa-eye::before {
|
||||||
|
content: '\f06e';
|
||||||
|
}
|
||||||
|
.fa-eye-slash::before {
|
||||||
|
content: '\f070';
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 20px 30px;
|
padding: 20px 30px;
|
||||||
min-width: 220px;
|
min-width: 0;
|
||||||
|
max-width: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 面板标题 */
|
/* 面板标题 */
|
||||||
@@ -51,6 +52,8 @@
|
|||||||
/* ========== 路径选择 - 大按钮设计 ========== */
|
/* ========== 路径选择 - 大按钮设计 ========== */
|
||||||
.m-path-box {
|
.m-path-box {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
@@ -59,6 +62,16 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #495057;
|
color: #495057;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-path-text {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-path-btn {
|
.m-path-btn {
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
/*
|
|
||||||
Page 4 - 数据导入模式
|
|
||||||
手机横屏优化:左右分栏,大触摸区域,紧凑布局
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ========== 手机横屏核心布局 ========== */
|
|
||||||
.l-mobile-landscape {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0;
|
|
||||||
padding: 0 60px;
|
|
||||||
height: 100%;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 配置面板 */
|
|
||||||
.m-config-panel {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 20px 30px;
|
|
||||||
min-width: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 面板标题 */
|
|
||||||
.m-panel-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #495057;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-panel-title i {
|
|
||||||
color: var(--cs-primary);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 垂直分隔线 */
|
|
||||||
.m-divider-v {
|
|
||||||
width: 1px;
|
|
||||||
height: 100px;
|
|
||||||
background: #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== 路径选择 - 大按钮设计 ========== */
|
|
||||||
.m-path-box {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #495057;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-path-btn {
|
|
||||||
width: 100%;
|
|
||||||
height: 44px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #ced4da;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #495057;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-path-btn:hover {
|
|
||||||
border-color: var(--cs-primary);
|
|
||||||
color: var(--cs-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-path-btn i {
|
|
||||||
color: var(--cs-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== 单选按钮 - 大触摸区域 ========== */
|
|
||||||
.m-radio-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-radio-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #495057;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-radio-item input {
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
accent-color: var(--cs-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-radio-item:hover {
|
|
||||||
border-color: var(--cs-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-radio-item:has(input:checked) {
|
|
||||||
border-color: var(--cs-primary);
|
|
||||||
background: rgba(0, 128, 0, 0.05);
|
|
||||||
}
|
|
||||||
@@ -73,6 +73,101 @@
|
|||||||
color: var(--cs-primary);
|
color: var(--cs-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 不支持的工具按钮(如当前 DLL 无废卡接口):置灰禁用 */
|
||||||
|
.m-tool-btn:disabled {
|
||||||
|
background: #f1f3f5;
|
||||||
|
border-color: #e9ecef;
|
||||||
|
color: #adb5bd;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-tool-btn:disabled:hover {
|
||||||
|
border-color: #e9ecef;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-tool-btn:disabled i,
|
||||||
|
.m-tool-btn:disabled:hover i {
|
||||||
|
color: #ced4da;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 读卡 / 退卡 PK 式斜切分隔按钮 ========== */
|
||||||
|
.m-tool-versus {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-tool-versus:hover {
|
||||||
|
border-color: var(--cs-primary);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 128, 0, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-versus-half {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 50%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #495057;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.18s ease, color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-versus-half span {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-versus-half i {
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #6c757d;
|
||||||
|
transition: color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左半:内容朝中间(右对齐),右边缘斜切为 \ 形 */
|
||||||
|
.m-versus-half--read {
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-right: 12px;
|
||||||
|
clip-path: polygon(0 0, calc(100% - 9px) 0, calc(100% + 9px) 100%, 0 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右半:内容朝中间(左对齐),左边缘斜切为 \ 形 */
|
||||||
|
.m-versus-half--eject {
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-left: 12px;
|
||||||
|
clip-path: polygon(calc(0% - 9px) 0, 100% 0, 100% 100%, calc(0% + 9px) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-versus-half:hover {
|
||||||
|
background: rgba(0, 128, 0, 0.08);
|
||||||
|
color: var(--cs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-versus-half:hover i {
|
||||||
|
color: var(--cs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-versus-half:active {
|
||||||
|
background: rgba(0, 128, 0, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
/* ========== 垂直分隔线 ========== */
|
/* ========== 垂直分隔线 ========== */
|
||||||
.m-divider {
|
.m-divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
|
|||||||
@@ -1,360 +0,0 @@
|
|||||||
/*
|
|
||||||
Page 8 - 循环任务执行中样式
|
|
||||||
左右分布布局:左 = 状态+工作流,右 = 大圆环
|
|
||||||
Index 首页也复用此样式
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ========== Index 首页仪表板样式 ========== */
|
|
||||||
.l-dashboard {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 60px;
|
|
||||||
padding: 20px 80px;
|
|
||||||
height: 100%;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 区域标题 */
|
|
||||||
.m-section-title {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #adb5bd;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
padding-left: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== 工具区域(左) ========== */
|
|
||||||
.m-tool-section {
|
|
||||||
width: 180px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-tool-grid {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-tool-btn {
|
|
||||||
width: 100%;
|
|
||||||
height: 48px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 6px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
padding: 0 16px;
|
|
||||||
gap: 10px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #495057;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-tool-btn i {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #6c757d;
|
|
||||||
width: 20px;
|
|
||||||
text-align: center;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-tool-btn:hover {
|
|
||||||
border-color: var(--cs-primary);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 128, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-tool-btn:hover i {
|
|
||||||
color: var(--cs-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== 垂直分隔线 ========== */
|
|
||||||
.m-divider {
|
|
||||||
width: 1px;
|
|
||||||
height: 140px;
|
|
||||||
background: linear-gradient(to bottom, transparent, #dee2e6, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== 任务区域(右) ========== */
|
|
||||||
.m-task-section {
|
|
||||||
flex: 1;
|
|
||||||
max-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-task-grid {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-task-card {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 100px;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-task-icon {
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-task-icon i {
|
|
||||||
font-size: 18px;
|
|
||||||
color: #6c757d;
|
|
||||||
transition: color 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-task-info {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-task-info h4 {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #495057;
|
|
||||||
margin: 0 0 4px 0;
|
|
||||||
transition: color 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-task-info p {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #adb5bd;
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 悬停效果 - 绿色主题 */
|
|
||||||
.m-task-card:hover {
|
|
||||||
border-color: var(--cs-primary);
|
|
||||||
box-shadow: 0 6px 16px rgba(0, 128, 0, 0.12);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-task-card:hover .m-task-icon {
|
|
||||||
background: var(--cs-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-task-card:hover .m-task-icon i {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-task-card:hover h4 {
|
|
||||||
color: var(--cs-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 停止按钮样式 - 醒目红色 */
|
|
||||||
.c-nav-btn--stop {
|
|
||||||
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%) !important;
|
|
||||||
box-shadow: 0 3px 10px rgba(220, 53, 69, 0.35) !important;
|
|
||||||
width: 48px !important;
|
|
||||||
height: 48px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-nav-btn--stop i {
|
|
||||||
color: #fff !important;
|
|
||||||
font-size: 18px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-nav-btn--stop span {
|
|
||||||
color: #fff !important;
|
|
||||||
font-weight: 700 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-nav-btn--stop:hover {
|
|
||||||
background: linear-gradient(135deg, #c82333 0%, #a71d2a 100%) !important;
|
|
||||||
box-shadow: 0 4px 14px rgba(220, 53, 69, 0.45) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== 核心布局:左右分布 ========== */
|
|
||||||
.l-hero-container {
|
|
||||||
display: flex !important;
|
|
||||||
flex-direction: row !important;
|
|
||||||
align-items: center !important;
|
|
||||||
justify-content: center !important;
|
|
||||||
gap: 80px !important;
|
|
||||||
padding: 0 120px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 左侧面板:状态 + 工作流 */
|
|
||||||
.m-left-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 30px;
|
|
||||||
flex: 1;
|
|
||||||
max-width: 380px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 右侧面板:圆环进度 */
|
|
||||||
.m-right-panel {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== 状态消息 - 左对齐 ========== */
|
|
||||||
.c-status-panel {
|
|
||||||
text-align: left !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-status-title.is-looping {
|
|
||||||
color: var(--cs-primary);
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 800;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-status-title.is-looping::before {
|
|
||||||
content: '';
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background: var(--cs-primary);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: blink 1.5s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.3; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-status-sub {
|
|
||||||
text-align: left !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 统计计数文字 */
|
|
||||||
.c-status-counter {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #6c757d;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 8px 0 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-status-counter .ok {
|
|
||||||
color: var(--cs-primary);
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-status-counter .err {
|
|
||||||
color: #dc3545;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== 4步工作流 - 直接渲染 ========== */
|
|
||||||
.m-steps-flow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 0;
|
|
||||||
width: 100%;
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-steps-flow .step-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 70px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-steps-flow .step-dot {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-steps-flow .step-item.is-active .step-dot {
|
|
||||||
background: var(--cs-primary);
|
|
||||||
box-shadow: 0 0 0 4px rgba(0, 128, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-steps-flow .step-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #adb5bd;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-steps-flow .step-item.is-active .step-label {
|
|
||||||
color: var(--cs-primary);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-steps-flow .step-line {
|
|
||||||
width: 50px;
|
|
||||||
height: 3px;
|
|
||||||
background: #dee2e6;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-steps-flow .step-line.is-active {
|
|
||||||
background: var(--cs-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== 圆形进度条 ========== */
|
|
||||||
.m-progress-circle {
|
|
||||||
position: relative;
|
|
||||||
width: 160px;
|
|
||||||
height: 160px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-progress-circle svg {
|
|
||||||
transform: rotate(-90deg);
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-progress-circle circle {
|
|
||||||
fill: none;
|
|
||||||
stroke-width: 10;
|
|
||||||
stroke-linecap: round;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-progress-circle .bg {
|
|
||||||
stroke: #ecf0f1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-progress-circle .fill {
|
|
||||||
stroke: var(--cs-primary);
|
|
||||||
stroke-dasharray: 283;
|
|
||||||
transition: stroke-dashoffset 0.5s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-progress-value {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
font-size: 34px;
|
|
||||||
font-weight: 900;
|
|
||||||
color: var(--cs-primary);
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
/*
|
|
||||||
Page 3 - 任务失败界面
|
|
||||||
风格与 page8 统一:左右分布,红色错误主题
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ========== 核心布局:左右分布 ========== */
|
|
||||||
.l-hero-container {
|
|
||||||
display: flex !important;
|
|
||||||
flex-direction: row !important;
|
|
||||||
align-items: center !important;
|
|
||||||
justify-content: center !important;
|
|
||||||
gap: 80px !important;
|
|
||||||
padding: 0 120px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 左侧面板:状态 + 工作流 */
|
|
||||||
.m-left-panel {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 30px;
|
|
||||||
flex: 1;
|
|
||||||
max-width: 380px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 右侧面板:错误图标 */
|
|
||||||
.m-right-panel {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== 状态消息 - 红色错误主题 ========== */
|
|
||||||
.c-status-panel {
|
|
||||||
text-align: left !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-status-title.is-error {
|
|
||||||
color: #dc3545;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 800;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-status-title.is-error::before {
|
|
||||||
content: '';
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
background: #dc3545;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: blink-red 1.5s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink-red {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.3; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-status-sub {
|
|
||||||
text-align: left !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 统计计数文字 */
|
|
||||||
.c-status-counter {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #6c757d;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 8px 0 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-status-counter .ok {
|
|
||||||
color: #28a745;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-status-counter .err {
|
|
||||||
color: #dc3545;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== 4步工作流 - 红色错误主题 ========== */
|
|
||||||
.m-steps-flow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
gap: 0;
|
|
||||||
width: 100%;
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-steps-flow .step-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 70px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-steps-flow .step-dot {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #dee2e6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 完成状态 - 绿色 */
|
|
||||||
.m-steps-flow .step-item.is-completed .step-dot {
|
|
||||||
background: #28a745;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-steps-flow .step-item.is-completed .step-label {
|
|
||||||
color: #28a745;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 错误状态 - 红色 */
|
|
||||||
.m-steps-flow .step-item.is-error .step-dot {
|
|
||||||
background: #dc3545;
|
|
||||||
box-shadow: 0 0 0 4px rgba(220, 53, 69, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-steps-flow .step-item.is-error .step-label {
|
|
||||||
color: #dc3545;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-steps-flow .step-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #adb5bd;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-steps-flow .step-line {
|
|
||||||
width: 50px;
|
|
||||||
height: 3px;
|
|
||||||
background: #dee2e6;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-steps-flow .step-line.is-completed {
|
|
||||||
background: #28a745;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-steps-flow .step-line.is-error {
|
|
||||||
background: linear-gradient(to right, #28a745 50%, #dc3545 50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== 错误图标 - 红色大三角 ========== */
|
|
||||||
.m-error-icon {
|
|
||||||
width: 160px;
|
|
||||||
height: 160px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 8px 24px rgba(220, 53, 69, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-error-icon i {
|
|
||||||
font-size: 70px;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
/*
|
|
||||||
Page 7 业务样式 - 打印系统核心界面
|
|
||||||
基于 base.css 构建
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* 左右栏固定 1:1,内容变化不挤占宽度 */
|
/* 左右栏固定 1:1,内容变化不挤占宽度 */
|
||||||
.app-shell__main.l-main-flex {
|
.app-shell__main.l-main-flex {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -53,6 +48,11 @@
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.m-panel-toolbar .toolbar-row + .toolbar-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar-item {
|
.toolbar-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -81,6 +81,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
@@ -108,22 +110,64 @@
|
|||||||
margin-left: 3px;
|
margin-left: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 提示文字 */
|
.m-panel-toolbar .c-checkbox-item .c-input.dog-auth {
|
||||||
.m-panel-toolbar .c-checkbox-item .dog-hint {
|
width: 140px;
|
||||||
font-size: 8px;
|
height: 20px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 9px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 授权码输入框容器 + 小眼睛 */
|
||||||
|
.m-panel-toolbar .c-checkbox-item .dog-auth-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-panel-toolbar .c-checkbox-item .dog-auth-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 2px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
color: #adb5bd;
|
color: #adb5bd;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-panel-toolbar .c-checkbox-item .dog-auth-toggle:hover {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 授权码标签 */
|
||||||
|
.m-panel-toolbar .c-checkbox-item .dog-auth-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #495057;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-left: 2px;
|
margin-left: 6px;
|
||||||
flex-shrink: 1;
|
flex-shrink: 0;
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-panel-toolbar .c-checkbox-item {
|
/* 提示文字 */
|
||||||
flex: 1;
|
.m-panel-toolbar .c-checkbox-item .dog-hint {
|
||||||
min-width: 0;
|
font-size: 9px;
|
||||||
|
color: #adb5bd;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 列表业务项 (File Items) */
|
/* 列表业务项 (File Items) */
|
||||||
@@ -410,214 +454,6 @@
|
|||||||
border-top: 1px solid #f0f0f0;
|
border-top: 1px solid #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 10. 设置弹窗 (Page 8) */
|
|
||||||
.m-settings-modal {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal.is-open {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.24);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__dialog {
|
|
||||||
position: relative;
|
|
||||||
width: min(540px, calc(100% - 20px));
|
|
||||||
max-height: calc(100% - 12px);
|
|
||||||
min-height: 0;
|
|
||||||
background: #f3f3f3;
|
|
||||||
border: 1px solid #cfcfcf;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__header {
|
|
||||||
flex-shrink: 0;
|
|
||||||
height: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 8px 0 12px;
|
|
||||||
border-bottom: 1px solid #dddddd;
|
|
||||||
background: linear-gradient(to bottom, #fbfbfb, #efefef);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__header h2 {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #5a5a5a;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__close {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: transparent;
|
|
||||||
color: #666;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__close:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.06);
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
padding: 6px 10px 4px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #bbb transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__body::-webkit-scrollbar {
|
|
||||||
width: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__body::-webkit-scrollbar-thumb {
|
|
||||||
background: #bbb;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-group {
|
|
||||||
flex-shrink: 0;
|
|
||||||
border: 1px solid #d8d8d8;
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-group--advanced {
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-group__title {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #444;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-group__panel {
|
|
||||||
background: #efefef;
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
padding: 6px;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 58px 155px 58px 1fr;
|
|
||||||
align-items: center;
|
|
||||||
column-gap: 8px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-row:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
grid-template-columns: 86px 155px 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-row label {
|
|
||||||
font-size: 10px;
|
|
||||||
color: #333;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-row .c-app-select {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-options {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
row-gap: 6px;
|
|
||||||
column-gap: 14px;
|
|
||||||
align-content: start;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-check {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: #333;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-check input[type='checkbox'] {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__footer {
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 4px 12px 8px;
|
|
||||||
border-top: 1px solid #ddd;
|
|
||||||
background: #f3f3f3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__cancel {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
min-width: 68px;
|
|
||||||
height: 24px;
|
|
||||||
padding: 0 10px;
|
|
||||||
border: 1px solid #ced4da;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: #fff;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #495057;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__cancel:hover {
|
|
||||||
border-color: #adb5bd;
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__cancel .fas {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__confirm {
|
|
||||||
min-width: 68px;
|
|
||||||
height: 24px;
|
|
||||||
font-size: 12px;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 路径提示 */
|
/* 路径提示 */
|
||||||
.m-path-hint {
|
.m-path-hint {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -780,6 +616,43 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.c-data-table-mini .c-field-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 16px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: inherit;
|
||||||
|
font-weight: inherit;
|
||||||
|
color: inherit;
|
||||||
|
outline: none;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-field-pick {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #495057;
|
||||||
|
padding: 0 6px;
|
||||||
|
height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-field-pick:hover {
|
||||||
|
background: #f1f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-field-value {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.c-path-cell__text {
|
.c-path-cell__text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -1,660 +0,0 @@
|
|||||||
/*
|
|
||||||
Page 7 业务样式 - 打印系统核心界面
|
|
||||||
基于 base.css 构建
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* 布局微调:增加左侧面板宽度给新控件 */
|
|
||||||
.m-panel--left {
|
|
||||||
flex: 5;
|
|
||||||
}
|
|
||||||
.m-panel--right {
|
|
||||||
flex: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========== 工具栏 - 紧凑两行布局 ========== */
|
|
||||||
.m-panel-toolbar {
|
|
||||||
padding: 10px 14px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-bottom: 1px solid #e9ecef;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #6c757d;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-item .c-input {
|
|
||||||
width: 90px;
|
|
||||||
height: 24px;
|
|
||||||
padding: 0 6px;
|
|
||||||
font-size: 9px;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: 1px solid #ced4da;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-item .c-select {
|
|
||||||
width: 90px;
|
|
||||||
height: 24px;
|
|
||||||
padding: 0 6px;
|
|
||||||
font-size: 9px;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: 1px solid #ced4da;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 复选框样式 */
|
|
||||||
.m-panel-toolbar .c-checkbox-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #6c757d;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-panel-toolbar .c-checkbox-item input[type="checkbox"] {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 加密狗数量输入框 */
|
|
||||||
.m-panel-toolbar .c-checkbox-item .c-input.dog-count {
|
|
||||||
width: 40px;
|
|
||||||
height: 20px;
|
|
||||||
padding: 0 4px;
|
|
||||||
font-size: 9px;
|
|
||||||
text-align: center;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: 1px solid #ced4da;
|
|
||||||
margin-left: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 提示文字 */
|
|
||||||
.m-panel-toolbar .c-checkbox-item .dog-hint {
|
|
||||||
font-size: 8px;
|
|
||||||
color: #adb5bd;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 列表业务项 (File Items) */
|
|
||||||
.m-file-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-bottom: 1px solid #f8f8f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-file-item__icon {
|
|
||||||
width: 32px;
|
|
||||||
font-size: 22px; /* 图标大幅放大,对齐 case.png */
|
|
||||||
color: var(--cs-dark-blue);
|
|
||||||
margin-right: 12px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.m-file-item__info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.m-file-item__name {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.m-file-item__meta {
|
|
||||||
font-size: 9px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
.m-file-item__delete {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: var(--cs-primary);
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 卡片预览区 (Preview Area) */
|
|
||||||
.m-preview-area {
|
|
||||||
background: #333;
|
|
||||||
margin: 8px;
|
|
||||||
height: 100px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.m-card-small {
|
|
||||||
width: 130px;
|
|
||||||
height: 84px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 5px;
|
|
||||||
font-size: 7px;
|
|
||||||
}
|
|
||||||
.m-card-small__row {
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
/* 面板头部专用布局 */
|
|
||||||
.c-panel__header .c-nav-group {
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 优化按钮文字展示,确保不换行 */
|
|
||||||
.c-button--mini,
|
|
||||||
.c-button--primary {
|
|
||||||
background: var(--cs-primary) !important;
|
|
||||||
color: #fff !important;
|
|
||||||
border: none !important;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1px 8px; /* 进一步收紧边距 */
|
|
||||||
font-weight: 800;
|
|
||||||
font-size: 10px;
|
|
||||||
white-space: nowrap;
|
|
||||||
letter-spacing: -0.2px; /* 微调字间距 */
|
|
||||||
}
|
|
||||||
.c-button--mini:hover {
|
|
||||||
background: var(--cs-primary-hover) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 数据详情表格 (Data Table) */
|
|
||||||
.m-data-section {
|
|
||||||
flex: 1;
|
|
||||||
padding: 0 10px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.m-data-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.m-data-table td {
|
|
||||||
padding: 3px 0;
|
|
||||||
border-bottom: 1px solid #f5f5f5;
|
|
||||||
}
|
|
||||||
.m-data-table td:first-child {
|
|
||||||
color: #888;
|
|
||||||
width: 40%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 动态表单字段 (Dynamic Fields) */
|
|
||||||
.m-dynamic-fields {
|
|
||||||
padding: 0 10px 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-dynamic-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center; /* 垂直居中 */
|
|
||||||
gap: 8px;
|
|
||||||
min-height: 24px; /* 增加最小高度确保对齐空间 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-dynamic-row select,
|
|
||||||
.m-dynamic-row input[type='text'] {
|
|
||||||
border: 1px solid var(--cs-border);
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-dynamic-row select {
|
|
||||||
flex: 1.2;
|
|
||||||
background: #f9f9f9;
|
|
||||||
}
|
|
||||||
.m-dynamic-row input[type='text'] {
|
|
||||||
flex: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 单选框组对齐优化 */
|
|
||||||
.m-dynamic-row .radio-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
flex: 1.8; /* 与输入框占据同样的宽度比例,保持视觉对称 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-dynamic-row label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center; /* 关键:Label 内部 Flex 居中 */
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: #444;
|
|
||||||
cursor: pointer;
|
|
||||||
line-height: 1; /* 防止行高干扰 */
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-dynamic-row input[type='radio'] {
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
position: relative;
|
|
||||||
top: 1px; /* 视觉补偿:单选框通常在浏览器中偏上 1px */
|
|
||||||
}
|
|
||||||
.c-list-item {
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-bottom: 1px solid #f5f6f7;
|
|
||||||
}
|
|
||||||
.c-list-item__icon {
|
|
||||||
color: #3498db;
|
|
||||||
font-size: 14px;
|
|
||||||
width: 20px;
|
|
||||||
}
|
|
||||||
.c-list-item__name {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
.c-list-item__meta {
|
|
||||||
font-size: 9px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
.c-list-item__action {
|
|
||||||
color: #e74c3c;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 4. 预览区:高保真拟物化 */
|
|
||||||
.m-preview-area {
|
|
||||||
background: #2d3436;
|
|
||||||
margin: 8px;
|
|
||||||
height: 105px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 15px;
|
|
||||||
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
.m-card-small {
|
|
||||||
width: 135px;
|
|
||||||
height: 88px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 6px;
|
|
||||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.m-card-small__row {
|
|
||||||
font-size: 8px;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
.m-card-small__label {
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
/* 5. 业务数据表格:极致紧凑化 (2px 间距) */
|
|
||||||
.m-data-section { padding: 2px 10px; }
|
|
||||||
.m-data-table { width: 100%; border-collapse: separate; border-spacing: 0 2px; font-size: 10px !important; }
|
|
||||||
.m-data-table td { padding: 0; border: none; vertical-align: middle; }
|
|
||||||
.m-data-table td:first-child {
|
|
||||||
color: #666; width: 35%; font-weight: 700; padding-right: 8px;
|
|
||||||
}
|
|
||||||
.m-data-table td:last-child {
|
|
||||||
color: #333; font-weight: 800; text-align: left;
|
|
||||||
background: #f9fafb; border: 1px solid #dcdfe6; border-radius: 3px;
|
|
||||||
padding: 1px 8px; height: 20px; /* 进一步压低高度 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 6. 动态表单项:极致压缩 (解决挤压问题) */
|
|
||||||
.m-dynamic-fields { padding: 0 10px 6px; display: flex; flex-direction: column; gap: 2px; }
|
|
||||||
.m-dynamic-row { display: flex; align-items: center; gap: 6px; min-height: 20px; }
|
|
||||||
.m-dynamic-row .c-select {
|
|
||||||
width: 35%; /* 强制与上方 Label 宽度一致,实现对齐 */
|
|
||||||
height: 20px; font-size: 10px; border: 1px solid #dcdfe6; border-radius: 3px;
|
|
||||||
}
|
|
||||||
.m-dynamic-row .c-input {
|
|
||||||
flex: 1; height: 20px; font-size: 10px; border: 1px solid #dcdfe6; border-radius: 3px; padding: 0 8px;
|
|
||||||
}
|
|
||||||
.m-dynamic-row .m-file-item__delete {
|
|
||||||
font-size: 12px; color: #999; padding: 0 4px;
|
|
||||||
}
|
|
||||||
.m-dynamic-row label { display: flex; align-items: center; gap: 4px; cursor: pointer; color: #444; font-size: 10px; }
|
|
||||||
|
|
||||||
/* 7. 主色调回归:Page 3 式工业绿 */
|
|
||||||
.c-button--primary {
|
|
||||||
background: var(--cs-primary) !important;
|
|
||||||
border-color: #3b633a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 8. 进度条深度美化 (极致窄版 - 12px 极简设计) */
|
|
||||||
.c-progress {
|
|
||||||
height: 12px; /* 极致窄版,节省空间 */
|
|
||||||
background: #dee2e6;
|
|
||||||
border-radius: 6px;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 2px 10px; /* 减小外边距 */
|
|
||||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15);
|
|
||||||
border: 1px solid #adb5bd;
|
|
||||||
}
|
|
||||||
.c-progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(to bottom, #40c057, #2f9e44);
|
|
||||||
border-radius: 5px;
|
|
||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
.c-progress-text {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 8.5px; /* 极致字号 */
|
|
||||||
font-weight: 800;
|
|
||||||
color: #fff;
|
|
||||||
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.4);
|
|
||||||
z-index: 2;
|
|
||||||
line-height: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 9. Footer 区域收缩 */
|
|
||||||
.c-panel__footer {
|
|
||||||
padding: 4px 0 !important; /* 彻底压缩 Footer 高度 */
|
|
||||||
min-height: auto !important;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 10. 设置弹窗 (Page 8) */
|
|
||||||
.m-settings-modal {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal.is-open {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.24);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__dialog {
|
|
||||||
position: relative;
|
|
||||||
width: 560px;
|
|
||||||
min-height: 265px;
|
|
||||||
background: #f3f3f3;
|
|
||||||
border: 1px solid #cfcfcf;
|
|
||||||
border-radius: 6px;
|
|
||||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__header {
|
|
||||||
height: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-bottom: 1px solid #dddddd;
|
|
||||||
background: linear-gradient(to bottom, #fbfbfb, #efefef);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__header h2 {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #5a5a5a;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__body {
|
|
||||||
flex: 1;
|
|
||||||
padding: 8px 12px 6px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-group {
|
|
||||||
border: 1px solid #d8d8d8;
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-group--advanced {
|
|
||||||
min-height: 132px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-group__title {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #444;
|
|
||||||
margin-bottom: 7px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-group__panel {
|
|
||||||
background: #efefef;
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 58px 155px 58px 1fr;
|
|
||||||
align-items: center;
|
|
||||||
column-gap: 8px;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-row:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
grid-template-columns: 86px 155px 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-row label {
|
|
||||||
font-size: 10px;
|
|
||||||
color: #333;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-row .c-select {
|
|
||||||
height: 20px;
|
|
||||||
font-size: 10px;
|
|
||||||
border-radius: 2px;
|
|
||||||
border-color: #c9c9c9;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-options {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
row-gap: 12px;
|
|
||||||
column-gap: 22px;
|
|
||||||
align-content: start;
|
|
||||||
min-height: 92px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-check {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: #333;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-check input[type='checkbox'] {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: 0 12px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-settings-modal__confirm {
|
|
||||||
min-width: 68px;
|
|
||||||
height: 22px;
|
|
||||||
font-size: 10px;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 0 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 路径提示 */
|
|
||||||
.m-path-hint {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-bottom: 1px solid #e9ecef;
|
|
||||||
font-size: 10px;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-path-hint i {
|
|
||||||
color: var(--cs-dark-grey);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 路径列表项 */
|
|
||||||
.c-path-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-bottom: 1px solid #f5f6f7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-path-item__info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-path-item__name {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-path-item__meta {
|
|
||||||
font-size: 9px;
|
|
||||||
color: #999;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-path-item__delete {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: #e74c3c;
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px 6px;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 标签预览区 */
|
|
||||||
.c-preview-area {
|
|
||||||
background: #2d3436;
|
|
||||||
margin: 8px;
|
|
||||||
height: 105px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 15px;
|
|
||||||
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-card-small {
|
|
||||||
width: 135px;
|
|
||||||
height: 88px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 6px;
|
|
||||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.6);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-card-small__row {
|
|
||||||
font-size: 8px;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-card-small__label {
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 紧凑数据表格 */
|
|
||||||
.c-data-table-mini {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: separate;
|
|
||||||
border-spacing: 0 2px;
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-data-table-mini td {
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-data-table-mini td:first-child {
|
|
||||||
color: #666;
|
|
||||||
width: 35%;
|
|
||||||
font-weight: 700;
|
|
||||||
padding-right: 8px;
|
|
||||||
font-size: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-data-table-mini td:last-child {
|
|
||||||
color: #333;
|
|
||||||
font-weight: 800;
|
|
||||||
text-align: left;
|
|
||||||
background: #f9fafb;
|
|
||||||
border: 1px solid #dcdfe6;
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
height: 18px;
|
|
||||||
font-size: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-data-table-mini td:last-child.c-path-cell {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c-path-update {
|
|
||||||
color: var(--cs-primary);
|
|
||||||
font-weight: 800;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
/* Electron 壳层:覆盖 design 原型用的深色信箱背景 */
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
@@ -13,7 +12,6 @@ body,
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 720×360 逻辑画布;内容区同比例;useScale 按 innerWidth/720 顶对齐 */
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -25,11 +23,6 @@ body,
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
* 首页两侧「空白」主要来自 page2.css 的 .l-dashboard:
|
|
||||||
* padding 左右 + justify-content:center + 左栏固定 180px / 右栏 max-width:400px
|
|
||||||
* 在 DevTools 里选中 main.app-shell__main.l-dashboard 可看到盒模型
|
|
||||||
*/
|
|
||||||
.app-shell__main.l-dashboard {
|
.app-shell__main.l-dashboard {
|
||||||
padding: 20px 40px;
|
padding: 20px 40px;
|
||||||
gap: 48px;
|
gap: 48px;
|
||||||
@@ -75,7 +68,6 @@ body,
|
|||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 与 page2.css 中 .m-tool-btn i / .m-task-icon i 对齐 */
|
|
||||||
.m-tool-btn .fas {
|
.m-tool-btn .fas {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
|
|||||||
@@ -25,12 +25,24 @@ export interface JobPollPayload {
|
|||||||
failed: boolean
|
failed: boolean
|
||||||
cancelled: boolean
|
cancelled: boolean
|
||||||
finished: boolean
|
finished: boolean
|
||||||
|
/** 任务失败当刻由主进程取到的打印机错误串 */
|
||||||
|
errorMessage?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsbPollPayload {
|
export interface UsbPollPayload {
|
||||||
|
/** SAPI_GetUsbCopyState 返回值,0 表示成功 */
|
||||||
|
queryCode: number
|
||||||
|
/** task_status: 0 preparing, 1 copying, 2 completed, 3 failed */
|
||||||
taskStatus: number
|
taskStatus: number
|
||||||
|
/** copy_progress 0-100 */
|
||||||
progress: number
|
progress: number
|
||||||
terminal: boolean
|
terminal: boolean
|
||||||
failed: boolean
|
failed: boolean
|
||||||
success: boolean
|
success: boolean
|
||||||
|
errorMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardPositionPollPayload {
|
||||||
|
queryCode: number
|
||||||
|
position: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** 提交到后端的网络凭据结构(JSON 字符串里的一项) */
|
||||||
|
export interface NetworkCredential {
|
||||||
|
host_name: string
|
||||||
|
user_name: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 客户端缓存的凭据(以 host 为 key) */
|
||||||
|
export interface StoredCredential {
|
||||||
|
userName: string
|
||||||
|
password: string
|
||||||
|
lastUsed: string
|
||||||
|
}
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
export interface PrinterStatusDisplay {
|
export interface PrinterStatusDisplay {
|
||||||
ribbonType: string
|
ribbonType: string
|
||||||
|
ribbonAmount: string
|
||||||
statusText: string
|
statusText: string
|
||||||
serialNo: string
|
serialNo: string
|
||||||
printedCount: number
|
/** 打印机型号/名称(如 TH80),用于判断单/双面能力 */
|
||||||
|
printerName: string
|
||||||
|
/** 是否为单面打印机(如 TH80),单面打印机不能选"双面"打印 */
|
||||||
|
isSingleSide: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Init 前 Header 占位;阶段二由 GetPrinterInfo 覆盖 */
|
|
||||||
export const defaultPrinterStatus: PrinterStatusDisplay = {
|
export const defaultPrinterStatus: PrinterStatusDisplay = {
|
||||||
ribbonType: '—',
|
ribbonType: '—',
|
||||||
|
ribbonAmount: '—',
|
||||||
statusText: '—',
|
statusText: '—',
|
||||||
serialNo: '—',
|
serialNo: '—',
|
||||||
printedCount: 0
|
printerName: '—',
|
||||||
|
isSingleSide: false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,69 @@
|
|||||||
import type { DistributeFormState } from '@/stores/distributeForm'
|
import type { DistributeFormState } from '@/stores/distributeForm'
|
||||||
|
import type { NetInfoCredential } from '@shared/network-host'
|
||||||
import { cleanPathPattern } from '@shared/path-pattern'
|
import { cleanPathPattern } from '@shared/path-pattern'
|
||||||
|
import { resolveJobTasks } from '@/utils/validateJobConfig'
|
||||||
|
|
||||||
|
export interface BuildJobOptions {
|
||||||
|
taskId: string
|
||||||
|
udfFile?: string
|
||||||
|
netInfo?: NetInfoCredential[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileForApi(formatType: Exclude<DistributeFormState['formatType'], 'none'>): string {
|
||||||
|
switch (formatType) {
|
||||||
|
case 'exfat':
|
||||||
|
return 'exFAT'
|
||||||
|
case 'ntfs':
|
||||||
|
return 'NTFS'
|
||||||
|
default:
|
||||||
|
return 'FAT32'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildJobConfig(
|
||||||
|
form: DistributeFormState,
|
||||||
|
opts: BuildJobOptions
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const { hasCopy, hasPrint } = resolveJobTasks(form)
|
||||||
|
const needFormat = form.formatType !== 'none'
|
||||||
|
|
||||||
export function buildJobConfig(form: DistributeFormState): Record<string, unknown> {
|
|
||||||
const taskId = `T${Date.now()}`
|
|
||||||
const hasCopy = form.pathList.length > 0
|
|
||||||
const hasPrint = !!form.templateFile.trim()
|
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
task_id: taskId,
|
task_id: opts.taskId,
|
||||||
print_copys: 1,
|
print_copys: 1,
|
||||||
has_print_task: hasPrint,
|
has_print_task: hasPrint,
|
||||||
has_copy_task: hasCopy,
|
has_copy_task: hasCopy,
|
||||||
label: form.volumeLabel || 'DATA_CARD',
|
label: form.volumeLabel || 'DATA_CARD',
|
||||||
file_type: String(form.copyType),
|
file_type: String(form.copyType),
|
||||||
zone_type: form.copyType === 1 ? '1' : '0',
|
zone_type: form.copyType === 1 ? '1' : '0',
|
||||||
need_format: form.formatType !== 'none',
|
need_format: needFormat,
|
||||||
format_file: form.formatType === 'ntfs' ? 'NTFS' : 'FAT',
|
|
||||||
disk_size: '16GB',
|
disk_size: '16GB',
|
||||||
dongle_install_count: form.dongleEnabled ? form.dongleMode : -1
|
dongle_install_count: form.dongleEnabled ? form.dongleInstallCount : -1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (form.dongleEnabled) {
|
||||||
|
body.auth_code = form.dongleAuthCode.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needFormat) {
|
||||||
|
body.format_file = formatFileForApi(form.formatType as Exclude<DistributeFormState['formatType'], 'none'>)
|
||||||
|
}
|
||||||
|
|
||||||
if (hasCopy) {
|
if (hasCopy) {
|
||||||
body.path_file = form.pathList.map((x) => cleanPathPattern(x.path))
|
body.path_file = form.pathList.map((x) => cleanPathPattern(x.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPrint) {
|
if (hasPrint) {
|
||||||
body.json_file = form.templateFile.trim()
|
body.json_file = form.templateFile.trim()
|
||||||
body.print_flag = 1
|
body.print_flag = form.printFlag
|
||||||
|
const udf = opts.udfFile?.trim()
|
||||||
|
if (udf) body.udf_file = udf
|
||||||
}
|
}
|
||||||
|
|
||||||
if (form.generateIso) body.is_generate_iso = true
|
if (form.generateIso) body.is_generate_iso = true
|
||||||
if (form.generateZip) body.is_generate_zip = true
|
if (form.generateZip) body.is_generate_zip = true
|
||||||
if (form.failPrintLabel) body.is_printer_record_logo = true
|
if (form.failPrintLabel) body.is_printer_record_logo = true
|
||||||
|
|
||||||
|
if (opts.netInfo?.length) body.net_info = opts.netInfo
|
||||||
|
|
||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { cleanPathPattern } from '@shared/path-pattern'
|
||||||
|
import { buildNetInfo, collectHostsForCopyPaths, type NetInfoCredential } from '@shared/network-host'
|
||||||
|
import type { DistributeFormState } from '@/stores/distributeForm'
|
||||||
|
import { fsResolveNetworkHosts } from '@/api/cardsoon'
|
||||||
|
import { useNetworkAuthStore } from '@/stores/networkAuth'
|
||||||
|
|
||||||
|
async function mergedDriveHostMap(form: DistributeFormState): Promise<Record<string, string>> {
|
||||||
|
const paths = form.pathList.map((x) => cleanPathPattern(x.path))
|
||||||
|
const netStore = useNetworkAuthStore()
|
||||||
|
if (!paths.length) return { ...netStore.getDriveHostMap() }
|
||||||
|
const r = await fsResolveNetworkHosts(paths)
|
||||||
|
const fromNetUse = r.ok && r.data?.driveHostMap ? r.data.driveHostMap : {}
|
||||||
|
return { ...fromNetUse, ...netStore.getDriveHostMap() }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveCopyNetworkHosts(form: DistributeFormState): Promise<string[]> {
|
||||||
|
const paths = form.pathList.map((x) => cleanPathPattern(x.path))
|
||||||
|
if (!paths.length) return []
|
||||||
|
const driveHostMap = await mergedDriveHostMap(form)
|
||||||
|
return collectHostsForCopyPaths(paths, driveHostMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildNetInfoForForm(form: DistributeFormState): Promise<NetInfoCredential[]> {
|
||||||
|
const hosts = await resolveCopyNetworkHosts(form)
|
||||||
|
if (!hosts.length) return []
|
||||||
|
const netStore = useNetworkAuthStore()
|
||||||
|
return buildNetInfo(
|
||||||
|
hosts.map((h) => ({ hostName: h })),
|
||||||
|
(host) => netStore.getCredential(host)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import i18n from '@/i18n'
|
||||||
|
import { genTaskId } from '@shared/gen-task-id'
|
||||||
|
import { buildJobConfig } from '@/utils/buildJobConfig'
|
||||||
|
import { resolveJobTasks } from '@/utils/validateJobConfig'
|
||||||
|
import { buildNetInfoForForm } from '@/utils/copyNetworkHosts'
|
||||||
|
import { dllJobCreate, fsWriteJobCsv } from '@/api/cardsoon'
|
||||||
|
import { useJobStore } from '@/stores/job'
|
||||||
|
import type { DistributeFormState } from '@/stores/distributeForm'
|
||||||
|
|
||||||
|
function t(key: string, named?: Record<string, unknown>): string {
|
||||||
|
return i18n.global.t(key, named ?? {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function printFieldRows(form: DistributeFormState) {
|
||||||
|
return (form.templatePreview?.fields ?? []).map((f) => ({
|
||||||
|
originName: f.originName || f.label.replace(/\[.*\]$/, ''),
|
||||||
|
value: f.value ?? ''
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildJobJson(
|
||||||
|
form: DistributeFormState
|
||||||
|
): Promise<{ ok: true; json: string } | { ok: false; message: string }> {
|
||||||
|
const { hasCopy, hasPrint } = resolveJobTasks(form)
|
||||||
|
if (!hasCopy && !hasPrint) {
|
||||||
|
return { ok: false, message: t('validation.noTask') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskId = genTaskId()
|
||||||
|
let udfFile = ''
|
||||||
|
if (hasPrint) {
|
||||||
|
const rows = printFieldRows(form)
|
||||||
|
if (rows.length > 0) {
|
||||||
|
const csv = await fsWriteJobCsv({ taskId, rows })
|
||||||
|
if (!csv.ok || !csv.data?.path) {
|
||||||
|
return { ok: false, message: csv.message || t('validation.csvGenFailed') }
|
||||||
|
}
|
||||||
|
udfFile = csv.data.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let netInfo: Awaited<ReturnType<typeof buildNetInfoForForm>> = []
|
||||||
|
if (hasCopy) {
|
||||||
|
try {
|
||||||
|
netInfo = await buildNetInfoForForm(form)
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, message: e instanceof Error ? e.message : String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: JSON.stringify(buildJobConfig(form, { taskId, udfFile, netInfo }))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, message: e instanceof Error ? e.message : String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDistributeJob(
|
||||||
|
form: DistributeFormState,
|
||||||
|
opts?: { resubmit?: boolean }
|
||||||
|
): Promise<{ ok: true; jobId: string } | { ok: false; message: string }> {
|
||||||
|
const jobStore = useJobStore()
|
||||||
|
let json: string
|
||||||
|
|
||||||
|
if (opts?.resubmit && jobStore.lastJobJson) {
|
||||||
|
json = jobStore.lastJobJson
|
||||||
|
} else {
|
||||||
|
const built = await buildJobJson(form)
|
||||||
|
if (!built.ok) return built
|
||||||
|
json = built.json
|
||||||
|
jobStore.setLastJobJson(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await dllJobCreate(json, opts)
|
||||||
|
if (!created.ok || !created.data?.jobId) {
|
||||||
|
return { ok: false, message: created.message || t('validation.createJobFailed') }
|
||||||
|
}
|
||||||
|
return { ok: true, jobId: created.data.jobId }
|
||||||
|
}
|
||||||
@@ -58,7 +58,3 @@ export function mapJobStateToUi(jobState: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldUseProgress(jobState: number): boolean {
|
|
||||||
return jobState === 2 || jobState === 3
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
import type { DistributeFormState } from '@/stores/distributeForm'
|
import type { DistributeFormState } from '@/stores/distributeForm'
|
||||||
|
import i18n from '@/i18n'
|
||||||
|
|
||||||
|
function t(key: string, named?: Record<string, unknown>): string {
|
||||||
|
return i18n.global.t(key, named ?? {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveJobTasks(f: DistributeFormState): { hasCopy: boolean; hasPrint: boolean } {
|
||||||
|
const hasCopy = f.pathList.some((p) => !!p.path.trim())
|
||||||
|
const hasPrint = !!f.templateFile.trim()
|
||||||
|
return { hasCopy, hasPrint }
|
||||||
|
}
|
||||||
|
|
||||||
export function validateJobConfig(f: DistributeFormState): string | null {
|
export function validateJobConfig(f: DistributeFormState): string | null {
|
||||||
const hasCopy = f.pathList.length > 0
|
const { hasCopy, hasPrint } = resolveJobTasks(f)
|
||||||
const hasPrint = !!f.templateFile.trim()
|
if (!hasCopy && !hasPrint) return t('validation.noTask')
|
||||||
if (!hasCopy && !hasPrint) return '请配置拷贝路径或打印模板'
|
if (hasPrint && !/\.soon$/i.test(f.templateFile.trim())) return t('validation.invalidTemplate')
|
||||||
if (hasPrint && !/\.soon$/i.test(f.templateFile.trim())) return '请选择 .soon 模板'
|
if (hasCopy && f.pathList.some((p) => !p.path.trim())) return t('validation.pathEmpty')
|
||||||
if (hasCopy && f.pathList.some((p) => !p.path.trim())) return '路径不能为空'
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import type { DistributeFormState } from '@/stores/distributeForm'
|
||||||
|
import i18n from '@/i18n'
|
||||||
|
import { fsPathExists, dllPrinterStatus } from '@/api/cardsoon'
|
||||||
|
import { resolveJobTasks, validateJobConfig } from '@/utils/validateJobConfig'
|
||||||
|
import { resolveCopyNetworkHosts } from '@/utils/copyNetworkHosts'
|
||||||
|
import { useNetworkAuthStore } from '@/stores/networkAuth'
|
||||||
|
import { useConfigStore } from '@/stores/config'
|
||||||
|
|
||||||
|
function t(key: string, named?: Record<string, unknown>): string {
|
||||||
|
return i18n.global.t(key, named ?? {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalCopyBytes(form: DistributeFormState): number {
|
||||||
|
return form.pathList.reduce((sum, item) => sum + (item.sizeBytes || 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printFlagMismatch(printFlag: number, templateFlag: number): boolean {
|
||||||
|
return (
|
||||||
|
(printFlag === 1 && templateFlag !== 1) ||
|
||||||
|
(printFlag === 2 && templateFlag === 3) ||
|
||||||
|
(printFlag === 3 && templateFlag === 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交前打印机硬件状态预检(分发/收集共用)。
|
||||||
|
* - 返回非 null 字符串:拦截提交并提示(未连接 / 设备故障码,如夹卡)
|
||||||
|
* - 返回 null:放行(含 DLL 不支持状态查询、查询异常等无法判断的情况,交给后续流程)
|
||||||
|
* 注:夹卡等具体故障码表待 DLL 侧提供后,可在此按 statusCode 精确映射文案。
|
||||||
|
*/
|
||||||
|
export async function preflightPrinterStatus(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const r = await dllPrinterStatus()
|
||||||
|
if (!r.ok) {
|
||||||
|
const msg = r.message || ''
|
||||||
|
// DLL 不支持状态查询时无法判断,放行交给后续流程
|
||||||
|
if (msg.includes('不支持') || msg.toLowerCase().includes('not supported')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return t('validation.printerNotReady')
|
||||||
|
}
|
||||||
|
const data = r.data
|
||||||
|
if (!data) return t('validation.printerNotReady')
|
||||||
|
// 健康:主进程返回了明确的非负状态码(0 空闲 / 忙碌 / 打印中)
|
||||||
|
if (typeof data.statusCode === 'number' && data.statusCode >= 0) return null
|
||||||
|
// 缓存兜底 + liveError:实时查询失败(断连 / 故障码)
|
||||||
|
if (data.fromCache && data.liveError) return t('validation.printerNotReady')
|
||||||
|
// 无状态码(未初始化 / 未知异常):保守拦截
|
||||||
|
return t('validation.printerNotReady')
|
||||||
|
} catch {
|
||||||
|
// 查询异常时不阻塞,让后续流程处理
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateJobPreflight(form: DistributeFormState): Promise<string | null> {
|
||||||
|
const err = validateJobConfig(form)
|
||||||
|
if (err) return err
|
||||||
|
|
||||||
|
// 打印机硬件预检:打印/拷贝任务都需要设备,未连接或故障码(如夹卡)时拦截提交
|
||||||
|
const { hasCopy, hasPrint } = resolveJobTasks(form)
|
||||||
|
if (hasPrint || hasCopy) {
|
||||||
|
const fault = await preflightPrinterStatus()
|
||||||
|
if (fault) return fault
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasCopy) {
|
||||||
|
const paths = form.pathList.map((x) => x.path)
|
||||||
|
if (paths.length > 0) {
|
||||||
|
const ex = await fsPathExists(paths)
|
||||||
|
if (ex.ok && ex.data?.missing.length) {
|
||||||
|
return t('validation.pathNotExist', { paths: ex.data.missing.join(', ') })
|
||||||
|
}
|
||||||
|
if (totalCopyBytes(form) <= 0) {
|
||||||
|
return t('validation.noFilesToCopy')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hosts = await resolveCopyNetworkHosts(form)
|
||||||
|
if (hosts.length > 0) {
|
||||||
|
const netStore = useNetworkAuthStore()
|
||||||
|
for (const host of hosts) {
|
||||||
|
const cred = netStore.getCredential(host)
|
||||||
|
if (!cred?.userName?.trim() || !cred.password) {
|
||||||
|
return t('validation.netCredMissing', { host })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasPrint) {
|
||||||
|
const soon = form.templateFile.trim()
|
||||||
|
const ex = await fsPathExists([soon])
|
||||||
|
if (ex.ok && ex.data?.missing.length) {
|
||||||
|
return t('validation.templateNotExist')
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = form.templatePreview
|
||||||
|
if (preview && printFlagMismatch(form.printFlag, preview.templateFlag)) {
|
||||||
|
return t('validation.printFlagMismatch')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单面打印机:双面模板必须显式选择正面或背面,且不能选双面
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
if (configStore.printer.isSingleSide) {
|
||||||
|
if (preview && preview.templateFlag === 1 && form.printFlag !== 2 && form.printFlag !== 3) {
|
||||||
|
return t('validation.pickSideRequired')
|
||||||
|
}
|
||||||
|
if (form.printFlag === 1) {
|
||||||
|
return t('validation.singleSideMustPick')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.dongleEnabled) {
|
||||||
|
if (!form.dongleAuthCode.trim()) {
|
||||||
|
return t('validation.dongleAuthRequired')
|
||||||
|
}
|
||||||
|
const n = form.dongleInstallCount
|
||||||
|
if (!Number.isInteger(n) || n < 0 || n > 101) {
|
||||||
|
return t('validation.dongleCountInvalid')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<AppHeader mode="数据收集模式">
|
<AppHeader :mode="t('header.modeCollect')">
|
||||||
<div class="c-nav-group">
|
<div class="c-nav-group">
|
||||||
<NavButton icon="home" label="首页" @click="goHome" />
|
<NavButton icon="home" :label="t('common.home')" @click="goHome" />
|
||||||
<NavButton icon="trash" label="清空" @click="collectStore.reset()" />
|
<NavButton icon="trash" :label="t('common.clear')" @click="collectStore.reset()" />
|
||||||
<NavButton
|
<NavButton
|
||||||
icon="check-circle"
|
icon="check-circle"
|
||||||
label="提交"
|
:label="t('common.submit')"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:active="true"
|
:active="true"
|
||||||
:disabled="!canSubmit"
|
:disabled="!canSubmit"
|
||||||
@@ -18,32 +18,34 @@
|
|||||||
<section class="m-config-panel">
|
<section class="m-config-panel">
|
||||||
<h3 class="m-panel-title">
|
<h3 class="m-panel-title">
|
||||||
<AppIcon name="folder-open" size="sm" />
|
<AppIcon name="folder-open" size="sm" />
|
||||||
数据导入地址
|
{{ t('dataCollect.importPath') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="m-path-box">
|
<div class="m-path-box">
|
||||||
<span class="m-path-text">{{ collectStore.destPath || '未选择目录' }}</span>
|
<span class="m-path-text" :title="collectStore.destPath || undefined">{{
|
||||||
|
collectStore.destPath || t('dataCollect.noDirSelected')
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="m-path-btn" @click="addPath">
|
<button type="button" class="m-path-btn" @click="addPath">
|
||||||
<AppIcon name="plus" size="sm" />
|
<AppIcon name="plus" size="sm" />
|
||||||
添加路径
|
{{ t('common.addPath') }}
|
||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
<div class="m-divider-v" />
|
<div class="m-divider-v" />
|
||||||
<section class="m-config-panel">
|
<section class="m-config-panel">
|
||||||
<h3 class="m-panel-title">
|
<h3 class="m-panel-title">
|
||||||
<AppIcon name="exchange" size="sm" />
|
<AppIcon name="exchange" size="sm" />
|
||||||
出卡方向
|
{{ t('dataCollect.cardOutput') }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="m-radio-group">
|
<div class="m-radio-group">
|
||||||
<label class="m-radio-item">
|
<label class="m-radio-item">
|
||||||
<input v-model="collectStore.cardOutput" type="radio" :value="1" />
|
<input v-model="collectStore.cardOutput" type="radio" :value="1" />
|
||||||
<span class="radio-custom" />
|
<span class="radio-custom" />
|
||||||
<span>向前出卡</span>
|
<span>{{ t('dataCollect.forwardOutput') }}</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="m-radio-item">
|
<label class="m-radio-item">
|
||||||
<input v-model="collectStore.cardOutput" type="radio" :value="2" />
|
<input v-model="collectStore.cardOutput" type="radio" :value="2" />
|
||||||
<span class="radio-custom" />
|
<span class="radio-custom" />
|
||||||
<span>向后出卡</span>
|
<span>{{ t('dataCollect.backwardOutput') }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -55,6 +57,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 { useI18n } from 'vue-i18n'
|
||||||
import { notify, notifyRequireInit } from '@/composables/useNotify'
|
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'
|
||||||
@@ -63,9 +66,11 @@ import NavButton from '@/components/NavButton.vue'
|
|||||||
import AppIcon from '@/components/AppIcon.vue'
|
import AppIcon from '@/components/AppIcon.vue'
|
||||||
import { useCollectStore } from '@/stores/collect'
|
import { useCollectStore } from '@/stores/collect'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { dialogOpenDirectory, dllUsbCopy, pollUsbStart } from '@/api/cardsoon'
|
import { dialogOpenDirectory, dllUsbCopy } from '@/api/cardsoon'
|
||||||
|
import { preflightPrinterStatus } from '@/utils/validateJobPreflight'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
const collectStore = useCollectStore()
|
const collectStore = useCollectStore()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
@@ -77,7 +82,7 @@ const canSubmit = computed(
|
|||||||
async function addPath(): Promise<void> {
|
async function addPath(): Promise<void> {
|
||||||
const r = await dialogOpenDirectory()
|
const r = await dialogOpenDirectory()
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
notify.error(r.message || '打开目录选择失败')
|
notify.error(r.message || t('notify.pathOpenFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const picked = r.data?.paths[0]
|
const picked = r.data?.paths[0]
|
||||||
@@ -86,31 +91,36 @@ async function addPath(): Promise<void> {
|
|||||||
|
|
||||||
async function onSubmit(): Promise<void> {
|
async function onSubmit(): Promise<void> {
|
||||||
if (!canUse.value) {
|
if (!canUse.value) {
|
||||||
notifyRequireInit('开始 USB 收集')
|
notifyRequireInit(t('notify.startUsbFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (appStore.mode === 'distributing') {
|
if (appStore.mode === 'distributing') {
|
||||||
notify.warning('请先停止数据分发任务')
|
notify.warning(t('notify.stopDistribute'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const dest = collectStore.destPath.trim()
|
const dest = collectStore.destPath.trim()
|
||||||
if (!dest) {
|
if (!dest) {
|
||||||
notify.warning('请先选择数据导入目录')
|
notify.warning(t('notify.selectDirFirst'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 打印机硬件预检:未连接或故障码(如夹卡)时拦截提交
|
||||||
|
const fault = await preflightPrinterStatus()
|
||||||
|
if (fault) {
|
||||||
|
notify.warning(fault)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const r = await dllUsbCopy(dest, collectStore.cardOutput)
|
const r = await dllUsbCopy(dest, collectStore.cardOutput)
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
notify.error(r.message || '可能已有任务在执行')
|
notify.error(r.message || t('notify.startUsbFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
appStore.setMode('usbCopying')
|
appStore.setMode('usbCopying')
|
||||||
await pollUsbStart()
|
|
||||||
await router.push('/collect/running')
|
await router.push('/collect/running')
|
||||||
}
|
}
|
||||||
|
|
||||||
function goHome(): void {
|
function goHome(): void {
|
||||||
if (appStore.mode === 'usbCopying') {
|
if (appStore.mode === 'usbCopying') {
|
||||||
notify.warning('数据收集进行中,请先在任务页停止')
|
notify.warning(t('notify.usbCollectInProgress'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
router.push('/home')
|
router.push('/home')
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<AppHeader mode="数据分发模式">
|
<AppHeader :mode="t('header.modeDistribute')">
|
||||||
<div class="c-nav-group">
|
<div class="c-nav-group">
|
||||||
<NavButton icon="home" label="首页" @click="router.push('/home')" />
|
<NavButton icon="home" :label="t('common.home')" @click="router.push('/home')" />
|
||||||
<NavButton icon="trash" label="清空" @click="onClear" />
|
<NavButton icon="trash" :label="t('common.clear')" @click="onClear" />
|
||||||
<NavButton
|
<NavButton
|
||||||
icon="check-circle"
|
icon="check-circle"
|
||||||
label="提交"
|
:label="t('common.submit')"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
:active="true"
|
:active="true"
|
||||||
:disabled="!canSubmit"
|
:disabled="!canSubmit"
|
||||||
@@ -17,43 +17,77 @@
|
|||||||
<main class="app-shell__main l-main-flex">
|
<main class="app-shell__main l-main-flex">
|
||||||
<section class="c-panel m-panel--left">
|
<section class="c-panel m-panel--left">
|
||||||
<div class="c-panel__header">
|
<div class="c-panel__header">
|
||||||
<span class="c-panel__title">路径配置</span>
|
<span class="c-panel__title">{{ t('distributeConfig.pathConfig') }}</span>
|
||||||
<div class="c-nav-group">
|
<div class="c-nav-group">
|
||||||
<button type="button" class="c-button-cs" @click="addPath">
|
<button type="button" class="c-button-cs" @click="addPath">
|
||||||
添加路径
|
{{ t('common.addPath') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="c-button-cs" @click="openNetworkDialog">
|
||||||
|
{{ t('distributeConfig.addNetworkLocation') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="m-path-hint">
|
<div class="m-path-hint">
|
||||||
<AppIcon name="info-circle" size="sm" />
|
<AppIcon name="info-circle" size="sm" />
|
||||||
<span>系统将拷贝该目录下的所有子项,但不包含文件夹本身</span>
|
<span>{{ t('distributeConfig.pathHint') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="configuredHosts.length" class="m-net-cred-hint">
|
||||||
|
{{ t('distributeConfig.netCredConfigured', { hosts: configuredHosts.join('、') }) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="m-panel-toolbar">
|
<div class="m-panel-toolbar">
|
||||||
<div class="toolbar-row">
|
<div class="toolbar-row">
|
||||||
<div class="toolbar-item">
|
<div class="toolbar-item">
|
||||||
<span>卷标</span>
|
<span>{{ t('distributeConfig.volumeLabel') }}</span>
|
||||||
<input v-model="formStore.volumeLabel" type="text" class="c-input" />
|
<input v-model="formStore.volumeLabel" type="text" class="c-input" />
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-item">
|
<div class="toolbar-item">
|
||||||
<span>拷贝类型</span>
|
<span>{{ t('distributeConfig.copyType') }}</span>
|
||||||
<AppSelect v-model="formStore.copyType" :items="COPY_TYPE_OPTIONS" />
|
<AppSelect v-model="formStore.copyType" :items="copyTypeItems" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-row">
|
<div class="toolbar-row">
|
||||||
<div class="toolbar-item">
|
<div class="toolbar-item">
|
||||||
<span>格式化类型</span>
|
<span>{{ t('distributeConfig.formatType') }}</span>
|
||||||
<AppSelect v-model="formStore.formatType" :items="FORMAT_TYPE_OPTIONS" />
|
<AppSelect v-model="formStore.formatType" :items="formatTypeItems" />
|
||||||
</div>
|
</div>
|
||||||
<label class="c-checkbox-item">
|
<label class="c-checkbox-item">
|
||||||
<input v-model="formStore.dongleEnabled" type="checkbox" />
|
|
||||||
<span>加密狗</span>
|
|
||||||
<input
|
<input
|
||||||
v-model.number="formStore.dongleMode"
|
v-model="formStore.dongleEnabled"
|
||||||
|
type="checkbox"
|
||||||
|
@change="onDongleToggle"
|
||||||
|
/>
|
||||||
|
<span>{{ t('distributeConfig.dongle') }}</span>
|
||||||
|
<input
|
||||||
|
:value="dongleInputValue"
|
||||||
type="number"
|
type="number"
|
||||||
class="c-input dog-count"
|
class="c-input dog-count"
|
||||||
|
min="0"
|
||||||
|
max="101"
|
||||||
|
step="1"
|
||||||
:disabled="!formStore.dongleEnabled"
|
:disabled="!formStore.dongleEnabled"
|
||||||
|
@input="onDongleCountInput"
|
||||||
/>
|
/>
|
||||||
<span class="dog-hint">{{ dongleHint }}</span>
|
<span class="dog-hint">{{ dongleHint }}</span>
|
||||||
|
<template v-if="formStore.dongleEnabled">
|
||||||
|
<span class="dog-auth-label">{{ t('distributeConfig.dongleAuthLabel') }}</span>
|
||||||
|
<div class="dog-auth-wrap">
|
||||||
|
<input
|
||||||
|
v-model="formStore.dongleAuthCode"
|
||||||
|
:type="showAuthCode ? 'text' : 'password'"
|
||||||
|
class="c-input dog-auth"
|
||||||
|
:placeholder="t('distributeConfig.donglePassword')"
|
||||||
|
@input="onDongleAuthInput"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dog-auth-toggle"
|
||||||
|
:title="showAuthCode ? t('distributeConfig.hideAuth') : t('distributeConfig.showAuth')"
|
||||||
|
@click="showAuthCode = !showAuthCode"
|
||||||
|
>
|
||||||
|
<AppIcon :name="showAuthCode ? 'eye-slash' : 'eye'" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,15 +111,51 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="c-panel m-panel--right">
|
<section class="c-panel m-panel--right">
|
||||||
<div class="c-panel__header">
|
<div class="c-panel__header">
|
||||||
<span class="c-panel__title">标签预览</span>
|
<span class="c-panel__title">{{ t('distributeConfig.templatePreview') }}</span>
|
||||||
<div class="c-nav-group">
|
<div class="c-nav-group">
|
||||||
<button type="button" class="c-button-cs" :disabled="!canUse" @click="pickTemplate">
|
<button type="button" class="c-button-cs" @click="pickTemplate">
|
||||||
添加标签
|
{{ t('distributeConfig.addLabel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="formStore.templateFile"
|
||||||
|
type="button"
|
||||||
|
class="c-button-cs"
|
||||||
|
@click="removeTemplate"
|
||||||
|
>
|
||||||
|
{{ t('distributeConfig.removeLabel') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="templatePreviewLoaded && hasDoubleSide" class="m-print-side-picker">
|
||||||
|
<template v-if="isSingleSidePrinter">
|
||||||
|
<label class="m-print-side-option">
|
||||||
|
<input v-model="formStore.printFlag" type="radio" :value="2" />
|
||||||
|
<span>{{ t('distributeConfig.frontSide') }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="m-print-side-option">
|
||||||
|
<input v-model="formStore.printFlag" type="radio" :value="3" />
|
||||||
|
<span>{{ t('distributeConfig.backSide') }}</span>
|
||||||
|
</label>
|
||||||
|
<span v-if="!sidePicked" class="m-print-side-hint">
|
||||||
|
{{ t('distributeConfig.singleSideHint') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<label class="m-print-side-option">
|
||||||
|
<input v-model="formStore.printFlag" type="radio" :value="1" />
|
||||||
|
<span>{{ t('distributeConfig.doubleSide') }}</span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
<div class="c-preview-area">
|
<div class="c-preview-area">
|
||||||
<div class="c-card-small c-card-small--slot">
|
<div
|
||||||
|
class="c-card-small c-card-small--slot c-card-side-pick"
|
||||||
|
:class="{
|
||||||
|
'c-card-side--dim': formStore.printFlag === 3,
|
||||||
|
'c-card-side-pick--off': !canPickFront
|
||||||
|
}"
|
||||||
|
@click="selectPrintSide(2)"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
v-if="formStore.templatePreview?.frontImageUrl"
|
v-if="formStore.templatePreview?.frontImageUrl"
|
||||||
class="c-card-small__img"
|
class="c-card-small__img"
|
||||||
@@ -94,7 +164,14 @@
|
|||||||
/>
|
/>
|
||||||
<span v-else class="c-card-side-label">FRONT</span>
|
<span v-else class="c-card-side-label">FRONT</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="c-card-small c-card-small--slot c-card-small--back">
|
<div
|
||||||
|
class="c-card-small c-card-small--slot c-card-small--back c-card-side-pick"
|
||||||
|
:class="{
|
||||||
|
'c-card-side--dim': formStore.printFlag === 2,
|
||||||
|
'c-card-side-pick--off': !canPickBack
|
||||||
|
}"
|
||||||
|
@click="selectPrintSide(3)"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
v-if="formStore.templatePreview?.backImageUrl"
|
v-if="formStore.templatePreview?.backImageUrl"
|
||||||
class="c-card-small__img"
|
class="c-card-small__img"
|
||||||
@@ -109,7 +186,21 @@
|
|||||||
<table class="c-data-table-mini">
|
<table class="c-data-table-mini">
|
||||||
<tr v-for="(row, idx) in formStore.templatePreview!.fields" :key="idx">
|
<tr v-for="(row, idx) in formStore.templatePreview!.fields" :key="idx">
|
||||||
<td>{{ row.label }}</td>
|
<td>{{ row.label }}</td>
|
||||||
<td>
|
<td v-if="isImageField(row)" class="c-path-cell">
|
||||||
|
<span class="c-path-cell__text" :title="row.value">{{
|
||||||
|
imageFieldLabel(row.value)
|
||||||
|
}}</span>
|
||||||
|
<button type="button" class="c-field-pick" @click="pickFieldImage(idx)">{{ t('common.select') }}</button>
|
||||||
|
</td>
|
||||||
|
<td v-else-if="isTextField(row)">
|
||||||
|
<input
|
||||||
|
v-model="row.value"
|
||||||
|
type="text"
|
||||||
|
class="c-field-input"
|
||||||
|
:placeholder="row.label"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td v-else>
|
||||||
<span class="c-field-value" :title="row.value">{{ row.value || '—' }}</span>
|
<span class="c-field-value" :title="row.value">{{ row.value || '—' }}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -119,12 +210,21 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<AppFooter />
|
<AppFooter />
|
||||||
|
<NetworkPathDialog
|
||||||
|
v-model:visible="networkDialogVisible"
|
||||||
|
:initial-host="networkDialogHost"
|
||||||
|
:initial-share="networkDialogShare"
|
||||||
|
@confirm="onNetworkConfirm"
|
||||||
|
/>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import { cleanPathPattern } from '@shared/path-pattern'
|
||||||
|
import { extractDriveLetter } from '@shared/network-host'
|
||||||
import { notify, notifyRequireInit } from '@/composables/useNotify'
|
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'
|
||||||
@@ -132,61 +232,194 @@ 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 AppSelect from '@/components/AppSelect.vue'
|
import AppSelect from '@/components/AppSelect.vue'
|
||||||
import { COPY_TYPE_OPTIONS, FORMAT_TYPE_OPTIONS } from '@/constants/selectOptions'
|
import NetworkPathDialog from '@/components/NetworkPathDialog.vue'
|
||||||
|
import { copyTypeOptions, formatTypeOptions } from '@/constants/selectOptions'
|
||||||
import { CARD_CAPACITY_BYTES, CARD_CAPACITY_GB } from '@/constants/cardCapacity'
|
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 { useConfigStore } from '@/stores/config'
|
||||||
import { buildJobConfig } from '@/utils/buildJobConfig'
|
import { useNetworkAuthStore } from '@/stores/networkAuth'
|
||||||
|
import { useDongleAuthStore } from '@/stores/dongleAuth'
|
||||||
|
import { validateJobPreflight } from '@/utils/validateJobPreflight'
|
||||||
|
import { createDistributeJob } from '@/utils/createDistributeJob'
|
||||||
import { formatBytesAsGb, formatBytesCompact } from '@/utils/formatBytes'
|
import { formatBytesAsGb, formatBytesCompact } from '@/utils/formatBytes'
|
||||||
import { dialogOpenDirectory, dialogOpenSoon, dllJobCreate, fsDirSize, fsParseSoon, fsPathExists } from '@/api/cardsoon'
|
import type { TemplateFieldRow } from '@/stores/distributeForm'
|
||||||
|
import {
|
||||||
|
dialogOpenDirectory,
|
||||||
|
dialogOpenImage,
|
||||||
|
dialogOpenSoon,
|
||||||
|
dllJobCancel,
|
||||||
|
fsDirSize,
|
||||||
|
fsParseSoon,
|
||||||
|
fsResolveNetworkHosts
|
||||||
|
} from '@/api/cardsoon'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
const formStore = useDistributeFormStore()
|
const formStore = useDistributeFormStore()
|
||||||
const jobStore = useJobStore()
|
const jobStore = useJobStore()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const configStore = useConfigStore()
|
||||||
|
const netStore = useNetworkAuthStore()
|
||||||
|
const dongleStore = useDongleAuthStore()
|
||||||
|
|
||||||
|
const networkDialogVisible = ref(false)
|
||||||
|
const networkDialogHost = ref('')
|
||||||
|
const networkDialogShare = ref('')
|
||||||
|
const showAuthCode = ref(false)
|
||||||
|
|
||||||
|
// 单面打印机检测到后,重置 printFlag(避免默认双面)
|
||||||
|
watch(
|
||||||
|
() => configStore.printer.isSingleSide,
|
||||||
|
(isSingle) => {
|
||||||
|
if (isSingle && formStore.printFlag === 1) {
|
||||||
|
const flag = formStore.templatePreview?.templateFlag ?? 1
|
||||||
|
formStore.printFlag = defaultPrintFlagForTemplate(flag, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
let dongleAuthPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const canUse = computed(() => appStore.initialized)
|
const canUse = computed(() => appStore.initialized)
|
||||||
const canSubmit = computed(
|
const canSubmit = computed(
|
||||||
() => canUse.value && !jobStore.submitting && appStore.mode !== 'usbCopying'
|
() => canUse.value && !jobStore.submitting && appStore.mode !== 'usbCopying'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const configuredHosts = computed(() => netStore.configuredHosts)
|
||||||
|
|
||||||
const totalLoadedBytes = computed(() =>
|
const totalLoadedBytes = computed(() =>
|
||||||
formStore.pathList.reduce((sum, item) => sum + (item.sizeBytes || 0), 0)
|
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(100, Math.round((totalLoadedBytes.value / CARD_CAPACITY_BYTES) * 100))
|
const pct = Math.min(100, Math.round((totalLoadedBytes.value / CARD_CAPACITY_BYTES) * 100))
|
||||||
|
return pct > 0 ? pct : 1
|
||||||
})
|
})
|
||||||
|
|
||||||
const hasTemplatePreview = computed(
|
const hasTemplatePreview = computed(
|
||||||
() => !!formStore.templatePreview && formStore.templatePreview.fields.length > 0
|
() => !!formStore.templatePreview && formStore.templatePreview.fields.length > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const templateFlag = computed(() => formStore.templatePreview?.templateFlag ?? 0)
|
||||||
|
|
||||||
|
const hasDoubleSide = computed(() => templateFlag.value === 1)
|
||||||
|
|
||||||
|
const isSingleSidePrinter = computed(() => configStore.printer.isSingleSide)
|
||||||
|
|
||||||
|
/** 模板已加载(无论有无可编辑字段),用于选面区显示 */
|
||||||
|
const templatePreviewLoaded = computed(() => !!formStore.templatePreview)
|
||||||
|
|
||||||
|
/** 单面打印机 + 双面模板时,必须显式选择正面或背面 */
|
||||||
|
const sidePicked = computed(
|
||||||
|
() => formStore.printFlag === 2 || formStore.printFlag === 3
|
||||||
|
)
|
||||||
|
|
||||||
|
const copyTypeItems = computed(() => copyTypeOptions(t))
|
||||||
|
const formatTypeItems = computed(() => formatTypeOptions(t))
|
||||||
|
|
||||||
|
const canPickFront = computed(() => templateFlag.value !== 3)
|
||||||
|
|
||||||
|
const canPickBack = computed(() => templateFlag.value !== 2)
|
||||||
|
|
||||||
const loadProgressText = computed(() => {
|
const loadProgressText = computed(() => {
|
||||||
const loadedGb = formatBytesAsGb(totalLoadedBytes.value)
|
const loadedGb = formatBytesAsGb(totalLoadedBytes.value)
|
||||||
return `已加载: ${loadedGb} GB / ${CARD_CAPACITY_GB} GB (${loadPercent.value}%)`
|
return t('distributeConfig.loadProgress', { loaded: loadedGb, total: CARD_CAPACITY_GB, percent: loadPercent.value })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function isTextField(row: TemplateFieldRow): boolean {
|
||||||
|
return row.fieldType === 3 || row.fieldType === 4 || row.fieldType === 5
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageField(row: TemplateFieldRow): boolean {
|
||||||
|
return row.fieldType === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageFieldLabel(value: string): string {
|
||||||
|
const v = value.trim()
|
||||||
|
if (!v) return '未选择图片'
|
||||||
|
const parts = v.replace(/\\/g, '/').split('/')
|
||||||
|
return parts[parts.length - 1] || v
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPrintFlagForTemplate(flag: number, isSingleSide: boolean): number {
|
||||||
|
// 单面打印机 + 双面模板:不默认,强制用户选择打印面(0=未选择)
|
||||||
|
if (isSingleSide && flag === 1) return 0
|
||||||
|
if (flag === 1 || flag === 2 || flag === 3) return flag
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPrintSide(flag: 2 | 3): void {
|
||||||
|
if (flag === 2 && !canPickFront.value) return
|
||||||
|
if (flag === 3 && !canPickBack.value) return
|
||||||
|
formStore.printFlag = flag
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFieldImage(idx: number): Promise<void> {
|
||||||
|
const preview = formStore.templatePreview
|
||||||
|
if (!preview) return
|
||||||
|
const row = preview.fields[idx]
|
||||||
|
if (!row || !isImageField(row)) return
|
||||||
|
const r = await dialogOpenImage()
|
||||||
|
if (!r.ok) {
|
||||||
|
notify.error(r.message || '打开图片选择失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const picked = r.data?.path?.trim()
|
||||||
|
if (!picked) return
|
||||||
|
row.value = picked
|
||||||
|
}
|
||||||
|
|
||||||
function onClear(): void {
|
function onClear(): void {
|
||||||
formStore.reset()
|
formStore.reset()
|
||||||
notify.info('已清空,已恢复初始状态')
|
notify.info(t('distributeConfig.cleared'))
|
||||||
}
|
}
|
||||||
|
|
||||||
const dongleHint = computed(() => {
|
function removeTemplate(): void {
|
||||||
if (!formStore.dongleEnabled) return '(未启用)'
|
formStore.templateFile = ''
|
||||||
if (formStore.dongleMode === -1) return '(-1 无加密狗)'
|
formStore.templatePreview = null
|
||||||
if (formStore.dongleMode === 0) return '(0 一次性)'
|
formStore.printFlag = 1
|
||||||
if (formStore.dongleMode === 255) return '(255 无限次)'
|
notify.info(t('distributeConfig.templateRemoved'))
|
||||||
return '(0=一次 255=无限)'
|
}
|
||||||
})
|
|
||||||
|
const dongleInputValue = computed(() =>
|
||||||
|
formStore.dongleEnabled ? formStore.dongleInstallCount : -1
|
||||||
|
)
|
||||||
|
|
||||||
|
const dongleHint = computed(() =>
|
||||||
|
formStore.dongleEnabled ? '(101 为不限次数)' : ''
|
||||||
|
)
|
||||||
|
|
||||||
|
function clampDongleCount(n: number): number {
|
||||||
|
return Math.min(101, Math.max(0, Math.round(n)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDongleToggle(): void {
|
||||||
|
if (formStore.dongleEnabled) {
|
||||||
|
formStore.dongleInstallCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDongleCountInput(e: Event): void {
|
||||||
|
if (!formStore.dongleEnabled) return
|
||||||
|
const raw = Number((e.target as HTMLInputElement).value)
|
||||||
|
if (!Number.isFinite(raw)) return
|
||||||
|
formStore.dongleInstallCount = clampDongleCount(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDongleAuthInput(): void {
|
||||||
|
if (dongleAuthPersistTimer) clearTimeout(dongleAuthPersistTimer)
|
||||||
|
dongleAuthPersistTimer = setTimeout(() => {
|
||||||
|
void dongleStore.persist(formStore.dongleAuthCode)
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
async function addPath(): Promise<void> {
|
async function addPath(): Promise<void> {
|
||||||
const r = await dialogOpenDirectory()
|
const r = await dialogOpenDirectory()
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
notify.error(r.message || '打开目录选择失败')
|
notify.error(r.message || t('notify.pathOpenFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!r.data?.paths.length) return
|
if (!r.data?.paths.length) return
|
||||||
@@ -194,7 +427,7 @@ async function addPath(): Promise<void> {
|
|||||||
const idx = formStore.pathList.length
|
const idx = formStore.pathList.length
|
||||||
formStore.pathList.push({
|
formStore.pathList.push({
|
||||||
path: `${dir}\\*.*`,
|
path: `${dir}\\*.*`,
|
||||||
meta: '计算中…',
|
meta: t('distributeConfig.calculating'),
|
||||||
sizeBytes: 0
|
sizeBytes: 0
|
||||||
})
|
})
|
||||||
await refreshPathSize(idx, dir)
|
await refreshPathSize(idx, dir)
|
||||||
@@ -218,11 +451,42 @@ function removePath(idx: number): void {
|
|||||||
formStore.pathList.splice(idx, 1)
|
formStore.pathList.splice(idx, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickTemplate(): Promise<void> {
|
async function openNetworkDialog(): Promise<void> {
|
||||||
if (!canUse.value) {
|
networkDialogHost.value = ''
|
||||||
notifyRequireInit('选择打印模板')
|
networkDialogShare.value = ''
|
||||||
return
|
for (const item of formStore.pathList) {
|
||||||
|
const dir = cleanPathPattern(item.path)
|
||||||
|
const letter = extractDriveLetter(dir)
|
||||||
|
if (!letter) continue
|
||||||
|
const r = await fsResolveNetworkHosts([dir])
|
||||||
|
if (r.ok && r.data?.driveHostMap?.[letter]) {
|
||||||
|
networkDialogHost.value = r.data.driveHostMap[letter]
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
if (r.ok && r.data?.hosts?.length) {
|
||||||
|
networkDialogHost.value = r.data.hosts[0]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
networkDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNetworkConfirm(payload: {
|
||||||
|
path: string
|
||||||
|
hostName: string
|
||||||
|
userName: string
|
||||||
|
password: string
|
||||||
|
}): void {
|
||||||
|
netStore.setCredential(payload.hostName, payload.userName, payload.password)
|
||||||
|
for (const item of formStore.pathList) {
|
||||||
|
const letter = extractDriveLetter(cleanPathPattern(item.path))
|
||||||
|
if (letter) netStore.setDriveHost(letter, payload.hostName)
|
||||||
|
}
|
||||||
|
networkDialogVisible.value = false
|
||||||
|
notify.success('网络凭据已保存')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickTemplate(): Promise<void> {
|
||||||
const r = await dialogOpenSoon()
|
const r = await dialogOpenSoon()
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
notify.error(r.message || '打开模板选择失败')
|
notify.error(r.message || '打开模板选择失败')
|
||||||
@@ -237,12 +501,16 @@ async function pickTemplate(): Promise<void> {
|
|||||||
}
|
}
|
||||||
formStore.templateFile = soonPath
|
formStore.templateFile = soonPath
|
||||||
formStore.templatePreview = parsed.data
|
formStore.templatePreview = parsed.data
|
||||||
|
formStore.printFlag = defaultPrintFlagForTemplate(
|
||||||
|
parsed.data.templateFlag,
|
||||||
|
isSingleSidePrinter.value
|
||||||
|
)
|
||||||
const { fields, frontImageUrl, backImageUrl } = parsed.data
|
const { fields, frontImageUrl, backImageUrl } = parsed.data
|
||||||
if (!fields.length && !frontImageUrl && !backImageUrl) {
|
if (!fields.length && !frontImageUrl && !backImageUrl) {
|
||||||
notify.warning('模板已打开,但未解析到可预览内容')
|
notify.warning(t('distributeConfig.templateNoPreview'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
notify.success('已加载标签模板')
|
notify.success(t('distributeConfig.templateLoaded'))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSubmit(): Promise<void> {
|
async function onSubmit(): Promise<void> {
|
||||||
@@ -255,37 +523,31 @@ async function onSubmit(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (jobStore.submitting) return
|
if (jobStore.submitting) return
|
||||||
const err = validateJobConfig(formStore)
|
const err = await validateJobPreflight(formStore)
|
||||||
if (err) {
|
if (err) {
|
||||||
notify.warning(err)
|
notify.warning(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const paths = formStore.pathList.map((x) => x.path)
|
|
||||||
if (paths.length) {
|
|
||||||
const ex = await fsPathExists(paths)
|
|
||||||
if (ex.ok && ex.data?.missing.length) {
|
|
||||||
notify.error(`路径不存在: ${ex.data.missing.join(', ')}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (formStore.templateFile.trim()) {
|
|
||||||
const ex = await fsPathExists([formStore.templateFile.trim()])
|
|
||||||
if (ex.ok && ex.data?.missing.length) {
|
|
||||||
notify.error('模板文件不存在')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jobStore.submitting = true
|
jobStore.submitting = true
|
||||||
|
// 新任务开始前重置次数计数器(不重置 jobId/lastJobJson,避免影响并发逻辑)
|
||||||
|
jobStore.resetCounts()
|
||||||
try {
|
try {
|
||||||
const json = JSON.stringify(buildJobConfig(formStore))
|
const created = await createDistributeJob(formStore)
|
||||||
const created = await dllJobCreate(json)
|
if (!created.ok) {
|
||||||
if (!created.ok || !created.data?.jobId) {
|
notify.error(created.message)
|
||||||
notify.error(created.message || '创建任务失败')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jobStore.setActiveJob(created.data.jobId)
|
const newJobId = created.jobId
|
||||||
|
jobStore.setActiveJob(newJobId)
|
||||||
appStore.setMode('distributing')
|
appStore.setMode('distributing')
|
||||||
|
try {
|
||||||
await router.push('/distribute/running')
|
await router.push('/distribute/running')
|
||||||
|
} catch {
|
||||||
|
await dllJobCancel(newJobId)
|
||||||
|
jobStore.clearActiveJob()
|
||||||
|
appStore.setMode('ready')
|
||||||
|
notify.error(t('notify.cannotEnterRunning'))
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
jobStore.submitting = false
|
jobStore.submitting = false
|
||||||
}
|
}
|
||||||
@@ -295,6 +557,32 @@ async function onSubmit(): Promise<void> {
|
|||||||
<style src="@/styles/pages/page4.css"></style>
|
<style src="@/styles/pages/page4.css"></style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.m-net-cred-hint {
|
||||||
|
margin: 0 8px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-print-side-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 6px 12px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #606266;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-print-side-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-print-side-hint {
|
||||||
|
color: #e6a23c;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.c-card-small--slot {
|
.c-card-small--slot {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -322,10 +610,15 @@ async function onSubmit(): Promise<void> {
|
|||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.c-field-value {
|
.c-card-side-pick {
|
||||||
display: block;
|
cursor: pointer;
|
||||||
overflow: hidden;
|
}
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
.c-card-side-pick--off {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c-card-side--dim {
|
||||||
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
<template>
|
|
||||||
<AppShell>
|
|
||||||
<AppHeader mode="数据分发模式">
|
|
||||||
<div class="c-nav-group">
|
|
||||||
<NavButton icon="arrow-left" label="返回" @click="onBack" />
|
|
||||||
<NavButton icon="redo" label="重置" variant="primary" @click="onReset" />
|
|
||||||
</div>
|
|
||||||
</AppHeader>
|
|
||||||
<main class="app-shell__main l-main-full">
|
|
||||||
<section class="l-hero-container">
|
|
||||||
<div class="m-left-panel">
|
|
||||||
<div class="c-status-panel">
|
|
||||||
<h2 class="c-status-title is-error">任务失败</h2>
|
|
||||||
<p class="c-status-sub">请检查设备故障后重新插入数据卡</p>
|
|
||||||
<p class="c-status-counter">
|
|
||||||
任务已经完成<span class="ok">{{ jobStore.successCount }}</span
|
|
||||||
>次,其中失败次数是<span class="err">{{ jobStore.failCount }}</span>。
|
|
||||||
</p>
|
|
||||||
<p v-if="errorText" class="m-error-detail">{{ errorText }}</p>
|
|
||||||
</div>
|
|
||||||
<WorkflowSteps mode="failed" :failed-step="3" />
|
|
||||||
</div>
|
|
||||||
<div class="m-right-panel">
|
|
||||||
<div class="m-error-icon">
|
|
||||||
<AppIcon name="warning" size="xl" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<AppFooter />
|
|
||||||
</AppShell>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import AppShell from '@/layouts/AppShell.vue'
|
|
||||||
import AppHeader from '@/components/AppHeader.vue'
|
|
||||||
import AppFooter from '@/components/AppFooter.vue'
|
|
||||||
import NavButton from '@/components/NavButton.vue'
|
|
||||||
import AppIcon from '@/components/AppIcon.vue'
|
|
||||||
import WorkflowSteps from '@/components/WorkflowSteps.vue'
|
|
||||||
import { useDistributeFormStore } from '@/stores/distributeForm'
|
|
||||||
import { useJobStore } from '@/stores/job'
|
|
||||||
import { useAppStore } from '@/stores/app'
|
|
||||||
import { dllPrinterErrorStr, pollJobStop } from '@/api/cardsoon'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const formStore = useDistributeFormStore()
|
|
||||||
const jobStore = useJobStore()
|
|
||||||
const appStore = useAppStore()
|
|
||||||
const errorText = ref('')
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
appStore.setMode('ready')
|
|
||||||
await pollJobStop()
|
|
||||||
const r = await dllPrinterErrorStr(-1)
|
|
||||||
errorText.value = r.ok && r.data?.text ? r.data.text : ''
|
|
||||||
})
|
|
||||||
|
|
||||||
function onBack(): void {
|
|
||||||
jobStore.clearActiveJob()
|
|
||||||
router.push('/distribute/config')
|
|
||||||
}
|
|
||||||
|
|
||||||
function onReset(): void {
|
|
||||||
formStore.reset()
|
|
||||||
jobStore.reset()
|
|
||||||
router.push('/home')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style src="@/styles/pages/page3.css"></style>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.m-error-detail {
|
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: #dc3545;
|
|
||||||
font-weight: 600;
|
|
||||||
max-width: 320px;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,29 +1,58 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<AppHeader :mode="headerMode">
|
<AppHeader :mode="headerMode">
|
||||||
<NavButton icon="stop" label="停止" variant="stop" @click="onStop" />
|
<div v-if="phase === 'failed'" class="c-nav-group">
|
||||||
|
<NavButton icon="arrow-left" :label="t('common.back')" @click="onFailedBack" />
|
||||||
|
<NavButton icon="redo" :label="t('common.reset')" variant="primary" @click="onFailedReset" />
|
||||||
|
</div>
|
||||||
|
<NavButton
|
||||||
|
v-else-if="phase === 'completed'"
|
||||||
|
icon="home"
|
||||||
|
:label="t('common.back')"
|
||||||
|
@click="onReturn"
|
||||||
|
/>
|
||||||
|
<NavButton v-else icon="stop" :label="t('common.stop')" variant="stop" @click="onStop" />
|
||||||
</AppHeader>
|
</AppHeader>
|
||||||
<main class="app-shell__main l-main-full">
|
<main class="app-shell__main l-main-full">
|
||||||
<section class="l-hero-container">
|
<section class="l-hero-container">
|
||||||
<div class="m-left-panel">
|
<div class="m-left-panel">
|
||||||
<div class="c-status-panel">
|
<div class="c-status-panel">
|
||||||
<h2 class="c-status-title is-looping">{{ statusTitle }}</h2>
|
<template v-if="phase === 'failed'">
|
||||||
|
<h2 class="c-status-title is-error">{{ t('running.taskFailed') }}</h2>
|
||||||
|
<p class="c-status-sub">{{ failedSub }}</p>
|
||||||
|
<p class="c-status-counter">
|
||||||
|
{{ t('running.progressCounter', { success: successCount, fail: failCount }) }}
|
||||||
|
</p>
|
||||||
|
<p v-if="failureErrorText" class="m-error-detail">{{ failureErrorText }}</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<h2 class="c-status-title" :class="{ 'is-looping': phase === 'running' }">
|
||||||
|
{{ statusTitle }}
|
||||||
|
</h2>
|
||||||
<p class="c-status-sub">{{ statusSub }}</p>
|
<p class="c-status-sub">{{ statusSub }}</p>
|
||||||
<p class="c-status-counter">
|
<p class="c-status-counter">
|
||||||
任务已经完成<span class="ok">{{ successCount }}</span>次,其中失败次数是<span
|
{{ t('running.progressCounter', { success: successCount, fail: failCount }) }}
|
||||||
class="err"
|
|
||||||
>{{ failCount }}</span
|
|
||||||
>。
|
|
||||||
</p>
|
</p>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<WorkflowSteps
|
<WorkflowSteps
|
||||||
|
v-if="phase === 'failed'"
|
||||||
|
mode="failed"
|
||||||
|
:variant="isCollect ? 'collect' : 'distribute'"
|
||||||
|
:failed-step="failureStep"
|
||||||
|
/>
|
||||||
|
<WorkflowSteps
|
||||||
|
v-else
|
||||||
:active-step="workflowStep"
|
:active-step="workflowStep"
|
||||||
:variant="isCollect ? 'collect' : 'distribute'"
|
:variant="isCollect ? 'collect' : 'distribute'"
|
||||||
mode="running"
|
mode="running"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="m-right-panel">
|
<div class="m-right-panel">
|
||||||
<div class="m-progress-circle">
|
<div v-if="phase === 'failed'" class="m-error-icon">
|
||||||
|
<AppIcon name="warning" size="xl" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="m-progress-circle">
|
||||||
<svg viewBox="0 0 100 100">
|
<svg viewBox="0 0 100 100">
|
||||||
<circle class="bg" cx="50" cy="50" r="45" />
|
<circle class="bg" cx="50" cy="50" r="45" />
|
||||||
<circle
|
<circle
|
||||||
@@ -46,135 +75,432 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { notify } from '@/composables/useNotify'
|
import { notify } from '@/composables/useNotify'
|
||||||
|
import { refreshLiveStatus } from '@/composables/usePrinterStatus'
|
||||||
import AppShell from '@/layouts/AppShell.vue'
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
import AppHeader from '@/components/AppHeader.vue'
|
import AppHeader from '@/components/AppHeader.vue'
|
||||||
import AppFooter from '@/components/AppFooter.vue'
|
import AppFooter from '@/components/AppFooter.vue'
|
||||||
import NavButton from '@/components/NavButton.vue'
|
import NavButton from '@/components/NavButton.vue'
|
||||||
|
import AppIcon from '@/components/AppIcon.vue'
|
||||||
import WorkflowSteps from '@/components/WorkflowSteps.vue'
|
import WorkflowSteps from '@/components/WorkflowSteps.vue'
|
||||||
import { useJobStore } from '@/stores/job'
|
import { useJobStore } from '@/stores/job'
|
||||||
import { useCollectStore } from '@/stores/collect'
|
import { useCollectStore } from '@/stores/collect'
|
||||||
|
import { useDistributeFormStore } from '@/stores/distributeForm'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import {
|
import {
|
||||||
dllJobCancel,
|
dllJobCancel,
|
||||||
|
dllPrinterErrorStr,
|
||||||
|
dllUsbCopy,
|
||||||
|
onCardPositionTick,
|
||||||
onJobPollTick,
|
onJobPollTick,
|
||||||
onUsbPollTick,
|
onUsbPollTick,
|
||||||
|
pollCardPositionStart,
|
||||||
|
pollCardPositionStop,
|
||||||
pollJobStart,
|
pollJobStart,
|
||||||
pollJobStop,
|
pollJobStop,
|
||||||
pollUsbStop
|
pollUsbStop
|
||||||
} from '@/api/cardsoon'
|
} from '@/api/cardsoon'
|
||||||
import { mapJobStateToUi, shouldUseProgress } from '@/utils/job-state'
|
import { createDistributeJob } from '@/utils/createDistributeJob'
|
||||||
import type { JobPollPayload, UsbPollPayload } from '@/types/ipc'
|
import { validateJobPreflight } from '@/utils/validateJobPreflight'
|
||||||
|
import { mapJobStateToUi } from '@/utils/job-state'
|
||||||
|
import { POSITION_PREPARE, resolveCardPosition } from '@shared/card-position'
|
||||||
|
import {
|
||||||
|
USB_TASK_COPYING,
|
||||||
|
USB_TASK_PREPARING,
|
||||||
|
clampUsbCopyProgress,
|
||||||
|
usbTaskStatusHint
|
||||||
|
} from '@shared/usb-copy-state'
|
||||||
|
import type { CardPositionPollPayload, JobPollPayload, UsbPollPayload } from '@/types/ipc'
|
||||||
|
|
||||||
const CIRCLE_LEN = 283
|
const CIRCLE_LEN = 283
|
||||||
|
|
||||||
|
/** 等待进卡/读 U 盘超过该时长仍无进展,给出可操作提示(每个等待回合提示一次) */
|
||||||
|
const WAIT_WARN_MS = 45000
|
||||||
|
/** 看门狗检查间隔 */
|
||||||
|
const WAIT_WATCHDOG_MS = 5000
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
const jobStore = useJobStore()
|
const jobStore = useJobStore()
|
||||||
const collectStore = useCollectStore()
|
const collectStore = useCollectStore()
|
||||||
|
const formStore = useDistributeFormStore()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
const isCollect = computed(() => route.name === 'collect-running')
|
const isCollect = computed(() => route.name === 'collect-running')
|
||||||
const headerMode = computed(() => (isCollect.value ? '数据收集模式' : '数据分发模式'))
|
const headerMode = computed(() => (isCollect.value ? t('header.modeCollect') : t('header.modeDistribute')))
|
||||||
const successCount = computed(() =>
|
const successCount = computed(() =>
|
||||||
isCollect.value ? collectStore.successCount : jobStore.successCount
|
isCollect.value ? collectStore.successCount : jobStore.successCount
|
||||||
)
|
)
|
||||||
const failCount = computed(() => (isCollect.value ? collectStore.failCount : jobStore.failCount))
|
const failCount = computed(() => (isCollect.value ? collectStore.failCount : jobStore.failCount))
|
||||||
|
|
||||||
|
const phase = ref<'running' | 'completed' | 'failed'>('running')
|
||||||
const progress = ref(0)
|
const progress = ref(0)
|
||||||
const strokeOffset = ref(CIRCLE_LEN)
|
const strokeOffset = ref(CIRCLE_LEN)
|
||||||
const workflowStep = ref(1)
|
const workflowStep = ref(1)
|
||||||
|
const failureStep = ref(1)
|
||||||
|
const failureErrorText = ref('')
|
||||||
const waitCard = ref(false)
|
const waitCard = ref(false)
|
||||||
let unsub: (() => void) | null = null
|
const collectHint = ref('')
|
||||||
let fakeTimer: ReturnType<typeof setInterval> | null = null
|
let unsubJob: (() => void) | null = null
|
||||||
|
let unsubUsb: (() => void) | null = null
|
||||||
|
let unsubCard: (() => void) | null = null
|
||||||
|
let finishing = false
|
||||||
|
let resubmitting = false
|
||||||
|
let lastCardPosition = -1
|
||||||
|
let usbAwaitNewCycle = false
|
||||||
|
let queryFailStreak = 0
|
||||||
|
let usbQueryFailStreak = 0
|
||||||
|
let waitSince = 0
|
||||||
|
let waitWarned = false
|
||||||
|
let waitWatchdog: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const displayProgress = computed(() => Math.round(progress.value))
|
const displayProgress = computed(() => Math.round(progress.value))
|
||||||
|
|
||||||
const statusTitle = computed(() => (waitCard.value ? '等待插卡' : '循环执行中'))
|
const failedSub = computed(() =>
|
||||||
const statusSub = computed(() =>
|
isCollect.value
|
||||||
waitCard.value ? '请插入数据卡,任务将自动连续执行' : '任务将自动连续执行'
|
? t('running.failedSubCollect')
|
||||||
|
: t('running.failedSubDistribute')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const statusTitle = computed(() => {
|
||||||
|
if (phase.value === 'completed') {
|
||||||
|
return isCollect.value ? t('running.collectCompleted') : t('running.taskCompleted')
|
||||||
|
}
|
||||||
|
if (isCollect.value) {
|
||||||
|
return collectHint.value || t('running.dataCollecting')
|
||||||
|
}
|
||||||
|
return waitCard.value ? t('running.waitingCard') : t('running.taskRunning')
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusSub = computed(() => {
|
||||||
|
if (phase.value === 'completed') {
|
||||||
|
return isCollect.value
|
||||||
|
? t('running.completedSubCollect')
|
||||||
|
: t('running.completedSubDistribute')
|
||||||
|
}
|
||||||
|
if (isCollect.value) {
|
||||||
|
return collectHint.value || t('running.collectingHint')
|
||||||
|
}
|
||||||
|
return waitCard.value ? t('running.waitCardHint') : t('running.runningHint')
|
||||||
|
})
|
||||||
|
|
||||||
function setProgress(value: number): void {
|
function setProgress(value: number): void {
|
||||||
const p = Math.min(100, Math.max(0, value))
|
const p = Math.min(100, Math.max(0, value))
|
||||||
progress.value = p
|
progress.value = p
|
||||||
strokeOffset.value = CIRCLE_LEN - (CIRCLE_LEN * p) / 100
|
strokeOffset.value = CIRCLE_LEN - (CIRCLE_LEN * p) / 100
|
||||||
}
|
}
|
||||||
|
|
||||||
function startFakeProgress(): void {
|
function clearPollListeners(): void {
|
||||||
stopFakeProgress()
|
unsubJob?.()
|
||||||
fakeTimer = setInterval(() => {
|
unsubJob = null
|
||||||
if (progress.value >= 95) return
|
unsubUsb?.()
|
||||||
setProgress(progress.value + 1.5 + Math.random() * 2.5)
|
unsubUsb = null
|
||||||
}, 380)
|
unsubCard?.()
|
||||||
|
unsubCard = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopFakeProgress(): void {
|
/** 进入"等待进卡/读 U 盘"回合(已在等待中则不重复计时) */
|
||||||
if (fakeTimer) {
|
function markWaiting(): void {
|
||||||
clearInterval(fakeTimer)
|
if (waitSince === 0) {
|
||||||
fakeTimer = null
|
waitSince = Date.now()
|
||||||
|
waitWarned = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 离开等待状态(进卡成功 / 任务结束 / 重新提交) */
|
||||||
|
function clearWaiting(): void {
|
||||||
|
waitSince = 0
|
||||||
|
waitWarned = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopWaitWatchdog(): void {
|
||||||
|
if (waitWatchdog) {
|
||||||
|
clearInterval(waitWatchdog)
|
||||||
|
waitWatchdog = null
|
||||||
|
}
|
||||||
|
clearWaiting()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startWaitWatchdog(): void {
|
||||||
|
stopWaitWatchdog()
|
||||||
|
waitWatchdog = setInterval(() => {
|
||||||
|
if (phase.value !== 'running' || waitSince === 0 || waitWarned) return
|
||||||
|
if (Date.now() - waitSince < WAIT_WARN_MS) return
|
||||||
|
waitWarned = true
|
||||||
|
// 等待过久无进展:收集多为 U 盘数据线/卡片方向问题,分发多为卡片未到位/夹卡
|
||||||
|
notify.warning(
|
||||||
|
isCollect.value ? t('notify.waitTimeoutCollect') : t('notify.waitTimeoutDistribute')
|
||||||
|
)
|
||||||
|
}, WAIT_WATCHDOG_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function releasePolls(resetMode: boolean): Promise<void> {
|
||||||
|
clearPollListeners()
|
||||||
|
await pollCardPositionStop()
|
||||||
|
if (isCollect.value) {
|
||||||
|
await pollUsbStop({ resetMode })
|
||||||
|
} else {
|
||||||
|
await pollJobStop({ resetMode })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopTaskPollsOnly(): Promise<void> {
|
||||||
|
unsubJob?.()
|
||||||
|
unsubJob = null
|
||||||
|
unsubUsb?.()
|
||||||
|
unsubUsb = null
|
||||||
|
if (isCollect.value) {
|
||||||
|
await pollUsbStop({ resetMode: false })
|
||||||
|
} else {
|
||||||
|
await pollJobStop({ resetMode: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startCardPositionWatch(
|
||||||
|
sessionMode: 'distributing' | 'usbCopying'
|
||||||
|
): Promise<void> {
|
||||||
|
await pollCardPositionStop()
|
||||||
|
unsubCard?.()
|
||||||
|
unsubCard = onCardPositionTick((payload) =>
|
||||||
|
applyCardPosition(payload as CardPositionPollPayload)
|
||||||
|
)
|
||||||
|
const cardStarted = await pollCardPositionStart(sessionMode)
|
||||||
|
if (!cardStarted.ok) {
|
||||||
|
notify.warning(cardStarted.message || t('notify.pollMonitorFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterWaitPhase(next: 'completed' | 'failed', errorText = ''): Promise<void> {
|
||||||
|
clearWaiting()
|
||||||
|
if (phase.value !== next) {
|
||||||
|
phase.value = next
|
||||||
|
if (next === 'completed') {
|
||||||
|
setProgress(100)
|
||||||
|
workflowStep.value = isCollect.value ? 3 : 4
|
||||||
|
} else {
|
||||||
|
failureStep.value = workflowStep.value
|
||||||
|
failureErrorText.value = errorText
|
||||||
|
if (!failureErrorText.value && !isCollect.value) {
|
||||||
|
try {
|
||||||
|
const r = await dllPrinterErrorStr(-1)
|
||||||
|
failureErrorText.value = r.ok && r.data?.text ? r.data.text : ''
|
||||||
|
} catch {
|
||||||
|
failureErrorText.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jobStore.clearActiveJob()
|
||||||
|
}
|
||||||
|
} else if (next === 'failed' && errorText) {
|
||||||
|
failureErrorText.value = errorText
|
||||||
|
}
|
||||||
|
await stopTaskPollsOnly()
|
||||||
|
const sessionMode = isCollect.value ? 'usbCopying' : 'distributing'
|
||||||
|
appStore.setMode(sessionMode)
|
||||||
|
lastCardPosition = -1
|
||||||
|
await startCardPositionWatch(sessionMode)
|
||||||
|
void refreshLiveStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterFailedPhase(errorText = ''): Promise<void> {
|
||||||
|
await enterWaitPhase('failed', errorText)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterCompletedPhase(): Promise<void> {
|
||||||
|
await enterWaitPhase('completed')
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAutoResubmit(): boolean {
|
||||||
|
return phase.value === 'completed' || phase.value === 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCardPosition(p: CardPositionPollPayload): void {
|
||||||
|
if (!canAutoResubmit() || resubmitting) return
|
||||||
|
const pos = resolveCardPosition(p.queryCode, p.position)
|
||||||
|
if (pos === null) return
|
||||||
|
const enteredPrepare = pos === POSITION_PREPARE && lastCardPosition !== POSITION_PREPARE
|
||||||
|
lastCardPosition = pos
|
||||||
|
if (!enteredPrepare) return
|
||||||
|
void resubmitTask()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resubmitTask(): Promise<void> {
|
||||||
|
if (resubmitting || !canAutoResubmit()) return
|
||||||
|
const fromWaitPhase = phase.value
|
||||||
|
resubmitting = true
|
||||||
|
await pollCardPositionStop()
|
||||||
|
unsubCard?.()
|
||||||
|
unsubCard = null
|
||||||
|
|
||||||
|
const backToWait = async (message?: string): Promise<void> => {
|
||||||
|
if (message) notify.error(message)
|
||||||
|
if (fromWaitPhase === 'failed') {
|
||||||
|
await enterFailedPhase(message || failureErrorText.value)
|
||||||
|
} else {
|
||||||
|
await enterCompletedPhase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isCollect.value) {
|
||||||
|
const dest = collectStore.destPath.trim()
|
||||||
|
if (!dest) {
|
||||||
|
await backToWait(t('notify.dirInvalid'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
phase.value = 'running'
|
||||||
|
setProgress(0)
|
||||||
|
workflowStep.value = 1
|
||||||
|
collectHint.value = ''
|
||||||
|
queryFailStreak = 0
|
||||||
|
usbQueryFailStreak = 0
|
||||||
|
clearWaiting()
|
||||||
|
const r = await dllUsbCopy(dest, collectStore.cardOutput, { resubmit: true })
|
||||||
|
if (!r.ok) {
|
||||||
|
await backToWait(r.message || t('notify.resubmitCollectFailed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
usbAwaitNewCycle = true
|
||||||
|
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
|
||||||
|
markWaiting()
|
||||||
|
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
|
||||||
|
void refreshLiveStatus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!jobStore.lastJobJson) {
|
||||||
|
const err = await validateJobPreflight(formStore)
|
||||||
|
if (err) {
|
||||||
|
notify.warning(err)
|
||||||
|
await backToWait()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
phase.value = 'running'
|
||||||
|
setProgress(0)
|
||||||
|
workflowStep.value = 1
|
||||||
|
waitCard.value = false
|
||||||
|
queryFailStreak = 0
|
||||||
|
clearWaiting()
|
||||||
|
const created = await createDistributeJob(formStore, { resubmit: true })
|
||||||
|
if (!created.ok) {
|
||||||
|
await backToWait(created.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jobStore.setActiveJob(created.jobId)
|
||||||
|
const started = await pollJobStart(created.jobId)
|
||||||
|
if (!started.ok) {
|
||||||
|
await backToWait(started.message || t('notify.pollStartFailed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
|
||||||
|
void refreshLiveStatus()
|
||||||
|
} catch (e) {
|
||||||
|
await backToWait(String(e))
|
||||||
|
} finally {
|
||||||
|
resubmitting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyJobProgress(p: JobPollPayload): void {
|
function applyJobProgress(p: JobPollPayload): void {
|
||||||
|
if (finishing || phase.value !== 'running') return
|
||||||
|
if (p.queryErrorCode !== 0) {
|
||||||
|
queryFailStreak += 1
|
||||||
|
if (queryFailStreak < 3) return
|
||||||
|
jobStore.failCount += 1
|
||||||
|
void enterFailedPhase(t('notify.queryJobFailed', { code: p.queryErrorCode }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
queryFailStreak = 0
|
||||||
|
setProgress(p.progress)
|
||||||
|
|
||||||
const ui = mapJobStateToUi(p.jobState)
|
const ui = mapJobStateToUi(p.jobState)
|
||||||
workflowStep.value = ui.workflowStep
|
workflowStep.value = ui.workflowStep
|
||||||
waitCard.value = ui.hint === 'waitCard'
|
waitCard.value = ui.hint === 'waitCard'
|
||||||
if (shouldUseProgress(p.jobState)) {
|
if (ui.hint === 'waitCard') markWaiting()
|
||||||
setProgress(p.progress)
|
else clearWaiting()
|
||||||
}
|
|
||||||
if (p.queryErrorCode !== 0) {
|
|
||||||
notify.error(`查询任务失败: ${p.queryErrorCode}`)
|
|
||||||
finishDistribute(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (p.failed) {
|
if (p.failed) {
|
||||||
jobStore.failCount += 1
|
jobStore.failCount += 1
|
||||||
router.push('/distribute/failed')
|
// 优先使用主进程在失败当刻取到的错误串,避免事后取到通用文案
|
||||||
|
void enterFailedPhase(p.errorMessage || '')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (p.cancelled) {
|
if (p.cancelled) {
|
||||||
finishDistribute(false)
|
void finishDistribute(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (p.finished) {
|
if (p.finished) {
|
||||||
jobStore.successCount += 1
|
jobStore.successCount += 1
|
||||||
|
void enterCompletedPhase()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyUsbProgress(p: UsbPollPayload): void {
|
function applyUsbProgress(p: UsbPollPayload): void {
|
||||||
if (p.taskStatus === 1) workflowStep.value = 2
|
if (finishing || phase.value !== 'running') return
|
||||||
if (p.taskStatus === 1 && p.progress > 0) {
|
if (p.queryCode !== 0) {
|
||||||
setProgress(Math.max(progress.value, p.progress))
|
usbQueryFailStreak += 1
|
||||||
|
if (usbQueryFailStreak < 3) return
|
||||||
|
collectStore.failCount += 1
|
||||||
|
void enterFailedPhase(t('notify.queryUsbFailed', { code: p.queryCode }))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
usbQueryFailStreak = 0
|
||||||
|
|
||||||
|
if (usbAwaitNewCycle && p.queryCode === 0 && p.taskStatus === USB_TASK_PREPARING) {
|
||||||
|
usbAwaitNewCycle = false
|
||||||
|
}
|
||||||
|
|
||||||
if (p.failed) {
|
if (p.failed) {
|
||||||
collectStore.failCount += 1
|
collectStore.failCount += 1
|
||||||
notify.error('USB 收集失败')
|
void enterFailedPhase(p.errorMessage || usbTaskStatusHint(p.taskStatus))
|
||||||
finishCollect(false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (p.success) {
|
if (p.success) {
|
||||||
collectStore.successCount += 1
|
collectStore.successCount += 1
|
||||||
setProgress(100)
|
|
||||||
workflowStep.value = 3
|
workflowStep.value = 3
|
||||||
notify.success('USB 收集完成')
|
collectHint.value = usbTaskStatusHint(p.taskStatus)
|
||||||
window.setTimeout(() => finishCollect(true), 600)
|
notify.success(t('notify.usbCollectComplete'))
|
||||||
|
void enterCompletedPhase()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copyProgress = clampUsbCopyProgress(p.progress)
|
||||||
|
workflowStep.value = p.taskStatus === USB_TASK_COPYING ? 2 : 1
|
||||||
|
collectHint.value = usbTaskStatusHint(p.taskStatus)
|
||||||
|
// PREPARING(等待插卡/读 U 盘)开始计时;进入 COPYING 等后续阶段后清除
|
||||||
|
if (p.taskStatus === USB_TASK_PREPARING) markWaiting()
|
||||||
|
else clearWaiting()
|
||||||
|
setProgress(copyProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishDistribute(toHome: boolean): void {
|
async function finishDistribute(
|
||||||
stopFakeProgress()
|
toHome: boolean,
|
||||||
pollJobStop()
|
redirect: '/home' | '/distribute/config' = toHome ? '/home' : '/distribute/config'
|
||||||
|
): Promise<void> {
|
||||||
|
if (finishing) return
|
||||||
|
finishing = true
|
||||||
|
const skipCancel = phase.value === 'completed' || phase.value === 'failed'
|
||||||
|
await releasePolls(true)
|
||||||
|
const id = jobStore.jobId
|
||||||
|
if (id && !skipCancel) {
|
||||||
|
const r = await dllJobCancel(id)
|
||||||
|
if (!r.ok) {
|
||||||
|
notify.warning(r.message || t('notify.cancelIssue'))
|
||||||
|
}
|
||||||
|
}
|
||||||
jobStore.clearActiveJob()
|
jobStore.clearActiveJob()
|
||||||
appStore.setMode('ready')
|
appStore.setMode('ready')
|
||||||
router.push(toHome ? '/home' : '/distribute/config')
|
await router.push(redirect)
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishCollect(toHome: boolean): void {
|
async function finishCollect(toHome: boolean): Promise<void> {
|
||||||
stopFakeProgress()
|
if (finishing) return
|
||||||
pollUsbStop()
|
finishing = true
|
||||||
|
await releasePolls(true)
|
||||||
appStore.setMode('ready')
|
appStore.setMode('ready')
|
||||||
router.push(toHome ? '/home' : '/collect')
|
await router.push(toHome ? '/home' : '/collect')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -185,8 +511,11 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
workflowStep.value = 1
|
workflowStep.value = 1
|
||||||
setProgress(0)
|
setProgress(0)
|
||||||
startFakeProgress()
|
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
|
||||||
unsub = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
|
startWaitWatchdog()
|
||||||
|
markWaiting()
|
||||||
|
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
|
||||||
|
void refreshLiveStatus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,34 +524,86 @@ onMounted(async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
appStore.setMode('distributing')
|
appStore.setMode('distributing')
|
||||||
await pollJobStart(jobStore.jobId)
|
setProgress(0)
|
||||||
unsub = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
|
clearWaiting()
|
||||||
|
startWaitWatchdog()
|
||||||
|
const started = await pollJobStart(jobStore.jobId)
|
||||||
|
if (!started.ok) {
|
||||||
|
notify.error(started.message || t('notify.pollStartFailed'))
|
||||||
|
await finishDistribute(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
|
||||||
|
void refreshLiveStatus()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
unsub?.()
|
stopWaitWatchdog()
|
||||||
stopFakeProgress()
|
if (finishing || phase.value !== 'running') return
|
||||||
if (isCollect.value) {
|
void releasePolls(true)
|
||||||
if (appStore.mode === 'usbCopying') pollUsbStop()
|
if (isCollect.value && appStore.mode === 'usbCopying') {
|
||||||
} else if (appStore.mode === 'distributing') {
|
appStore.setMode('ready')
|
||||||
pollJobStop()
|
} else if (!isCollect.value && appStore.mode === 'distributing') {
|
||||||
appStore.setMode('ready')
|
appStore.setMode('ready')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onStop(): Promise<void> {
|
async function leaveFailedPage(redirect: '/collect' | '/distribute/config' | '/home'): Promise<void> {
|
||||||
|
finishing = true
|
||||||
|
await releasePolls(true)
|
||||||
|
appStore.setMode('ready')
|
||||||
|
await router.push(redirect)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFailedBack(): Promise<void> {
|
||||||
if (isCollect.value) {
|
if (isCollect.value) {
|
||||||
finishCollect(false)
|
await leaveFailedPage('/collect')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await dllJobCancel(jobStore.jobId)
|
await leaveFailedPage('/distribute/config')
|
||||||
finishDistribute(false)
|
}
|
||||||
|
|
||||||
|
async function onFailedReset(): Promise<void> {
|
||||||
|
if (isCollect.value) {
|
||||||
|
collectStore.reset()
|
||||||
|
} else {
|
||||||
|
formStore.reset()
|
||||||
|
jobStore.reset()
|
||||||
|
}
|
||||||
|
await leaveFailedPage('/home')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onReturn(): Promise<void> {
|
||||||
|
if (isCollect.value) {
|
||||||
|
await finishCollect(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await finishDistribute(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onStop(): Promise<void> {
|
||||||
|
if (isCollect.value) {
|
||||||
|
await finishCollect(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await finishDistribute(false)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style src="@/styles/pages/page2.css"></style>
|
<style src="@/styles/pages/page2.css"></style>
|
||||||
|
<style src="@/styles/pages/page3.css"></style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.m-error-detail {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #dc3545;
|
||||||
|
font-weight: 600;
|
||||||
|
max-width: 340px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.m-progress-circle .fill {
|
.m-progress-circle .fill {
|
||||||
stroke-dasharray: 283;
|
stroke-dasharray: 283;
|
||||||
transition: stroke-dashoffset 0.45s ease;
|
transition: stroke-dashoffset 0.45s ease;
|
||||||
|
|||||||
@@ -1,35 +1,61 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<AppHeader mode="卡树数据卡打印机软件" />
|
<AppHeader :mode="t('header.modeHome')" />
|
||||||
<main class="app-shell__main l-dashboard">
|
<main class="app-shell__main l-dashboard">
|
||||||
<section class="m-tool-section">
|
<section class="m-tool-section">
|
||||||
<h3 class="m-section-title">工具</h3>
|
<h3 class="m-section-title">{{ t('home.toolsTitle') }}</h3>
|
||||||
<div class="m-tool-grid">
|
<div class="m-tool-grid">
|
||||||
<button type="button" class="m-tool-btn" @click="onReset">
|
<button type="button" class="m-tool-btn" @click="onReset">
|
||||||
<AppIcon name="redo" />
|
<AppIcon name="redo" />
|
||||||
<span>重置打印机</span>
|
<span>{{ t('common.resetPrinter') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="m-tool-btn" @click="onReject">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="m-tool-btn"
|
||||||
|
:disabled="!configStore.rejectApiAvailable"
|
||||||
|
:title="configStore.rejectApiAvailable ? t('common.discardCard') : t('notify.rejectUnavailable')"
|
||||||
|
@click="onReject"
|
||||||
|
>
|
||||||
<AppIcon name="trash" />
|
<AppIcon name="trash" />
|
||||||
<span>废弃卡片</span>
|
<span>{{ t('common.discardCard') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div class="m-tool-versus">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="m-versus-half m-versus-half--read"
|
||||||
|
:title="t('home.readCard')"
|
||||||
|
@click="onReadCard"
|
||||||
|
>
|
||||||
|
<AppIcon name="id-card" size="sm" />
|
||||||
|
<span>{{ t('home.readCard') }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="m-versus-half m-versus-half--eject"
|
||||||
|
:title="t('home.ejectCard')"
|
||||||
|
@click="onEjectCard"
|
||||||
|
>
|
||||||
|
<span>{{ t('home.ejectCard') }}</span>
|
||||||
|
<AppIcon name="eject" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<button type="button" class="m-tool-btn" @click="onTemplate">
|
<button type="button" class="m-tool-btn" @click="onTemplate">
|
||||||
<AppIcon name="paint-brush" />
|
<AppIcon name="paint-brush" />
|
||||||
<span>模板设计</span>
|
<span>{{ t('common.templateDesign') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="m-divider" />
|
<div class="m-divider" />
|
||||||
<section class="m-task-section">
|
<section class="m-task-section">
|
||||||
<h3 class="m-section-title">任务</h3>
|
<h3 class="m-section-title">{{ t('home.tasksTitle') }}</h3>
|
||||||
<div class="m-task-grid">
|
<div class="m-task-grid">
|
||||||
<button type="button" class="m-task-card" @click="goDistribute">
|
<button type="button" class="m-task-card" @click="goDistribute">
|
||||||
<div class="m-task-icon">
|
<div class="m-task-icon">
|
||||||
<AppIcon name="share" />
|
<AppIcon name="share" />
|
||||||
</div>
|
</div>
|
||||||
<div class="m-task-info">
|
<div class="m-task-info">
|
||||||
<h4>数据分发</h4>
|
<h4>{{ t('home.dataDistribute') }}</h4>
|
||||||
<p>分发数据到打印卡片</p>
|
<p>{{ t('home.dataDistributeDesc') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="m-task-card" @click="goCollect">
|
<button type="button" class="m-task-card" @click="goCollect">
|
||||||
@@ -37,8 +63,8 @@
|
|||||||
<AppIcon name="download" />
|
<AppIcon name="download" />
|
||||||
</div>
|
</div>
|
||||||
<div class="m-task-info">
|
<div class="m-task-info">
|
||||||
<h4>数据收集</h4>
|
<h4>{{ t('home.dataCollect') }}</h4>
|
||||||
<p>从卡片收集导入数据</p>
|
<p>{{ t('home.dataCollectDesc') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,16 +76,19 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { notify, notifyRequireInit } from '@/composables/useNotify'
|
import { notify, notifyRequireInit } from '@/composables/useNotify'
|
||||||
|
import { refreshLiveStatus } from '@/composables/usePrinterStatus'
|
||||||
import AppShell from '@/layouts/AppShell.vue'
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
import AppHeader from '@/components/AppHeader.vue'
|
import AppHeader from '@/components/AppHeader.vue'
|
||||||
import AppFooter from '@/components/AppFooter.vue'
|
import AppFooter from '@/components/AppFooter.vue'
|
||||||
import AppIcon from '@/components/AppIcon.vue'
|
import AppIcon from '@/components/AppIcon.vue'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { dllPrinterReject, dllPrinterReset, shellOpenTemplateDir } from '@/api/cardsoon'
|
import { dllPrinterEjectCard, dllPrinterReadCard, dllPrinterReject, dllPrinterReset, openDesignApp } from '@/api/cardsoon'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
@@ -72,36 +101,61 @@ function guardInit(action?: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onReset(): Promise<void> {
|
async function onReset(): Promise<void> {
|
||||||
if (!guardInit('重置打印机')) return
|
if (!guardInit(t('common.resetPrinter'))) return
|
||||||
const r = await dllPrinterReset()
|
const r = await dllPrinterReset()
|
||||||
if (r.ok) notify.success('已发送重置指令')
|
if (r.ok) {
|
||||||
else notify.error(r.message || '重置失败')
|
notify.success(t('notify.resetSent'))
|
||||||
|
await refreshLiveStatus()
|
||||||
|
} else notify.error(r.message || t('notify.resetFailed'))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onReject(): Promise<void> {
|
async function onReject(): Promise<void> {
|
||||||
if (!guardInit('废弃卡片')) return
|
if (!guardInit(t('common.discardCard'))) return
|
||||||
if (!configStore.rejectApiAvailable) {
|
if (!configStore.rejectApiAvailable) {
|
||||||
notify.warning('当前环境不支持废卡接口')
|
notify.warning(t('notify.rejectUnavailable'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const r = await dllPrinterReject()
|
const r = await dllPrinterReject()
|
||||||
if (r.ok) notify.success('已废弃卡片')
|
if (r.ok) {
|
||||||
else notify.error(r.message || '操作失败')
|
notify.success(t('notify.cardRejected'))
|
||||||
|
await refreshLiveStatus()
|
||||||
|
} else notify.error(r.message || t('notify.operationFailed'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onReadCard(): Promise<void> {
|
||||||
|
if (!guardInit(t('home.readCard'))) return
|
||||||
|
const r = await dllPrinterReadCard()
|
||||||
|
if (r.ok) {
|
||||||
|
notify.success(t('notify.readCardSent'))
|
||||||
|
await refreshLiveStatus()
|
||||||
|
} else notify.error(r.message || t('notify.readCardFailed'))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onEjectCard(): Promise<void> {
|
||||||
|
if (!guardInit(t('home.ejectCard'))) return
|
||||||
|
const r = await dllPrinterEjectCard()
|
||||||
|
if (r.ok) {
|
||||||
|
notify.success(t('notify.ejectCardSent'))
|
||||||
|
await refreshLiveStatus()
|
||||||
|
} else notify.error(r.message || t('notify.ejectCardFailed'))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onTemplate(): Promise<void> {
|
async function onTemplate(): Promise<void> {
|
||||||
if (!guardInit('打开模板目录')) return
|
const r = await openDesignApp()
|
||||||
const r = await shellOpenTemplateDir()
|
if (!r.ok) {
|
||||||
if (!r.ok) notify.error(r.message || '打开模板目录失败')
|
notify.warning(r.message || t('notify.designCancelled'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notify.success(t('notify.designStarted'))
|
||||||
}
|
}
|
||||||
|
|
||||||
function guardBusy(): boolean {
|
function guardBusy(): boolean {
|
||||||
if (appStore.mode === 'distributing') {
|
if (appStore.mode === 'distributing') {
|
||||||
notify.warning('请先停止数据分发任务')
|
notify.warning(t('notify.stopDistribute'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (appStore.mode === 'usbCopying') {
|
if (appStore.mode === 'usbCopying') {
|
||||||
notify.warning('USB 收集进行中,请等待完成')
|
notify.warning(t('notify.usbCollecting'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/** workDll 备卡位:检测到后可自动续做 */
|
||||||
|
export const POSITION_PREPARE = 13
|
||||||
|
|
||||||
|
/** 卡位编号合理上限(用于解析 DLL 返回值) */
|
||||||
|
export const POSITION_MAX = 32
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 SAPI_GetPrinterCardPosition。
|
||||||
|
* 真机常见:ret=13,out=0(返回值即卡位);或 ret=0,out=卡位。
|
||||||
|
*/
|
||||||
|
export function resolveCardPosition(queryCode: number, position: number): number | null {
|
||||||
|
if (queryCode === 0 && position >= 0 && position <= POSITION_MAX) {
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
if (queryCode > 0 && queryCode <= POSITION_MAX) {
|
||||||
|
return queryCode
|
||||||
|
}
|
||||||
|
if (position > 0 && position <= POSITION_MAX) {
|
||||||
|
return position
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCardPositionQuery(
|
||||||
|
ret: number,
|
||||||
|
outPosition: number
|
||||||
|
): { queryCode: number; position: number } {
|
||||||
|
const pos = resolveCardPosition(ret, outPosition)
|
||||||
|
if (pos !== null) {
|
||||||
|
return { queryCode: 0, position: pos }
|
||||||
|
}
|
||||||
|
return { queryCode: ret, position: outPosition }
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function genTaskId(): string {
|
||||||
|
return `T${Date.now()}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
export function isNetworkPath(p: string): boolean {
|
||||||
|
if (!p) return false
|
||||||
|
return p.startsWith('\\\\') || p.startsWith('//')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractHostName(p: string): string {
|
||||||
|
if (!p) return ''
|
||||||
|
if (p.startsWith('\\\\')) {
|
||||||
|
const part = p.substring(2).split('\\')[0]
|
||||||
|
return part || ''
|
||||||
|
}
|
||||||
|
if (p.startsWith('//')) {
|
||||||
|
const part = p.substring(2).split('/')[0]
|
||||||
|
return part || ''
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDriveLetter(p: string): string {
|
||||||
|
const trimmed = (p || '').trim()
|
||||||
|
const m = /^([A-Za-z]):[\\/]/.exec(trimmed)
|
||||||
|
return m ? m[1].toUpperCase() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetInfoCredential {
|
||||||
|
host_name: string
|
||||||
|
user_name: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNetworkUrl(host: string, share: string): string {
|
||||||
|
const h = host.trim()
|
||||||
|
const s = share.trim().replace(/^[\\/]+/, '').replace(/[\\/]+$/, '')
|
||||||
|
return s ? `\\\\${h}\\${s}` : `\\\\${h}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function collectHostsForCopyPaths(
|
||||||
|
paths: string[],
|
||||||
|
driveHostMap: Record<string, string>
|
||||||
|
): string[] {
|
||||||
|
const hosts = new Set<string>()
|
||||||
|
for (const raw of paths) {
|
||||||
|
const p = (raw || '').trim()
|
||||||
|
if (!p) continue
|
||||||
|
if (isNetworkPath(p)) {
|
||||||
|
const h = extractHostName(p)
|
||||||
|
if (h) hosts.add(h)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const letter = extractDriveLetter(p)
|
||||||
|
if (letter && driveHostMap[letter]) {
|
||||||
|
hosts.add(driveHostMap[letter])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildNetInfo(
|
||||||
|
networkItems: { hostName?: string; userName?: string; password?: string }[],
|
||||||
|
getStored: (host: string) => { userName?: string; password?: string } | null
|
||||||
|
): NetInfoCredential[] {
|
||||||
|
const creds: NetInfoCredential[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
for (const it of networkItems) {
|
||||||
|
const host = (it.hostName || '').trim()
|
||||||
|
if (!host || seen.has(host)) continue
|
||||||
|
seen.add(host)
|
||||||
|
const stored = getStored(host)
|
||||||
|
const user_name = (it.userName || stored?.userName || '').trim()
|
||||||
|
const password = it.password || stored?.password || ''
|
||||||
|
if (!user_name || !password) {
|
||||||
|
throw new Error(`缺少网络凭据: ${host}`)
|
||||||
|
}
|
||||||
|
creds.push({
|
||||||
|
host_name: host,
|
||||||
|
user_name,
|
||||||
|
password
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return creds
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
export interface PrinterStatusSnapshot {
|
||||||
|
ribbonType: string
|
||||||
|
ribbonAmount: string
|
||||||
|
statusText: string
|
||||||
|
serialNo: string
|
||||||
|
/** 打印机型号/名称(如 TH80),用于判断单/双面能力 */
|
||||||
|
printerName: string
|
||||||
|
/** 是否为单面打印机(如 TH80),单面打印机不能选"双面"打印 */
|
||||||
|
isSingleSide: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已知单面打印机型号关键字(命中即视为单面) */
|
||||||
|
const SINGLE_SIDE_PRINTER_PATTERNS = ['TH80']
|
||||||
|
|
||||||
|
function detectSingleSide(printerName: string): boolean {
|
||||||
|
const s = (printerName || '').trim().toUpperCase()
|
||||||
|
if (!s) return false
|
||||||
|
return SINGLE_SIDE_PRINTER_PATTERNS.some((p) => s.includes(p.toUpperCase()))
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRINTER_STATUS_MAP: Record<string, string> = {
|
||||||
|
I: '空闲',
|
||||||
|
B: '忙碌',
|
||||||
|
P: '正在打印'
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFirst(obj: Record<string, unknown>, keys: string[]): unknown {
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = obj[k]
|
||||||
|
if (v != null && String(v).trim() !== '') return v
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatus(raw: unknown): string {
|
||||||
|
if (raw == null) return '—'
|
||||||
|
if (typeof raw === 'number' && raw > 0 && raw < 128) {
|
||||||
|
const c = String.fromCharCode(raw)
|
||||||
|
return PRINTER_STATUS_MAP[c] ?? c
|
||||||
|
}
|
||||||
|
const s = String(raw).trim()
|
||||||
|
if (!s) return '—'
|
||||||
|
return PRINTER_STATUS_MAP[s] ?? s
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SAPI_PrinterCheckstatus 返回值 → 展示文案 */
|
||||||
|
export function statusTextFromCheckstatus(code: number): {
|
||||||
|
ok: boolean
|
||||||
|
statusText: string
|
||||||
|
liveError?: string
|
||||||
|
} {
|
||||||
|
if (code === 0) {
|
||||||
|
return { ok: true, statusText: PRINTER_STATUS_MAP.I ?? '空闲' }
|
||||||
|
}
|
||||||
|
if (code > 0 && code < 128) {
|
||||||
|
const statusText = normalizeStatus(code)
|
||||||
|
if (statusText !== '—') {
|
||||||
|
return { ok: true, statusText }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (code < 0) {
|
||||||
|
return { ok: false, statusText: '未连接打印机', liveError: `code=${code}` }
|
||||||
|
}
|
||||||
|
return { ok: false, statusText: '—', liveError: `code=${code}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotFromRecord(row: Record<string, unknown>): PrinterStatusSnapshot {
|
||||||
|
const serial = pickFirst(row, [
|
||||||
|
'serial_no',
|
||||||
|
'SerialNo',
|
||||||
|
'serialNo',
|
||||||
|
'szPrinterSerial',
|
||||||
|
'PrinterSerial'
|
||||||
|
])
|
||||||
|
const printerName = pickFirst(row, [
|
||||||
|
'PrinterName',
|
||||||
|
'printer_name',
|
||||||
|
'PrinterModel',
|
||||||
|
'model',
|
||||||
|
'szPrinterName',
|
||||||
|
'szPrinterModel'
|
||||||
|
])
|
||||||
|
const ribbonType = pickFirst(row, ['ribbon_type', 'RibbonType'])
|
||||||
|
const ribbonAmount = pickFirst(row, ['RibbonAmount', 'ribbon_amount'])
|
||||||
|
const nameStr = String(printerName ?? '—')
|
||||||
|
|
||||||
|
return {
|
||||||
|
ribbonType: String(ribbonType ?? '—'),
|
||||||
|
ribbonAmount: String(ribbonAmount ?? '—'),
|
||||||
|
statusText: '—',
|
||||||
|
serialNo: String(serial ?? printerName ?? '—'),
|
||||||
|
printerName: nameStr,
|
||||||
|
isSingleSide: detectSingleSide(nameStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePrinterInfoFromDll(json: Record<string, unknown>): PrinterStatusSnapshot {
|
||||||
|
const flatSerial = pickFirst(json, ['serial_no', 'SerialNo', 'serialNo', 'szPrinterSerial'])
|
||||||
|
if (flatSerial != null) {
|
||||||
|
return snapshotFromRecord(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = (json.printerList as Record<string, unknown>[]) || []
|
||||||
|
if (list.length > 0) {
|
||||||
|
return snapshotFromRecord(list[0] as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshotFromRecord(json)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/** SAPI_GetUsbCopyState: task_status(0=preparing, 1=copying, 2=completed, 3=failed) */
|
||||||
|
export const USB_TASK_PREPARING = 0
|
||||||
|
export const USB_TASK_COPYING = 1
|
||||||
|
export const USB_TASK_COMPLETED = 2
|
||||||
|
export const USB_TASK_FAILED = 3
|
||||||
|
|
||||||
|
/** @deprecated 使用 USB_TASK_PREPARING */
|
||||||
|
export const USB_TASK_IDLE = USB_TASK_PREPARING
|
||||||
|
/** @deprecated 使用 USB_TASK_COMPLETED */
|
||||||
|
export const USB_TASK_SUCCESS = USB_TASK_COMPLETED
|
||||||
|
|
||||||
|
export function clampUsbCopyProgress(value: number): number {
|
||||||
|
return Math.min(100, Math.max(0, Math.round(value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usbTaskStatusHint(status: number): string {
|
||||||
|
switch (status) {
|
||||||
|
case USB_TASK_PREPARING:
|
||||||
|
return '准备读取数据卡,请确认卡片已插入读卡位'
|
||||||
|
case USB_TASK_COPYING:
|
||||||
|
return '正在从卡片拷贝数据'
|
||||||
|
case USB_TASK_COMPLETED:
|
||||||
|
return '拷贝完成'
|
||||||
|
case USB_TASK_FAILED:
|
||||||
|
return 'USB 收集失败:未检测到卡片存储、读卡失败或无法移动到 USB 读卡位'
|
||||||
|
default:
|
||||||
|
return `USB 任务状态异常 (taskStatus=${status})`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUsbCopyTerminal(status: number): boolean {
|
||||||
|
return status === USB_TASK_COMPLETED || status === USB_TASK_FAILED
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user