Compare commits

..

16 Commits

Author SHA1 Message Date
24kycj 9ab1bcbd5e 优化提交预检、废卡按钮置灰与等待超时提示 2026-09-09 23:45:25 +08:00
24kycj ac566846c9 优化bug 2026-09-09 10:36:42 +08:00
24kycj 19858f9a62 优化调整 2026-06-30 17:09:17 +08:00
24kycj 3918522831 chore: 更新 native 依赖
- 更新 workDll.dll

- 添加 OpenCV Release 版 opencv_world490.dll

- 添加 simhei.ttf 字体资源

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 23:21:55 +08:00
24kycj 7862ff2b2a fix: 测试联调四项修复
- 授权码并入加密狗行,password 掩码,窄宽样式

- 打印机信息仅调用 SAPI_GetPrinterInfo,移除 Ex 避免崩溃

- net_info 改为 JSON 数组,修复双重编码

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 21:08:37 +08:00
24kycj 82d7431e6f 更新 2026-06-12 11:53:40 +08:00
24kycj 931fcf90a4 fix: 落实测试反馈五项并优化分发配置
- 色带余量 RibbonAmount 独立展示与轮询刷新
- 加密狗授权码 UI、校验、auth_code 提交及 secrets 持久化
- 打印机状态全局轮询(前台 1.5s / 后台 8s)
- 网络路径:列表仅映射目录,凭据单独配置,net_info 对齐 SoonWoker
- 标签打印面:点击正/反面选择,print_flag 1/2/3 与模板校验

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 10:34:05 +08:00
24kycj f1b73ee3d3 优化bug 2026-06-10 23:50:12 +08:00
24kycj 6b135eaf7a Header 状态改用 Checkstatus 查询并移除已发行
状态与 GetPrinterInfo 分离:启动仅拉色带/序列号,操作后通过 SAPI_PrinterCheckstatus 刷新状态文案。
2026-06-03 16:07:58 +08:00
24kycj 10bc05f498 修复备卡位续做:兼容 queryCode 即卡位并缓存任务 JSON
resolveCardPosition 解析 ret=13/out=0 真机语义;续做时重发 lastJobJson;poll tick 发送前统一归一化。
2026-06-03 12:06:13 +08:00
24kycj 057285ed89 优化 Electron 打包体积:排除源码与冗余依赖
配置 electron-builder files 仅打 out 产物,移除未用依赖并将 Vue 生态移至 devDependencies;koffi 仅解包 win32_x64 并在 afterPack 裁剪;打包前清理旧 zip。
2026-06-03 11:24:13 +08:00
24kycj 75490d8647 修复备卡位 13 自动续做:卡位 API 解析与会话同步
兼容 workDll 返回值即卡位编号的语义,启动卡位轮询时同步主进程 mode,并修正续做失败时的 phase 时序。
2026-06-03 10:27:04 +08:00
24kycj 3ec168350c 标签字段可编辑,Init 参数改从 cardsoon.config.json 读取
- 模板 type 3/4/5 文本可编辑,type 1 支持选图
- cardsoon.config.json 增加 sharedDir、logLevel、cleanTaskFile(默认 0)
- cleanTaskFile 默认改为 false,启动时同步 sharedDir 到运行时配置

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 09:43:10 +08:00
24kycj a6fc966d8b 打包修改 2026-06-02 12:20:56 +08:00
24kycj 356e6a3d19 更新 2026-06-02 12:18:09 +08:00
24kycj 3168daedf4 完善分发收集续做、打印机状态查询与任务字段对齐
- 完成/失败态统一卡位13自动续做,修复主进程失败时 mode 被重置导致续做被拒
- 接入 SAPI_PrinterCheckstatus,Header 轻量刷状态,Init 后一次 GetPrinterInfo
- print_flag 取自 soon.flag,path_file 目录级 staging,加密狗与格式化选项对齐
- 失败页并入运行页,移除独立 DistributeFailedView

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 12:16:03 +08:00
70 changed files with 4628 additions and 912 deletions
+16 -1
View File
@@ -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
})
]
} }
}) })
+1032 -313
View File
File diff suppressed because it is too large Load Diff
+31 -14
View File
@@ -12,30 +12,42 @@
"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, "asar": true,
"asarUnpack": [ "asarUnpack": [
"**/node_modules/koffi/**" "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"
}, },
@@ -53,18 +65,19 @@
{ {
"from": "resources/native/CapSettings.json", "from": "resources/native/CapSettings.json",
"to": "CapSettings.json" "to": "CapSettings.json"
},
{
"from": "resources/cardsoon.config.json",
"to": "cardsoon.config.json"
} }
], ],
"win": { "win": {
"target": [ "target": [
"nsis" "zip"
], ],
"signAndEditExecutable": false "signAndEditExecutable": false
}, },
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"perMachine": false
},
"mac": { "mac": {
"target": [ "target": [
"dmg" "dmg"
@@ -77,13 +90,17 @@
} }
}, },
"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"
} }
} }
+4 -1
View File
@@ -1,3 +1,6 @@
{ {
"designAppPath": "D:\\SoonProject\\SoonDesign\\build\\win-unpacked\\SoonDesign.exe" "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.
+9
View File
@@ -14,6 +14,15 @@ const required = [
'opencv_world490d.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(', ')}`)
+12
View File
@@ -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))
}
}
+79
View File
@@ -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)
+31
View File
@@ -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 })
}
}
}
+24 -17
View File
@@ -1,4 +1,4 @@
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')
@@ -14,10 +14,12 @@ if (!gotSingleInstanceLock) {
app.quit() app.quit()
} }
import { ensureConsoleUtf8 } from './utils/ensure-console-utf8'
import { suppressKnownDllStderr } from './utils/suppress-dll-stderr' import { suppressKnownDllStderr } from './utils/suppress-dll-stderr'
import { loadAppFileConfig } from './services/app-config' import { loadAppFileConfig, applyFileConfigToStore } from './services/app-config'
import { migrateTraceConfig, setTraceWebContents } from './utils/trace-bridge' import { migrateTraceConfig, setTraceWebContents } from './utils/trace-bridge'
ensureConsoleUtf8()
suppressKnownDllStderr() suppressKnownDllStderr()
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
@@ -41,24 +43,12 @@ function focusMainWindow(): void {
mainWindow.focus() mainWindow.focus()
} }
function getDefaultWindowSize(): { width: number; height: number } {
const { width: sw, height: sh } = screen.getPrimaryDisplay().workAreaSize
let w = Math.max(1280, Math.min(Math.floor(sw * 0.85), 1600))
let h = contentHeightForWidth(w)
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,
@@ -94,6 +84,8 @@ function createWindow(): void {
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) {
@@ -114,6 +106,20 @@ function createWindow(): void {
) )
}) })
// 页面加载失败(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 {
@@ -133,6 +139,7 @@ app.whenReady().then(async () => {
try { try {
migrateTraceConfig() migrateTraceConfig()
loadAppFileConfig() loadAppFileConfig()
applyFileConfigToStore()
log.info('app startup', { packaged: app.isPackaged, execPath: process.execPath }) log.info('app startup', { packaged: app.isPackaged, execPath: process.execPath })
registerIpcHandlers() registerIpcHandlers()
try { try {
+123 -5
View File
@@ -5,13 +5,15 @@ 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 { startUsbPoll, startJobPoll, stopAllPolls, stopJobPoll, stopUsbPoll, stopCardPositionPoll, startCardPositionPoll, getPollMainWindow, isJobPollActive } from '../services/poll-manager' import { startUsbPoll, startJobPoll, stopAllPolls, stopJobPoll, stopUsbPoll, stopCardPositionPoll, startCardPositionPoll, getPollMainWindow, isJobPollActive } from '../services/poll-manager'
import { parsePrinterInfoFromDll, type PrinterStatusSnapshot } from '@shared/printer-info' import { parsePrinterInfoFromDll, statusTextFromCheckstatus, type PrinterStatusSnapshot } from '@shared/printer-info'
import { cleanPathPattern, getDirectorySizeBytes } from '../utils/dir-size' import { cleanPathPattern, getDirectorySizeBytes } from '../utils/dir-size'
import { getDesignAppPath } from '../services/app-config' import { getDesignAppPath } from '../services/app-config'
import { openDesignApp } from '../services/open-design-app' import { openDesignApp } from '../services/open-design-app'
import { writeJobCsv, type JobCsvRow } from '../utils/job-csv' import { writeJobCsv, type JobCsvRow } from '../utils/job-csv'
import { parseSoonTemplate } from '../utils/parse-soon' import { parseSoonTemplate } from '../utils/parse-soon'
import { stageJobPayloadJson } from '../utils/stage-job-payload' import { stageJobPayloadJson } from '../utils/stage-job-payload'
import { resolveDriveHostMap, resolveHostsFromPaths } from '../utils/network-drive'
import { getSecrets, setSecrets, type SecretsPayload } from '../services/secrets-store'
import { ensureDllInitialized, isDllInitAttempted } from '../services/dll-bootstrap' import { ensureDllInitialized, isDllInitAttempted } from '../services/dll-bootstrap'
import { loadDllModule } from '../services/dll-loader' import { loadDllModule } from '../services/dll-loader'
import { tracedHandle } from './traced-handler' import { tracedHandle } from './traced-handler'
@@ -66,7 +68,7 @@ export function registerIpcHandlers(): void {
return ok({ return ok({
...cached, ...cached,
fromCache: true, fromCache: true,
liveError: r.code <= 0 ? '未连接打印机' : `GetPrinterInfoEx code=${r.code}` liveError: r.code <= 0 ? '未连接打印机' : `GetPrinterInfo code=${r.code}`
}) })
} }
return fail(0, '未连接打印机') return fail(0, '未连接打印机')
@@ -83,12 +85,63 @@ export function registerIpcHandlers(): void {
} }
}) })
tracedHandle('dll:printer-status', async () => {
if (!mainAppState.initialized) {
const cached = configStore.get('lastPrinterStatus')
return ok({
statusText: cached?.statusText ?? '未初始化',
fromCache: true
})
}
try {
assertReady()
const dll = await loadDllModule()
if (!dll.isPrinterCheckstatusApiAvailable()) {
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 () => { tracedHandle('dll:printer-reset', async () => {
try { try {
assertReady() assertReady()
const dll = await loadDllModule() const dll = await loadDllModule()
const code = dll.dllPrinterReset() const code = dll.dllPrinterReset()
return code === CS_OK ? ok() : fail(code, '重置失败') // 物理动作指令:负数为错误,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))
} }
@@ -100,7 +153,46 @@ export function registerIpcHandlers(): void {
const dll = await loadDllModule() const dll = await loadDllModule()
if (!dll.isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE') if (!dll.isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE')
const code = dll.dllPrinterReject() const code = dll.dllPrinterReject()
return code === CS_OK ? ok() : fail(code, '废卡失败') if (code < 0) {
log.warn('SAPI_PrinterMovetoreject returned non-OK code', code)
return fail(code, '废卡失败')
}
return ok({ code })
} catch (err) {
return fail(CS_FAIL, String(err))
}
})
// 读卡:移动卡片到读取区(SAPI_PrinterMovetousbreader
tracedHandle('dll:printer-read-card', async () => {
try {
assertReady()
const dll = await loadDllModule()
if (!dll.isUsbReaderApiAvailable()) return fail(CS_FAIL, 'USB_READER_API_UNAVAILABLE')
const code = dll.dllPrinterMoveToUsbReader()
if (code < 0) {
log.warn('SAPI_PrinterMovetousbreader returned non-OK code', code)
return fail(code, '读卡失败')
}
return ok({ code })
} catch (err) {
return fail(CS_FAIL, String(err))
}
})
// 退卡:移动卡片到出卡区(SAPI_PrinterMovetohopper
tracedHandle('dll:printer-eject-card', async () => {
try {
assertReady()
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))
} }
@@ -243,9 +335,12 @@ export function registerIpcHandlers(): void {
return ok() return ok()
}) })
tracedHandle('poll:card-position-start', async () => { tracedHandle('poll:card-position-start', async (_e, sessionMode?: 'distributing' | 'usbCopying') => {
try { try {
assertReady() assertReady()
if (sessionMode === 'distributing' || sessionMode === 'usbCopying') {
mainAppState.mode = sessionMode
}
const dll = await loadDllModule() const dll = await loadDllModule()
if (!dll.isCardPositionApiAvailable()) { if (!dll.isCardPositionApiAvailable()) {
return fail(CS_FAIL, '当前 DLL 不支持卡位查询,无法自动续做') return fail(CS_FAIL, '当前 DLL 不支持卡位查询,无法自动续做')
@@ -304,6 +399,29 @@ export function registerIpcHandlers(): void {
return ok({ items }) return ok({ items })
}) })
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( tracedHandle(
'fs:write-job-csv', 'fs:write-job-csv',
(_e, payload: { taskId: string; rows: JobCsvRow[] }) => { (_e, payload: { taskId: string; rows: JobCsvRow[] }) => {
+54 -3
View File
@@ -2,20 +2,59 @@ import { app } from 'electron'
import fs from 'fs' import fs from 'fs'
import path from 'path' import path from 'path'
import log from 'electron-log' import log from 'electron-log'
import { LOG_FATAL_FLAG } from '../constants'
import { configStore } from './config-store'
import { getProcessExecDir } from './native-path' import { getProcessExecDir } from './native-path'
export const APP_CONFIG_FILENAME = 'cardsoon.config.json' export const APP_CONFIG_FILENAME = 'cardsoon.config.json'
const DEFAULT_SHARED_DIR = path.join('C:', 'PrintTasks')
export interface AppFileConfig { export interface AppFileConfig {
designAppPath: string designAppPath: string
sharedDir: string
logLevel: number
cleanTaskFile: boolean
} }
const defaults: AppFileConfig = { const defaults: AppFileConfig = {
designAppPath: '' designAppPath: '',
sharedDir: DEFAULT_SHARED_DIR,
logLevel: LOG_FATAL_FLAG,
cleanTaskFile: false
} }
let cached: AppFileConfig | null = null 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 { function bundledConfigPath(): string {
if (app.isPackaged) { if (app.isPackaged) {
return path.join(process.resourcesPath, APP_CONFIG_FILENAME) return path.join(process.resourcesPath, APP_CONFIG_FILENAME)
@@ -33,7 +72,10 @@ function configSearchPaths(): string[] {
function parseConfigFile(filePath: string): AppFileConfig { function parseConfigFile(filePath: string): AppFileConfig {
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, unknown> const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, unknown>
return { return {
designAppPath: String(raw.designAppPath ?? '').trim() designAppPath: String(pickRaw(raw, 'designAppPath') ?? '').trim(),
sharedDir: parseSharedDir(pickRaw(raw, 'sharedDir', 'shareddir')),
logLevel: parseLogLevel(pickRaw(raw, 'logLevel', 'loglevel')),
cleanTaskFile: parseCleanTaskFile(pickRaw(raw, 'cleanTaskFile', 'cleantaskfile'))
} }
} }
@@ -44,7 +86,11 @@ export function loadAppFileConfig(): AppFileConfig {
if (!fs.existsSync(filePath)) continue if (!fs.existsSync(filePath)) continue
try { try {
cached = parseConfigFile(filePath) cached = parseConfigFile(filePath)
log.info(`Loaded ${APP_CONFIG_FILENAME} from ${filePath}`) log.info(`Loaded ${APP_CONFIG_FILENAME} from ${filePath}`, {
sharedDir: cached.sharedDir,
logLevel: cached.logLevel,
cleanTaskFile: cached.cleanTaskFile
})
return cached return cached
} catch (e) { } catch (e) {
log.warn(`Skip invalid ${APP_CONFIG_FILENAME}: ${filePath}`, e) log.warn(`Skip invalid ${APP_CONFIG_FILENAME}: ${filePath}`, e)
@@ -58,6 +104,11 @@ export function loadAppFileConfig(): AppFileConfig {
return cached return cached
} }
export function applyFileConfigToStore(): void {
const cfg = loadAppFileConfig()
configStore.set('sharedDir', cfg.sharedDir)
}
export function getDesignAppPath(): string { export function getDesignAppPath(): string {
return loadAppFileConfig().designAppPath return loadAppFileConfig().designAppPath
} }
+5 -3
View File
@@ -3,6 +3,7 @@ import log from 'electron-log'
import { CS_OK } from '../constants' import { CS_OK } from '../constants'
import { mainAppState } from './app-state' import { mainAppState } from './app-state'
import { configStore } from './config-store' import { configStore } from './config-store'
import { loadAppFileConfig } from './app-config'
import { loadDllModule } from './dll-loader' import { loadDllModule } from './dll-loader'
import type { InitParams } from './work-dll.service' import type { InitParams } from './work-dll.service'
@@ -18,8 +19,9 @@ export async function ensureDllInitialized(
if (dllInitAttempted) { if (dllInitAttempted) {
return { code: CS_OK } return { code: CS_OK }
} }
const fileCfg = loadAppFileConfig()
const sharedDir = const sharedDir =
params?.sharedDir || (configStore.get('sharedDir') as string) || 'C:\\PrintTasks' params?.sharedDir?.trim() || fileCfg.sharedDir || (configStore.get('sharedDir') as string)
try { try {
const dll = await loadDllModule() const dll = await loadDllModule()
fs.mkdirSync(sharedDir, { recursive: true }) fs.mkdirSync(sharedDir, { recursive: true })
@@ -27,10 +29,10 @@ export async function ensureDllInitialized(
sharedDir, sharedDir,
keepCombinedImage: params?.keepCombinedImage, keepCombinedImage: params?.keepCombinedImage,
stopOnFailure: params?.stopOnFailure, stopOnFailure: params?.stopOnFailure,
cleanTaskFile: params?.cleanTaskFile, cleanTaskFile: params?.cleanTaskFile ?? fileCfg.cleanTaskFile,
autoRetryTimes: params?.autoRetryTimes, autoRetryTimes: params?.autoRetryTimes,
rejectConfig: params?.rejectConfig, rejectConfig: params?.rejectConfig,
logLevel: params?.logLevel, logLevel: params?.logLevel ?? fileCfg.logLevel,
outBack: params?.outBack outBack: params?.outBack
}) })
dllInitAttempted = true dllInitAttempted = true
+3 -1
View File
@@ -23,7 +23,9 @@ export async function openDesignApp(
const target = path.resolve(exePath.trim()) const target = path.resolve(exePath.trim())
const err = await shell.openPath(target) const err = await shell.openPath(target)
if (err) { if (err) {
return { ok: false, message: err } // 用户在 UAC/系统弹窗中点击"否"或被系统拒绝时,shell.openPath 返回通用错误字符串
// 不把原始 "Failed to Open Path" 直接抛给用户,给出可读说明
return { ok: false, message: '打开设计软件失败,可能已被取消或需要管理员权限' }
} }
return { ok: true } return { ok: true }
} }
+30 -18
View File
@@ -1,5 +1,6 @@
import { BrowserWindow } from 'electron' import { BrowserWindow } from 'electron'
import log from 'electron-log' import log from 'electron-log'
import { normalizeCardPositionQuery } from '@shared/card-position'
import { POLL_INTERVAL_MS, CS_OK } from '../constants' import { POLL_INTERVAL_MS, CS_OK } from '../constants'
import { import {
USB_TASK_COMPLETED, USB_TASK_COMPLETED,
@@ -79,6 +80,15 @@ export function startJobPoll(id: string): void {
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
// 失败当刻立即取打印机错误串(与 USB 收集轮询对齐),避免渲染层事后取到通用/过期文案
let errorMessage = ''
if (failed) {
try {
errorMessage = dll.dllGetPrinterErrorStr(-1) || ''
} catch (e) {
log.warn('GetPrinterErrorStr on job failed failed', e)
}
}
const tick = { const tick = {
jobId, jobId,
queryErrorCode: r.queryErrorCode, queryErrorCode: r.queryErrorCode,
@@ -87,7 +97,8 @@ export function startJobPoll(id: string): void {
terminal, terminal,
failed, failed,
cancelled, cancelled,
finished finished,
errorMessage
} }
emitTrace('[poll] job:poll-tick', tick) emitTrace('[poll] job:poll-tick', tick)
send('job:poll-tick', tick) send('job:poll-tick', tick)
@@ -95,14 +106,12 @@ export function startJobPoll(id: string): void {
log.warn('GetJobStateById query failed', r.queryErrorCode) log.warn('GetJobStateById query failed', r.queryErrorCode)
return return
} }
if (failed || cancelled) { if (failed || cancelled || finished) {
stopJobPoll(true)
} else if (finished) {
stopJobPoll(false) 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)
@@ -115,7 +124,7 @@ export function startUsbPoll(): void {
usbTimer = setInterval(() => { usbTimer = setInterval(() => {
void pollUsbOnce(gen).catch((e) => { void pollUsbOnce(gen).catch((e) => {
log.error('usb poll error', e) log.error('usb poll error', e)
stopUsbPoll(true) stopUsbPoll(false)
}) })
}, POLL_INTERVAL_MS) }, POLL_INTERVAL_MS)
} }
@@ -156,21 +165,24 @@ async function pollUsbOnce(gen: number): Promise<void> {
} }
} }
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) {
log.error('card position poll error', e)
}
}
export function startCardPositionPoll(): void { export function startCardPositionPoll(): void {
stopCardPositionPoll() stopCardPositionPoll()
void pollCardPositionOnce()
cardTimer = setInterval(() => { cardTimer = setInterval(() => {
void (async () => { void pollCardPositionOnce()
try {
const dll = await loadDllModule()
if (!dll.isCardPositionApiAvailable()) return
const r = dll.dllGetPrinterCardPosition()
const tick = { queryCode: r.queryCode, position: r.position }
emitTrace('[poll] card:position-tick', tick)
send('card:position-tick', tick)
} catch (e) {
log.error('card position poll error', e)
}
})()
}, POLL_INTERVAL_MS) }, POLL_INTERVAL_MS)
} }
+92
View File
@@ -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()
}
+76 -32
View File
@@ -1,6 +1,7 @@
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 { emitTrace, isTraceEnabled } from '../utils/trace-bridge'
import { getNativeDir } from './native-path' import { getNativeDir } from './native-path'
@@ -23,8 +24,6 @@ let SAPI_Init: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterInfo: any = null let SAPI_GetPrinterInfo: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterInfoEx: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_FreePrinterInfo: any = null let SAPI_FreePrinterInfo: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterErrorStr: any = null let SAPI_GetPrinterErrorStr: any = null
@@ -45,15 +44,20 @@ let SAPI_PrinterMovetoreject: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterMovetousbreader: any = null let SAPI_PrinterMovetousbreader: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 let SAPI_GetPrinterCardPosition: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 SAPI_UploadFile: any = null
let hasRejectApi = false let hasRejectApi = false
let hasCardPositionApi = false let hasCardPositionApi = false
let hasCheckstatusApi = false
let hasCancelApi = false let hasCancelApi = false
let hasUploadApi = false let hasUploadApi = false
let hasPrinterInfoEx = false
let hasUsbReaderApi = false let hasUsbReaderApi = false
let hasHopperApi = false
let loggedCancelMissing = false let loggedCancelMissing = false
let loggedRejectMissing = false let loggedRejectMissing = false
@@ -78,6 +82,20 @@ function traceCall<T>(name: string, args: Record<string, unknown> | undefined, f
} }
} }
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> } { function readPrinterJsonFromOutPtr(len: number, outPtr: Buffer): { code: number; json?: Record<string, unknown> } {
if (len <= 0) return { code: len } if (len <= 0) return { code: len }
const ptr = koffi.decode(outPtr, 0, 'void *') as number const ptr = koffi.decode(outPtr, 0, 'void *') as number
@@ -91,15 +109,7 @@ function readPrinterJsonFromOutPtr(len: number, outPtr: Buffer): { code: number;
return { code: len } return { code: len }
} }
} finally { } finally {
if (SAPI_FreePrinterInfo) { freePrinterInfoPtr(ptr)
try {
SAPI_FreePrinterInfo(ptr)
} catch (e) {
log.warn('SAPI_FreePrinterInfo', e)
}
} else {
koffi.free(ptr)
}
} }
} }
@@ -120,13 +130,10 @@ function loadLibrary(): void {
SAPI_PrinterResetprinter = lib.func('int __stdcall SAPI_PrinterResetprinter()') SAPI_PrinterResetprinter = lib.func('int __stdcall SAPI_PrinterResetprinter()')
try { try {
SAPI_GetPrinterInfoEx = lib.func('int __stdcall SAPI_GetPrinterInfoEx(_Out_ void **)')
SAPI_FreePrinterInfo = lib.func('void __stdcall SAPI_FreePrinterInfo(void *)') SAPI_FreePrinterInfo = lib.func('void __stdcall SAPI_FreePrinterInfo(void *)')
hasPrinterInfoEx = true
} catch { } catch {
SAPI_GetPrinterInfoEx = null
SAPI_FreePrinterInfo = null SAPI_FreePrinterInfo = null
hasPrinterInfoEx = false log.warn('SAPI_FreePrinterInfo not in workDll')
} }
try { try {
@@ -170,6 +177,15 @@ function loadLibrary(): void {
emitTrace('[dll] SAPI_PrinterMovetousbreader not in workDll (optional)') 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 { try {
SAPI_GetPrinterCardPosition = lib.func('int __stdcall SAPI_GetPrinterCardPosition(_Out_ int *)') SAPI_GetPrinterCardPosition = lib.func('int __stdcall SAPI_GetPrinterCardPosition(_Out_ int *)')
hasCardPositionApi = true hasCardPositionApi = true
@@ -179,13 +195,23 @@ function loadLibrary(): void {
emitTrace('[dll] SAPI_GetPrinterCardPosition not in workDll (optional)') 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', { log.info('workDll loaded', {
upload: hasUploadApi, upload: hasUploadApi,
printerInfoEx: hasPrinterInfoEx,
cancel: hasCancelApi, cancel: hasCancelApi,
reject: hasRejectApi, reject: hasRejectApi,
usbReader: hasUsbReaderApi, usbReader: hasUsbReaderApi,
cardPosition: hasCardPositionApi hopper: hasHopperApi,
cardPosition: hasCardPositionApi,
checkstatus: hasCheckstatusApi
}) })
} }
@@ -209,11 +235,21 @@ export function isUsbReaderApiAvailable(): boolean {
return hasUsbReaderApi return hasUsbReaderApi
} }
export function isHopperApiAvailable(): boolean {
loadLibrary()
return hasHopperApi
}
export function isCardPositionApiAvailable(): boolean { export function isCardPositionApiAvailable(): boolean {
loadLibrary() loadLibrary()
return hasCardPositionApi return hasCardPositionApi
} }
export function isPrinterCheckstatusApiAvailable(): boolean {
loadLibrary()
return hasCheckstatusApi
}
export function dllInit(params: InitParams): number { export function dllInit(params: InitParams): number {
return traceCall( return traceCall(
'SAPI_Init', 'SAPI_Init',
@@ -221,7 +257,7 @@ export function dllInit(params: InitParams): number {
sharedDir: params.sharedDir, sharedDir: params.sharedDir,
keepCombinedImage: params.keepCombinedImage ?? true, keepCombinedImage: params.keepCombinedImage ?? true,
stopOnFailure: params.stopOnFailure ?? false, stopOnFailure: params.stopOnFailure ?? false,
cleanTaskFile: params.cleanTaskFile ?? true, cleanTaskFile: params.cleanTaskFile ?? false,
autoRetryTimes: params.autoRetryTimes ?? 0, autoRetryTimes: params.autoRetryTimes ?? 0,
rejectConfig: params.rejectConfig ?? false, rejectConfig: params.rejectConfig ?? false,
logLevel: params.logLevel ?? LOG_FATAL_FLAG, logLevel: params.logLevel ?? LOG_FATAL_FLAG,
@@ -233,7 +269,7 @@ export function dllInit(params: InitParams): number {
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,
@@ -244,7 +280,7 @@ export function dllInit(params: InitParams): number {
} }
function dllGetPrinterInfoInternal( function dllGetPrinterInfoInternal(
apiName: 'SAPI_GetPrinterInfo' | 'SAPI_GetPrinterInfoEx', apiName: 'SAPI_GetPrinterInfo',
fn: (outPtr: Buffer) => number fn: (outPtr: Buffer) => number
): { code: number; json?: Record<string, unknown> } { ): { code: number; json?: Record<string, unknown> } {
return traceCall(apiName, undefined, () => { return traceCall(apiName, undefined, () => {
@@ -260,13 +296,6 @@ function dllGetPrinterInfoInternal(
} }
export function dllGetPrinterInfo(): { code: number; json?: Record<string, unknown> } { export function dllGetPrinterInfo(): { code: number; json?: Record<string, unknown> } {
loadLibrary()
if (hasPrinterInfoEx && SAPI_GetPrinterInfoEx) {
const ex = dllGetPrinterInfoInternal('SAPI_GetPrinterInfoEx', (p) => SAPI_GetPrinterInfoEx!(p))
if (ex.json && Object.keys(ex.json).length > 0) {
return ex
}
}
return dllGetPrinterInfoInternal('SAPI_GetPrinterInfo', (p) => SAPI_GetPrinterInfo!(p)) return dllGetPrinterInfoInternal('SAPI_GetPrinterInfo', (p) => SAPI_GetPrinterInfo!(p))
} }
@@ -336,7 +365,6 @@ export function dllCopyFromUsb(destFolder: string, cardOutput: number): number {
export function dllGetUsbCopyState(): { export function dllGetUsbCopyState(): {
queryCode: number queryCode: number
taskStatus: number taskStatus: number
/** copy_progress 0-100 */
progress: number progress: number
} { } {
return traceCall('SAPI_GetUsbCopyState', undefined, () => { return traceCall('SAPI_GetUsbCopyState', undefined, () => {
@@ -367,6 +395,14 @@ export function dllPrinterMoveToUsbReader(): 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, () => { return traceCall('SAPI_PrinterMovetoreject', undefined, () => {
loadLibrary() loadLibrary()
@@ -379,8 +415,16 @@ export function dllGetPrinterCardPosition(): { queryCode: number; position: numb
return traceCall('SAPI_GetPrinterCardPosition', undefined, () => { return traceCall('SAPI_GetPrinterCardPosition', undefined, () => {
loadLibrary() loadLibrary()
if (!SAPI_GetPrinterCardPosition) throw new Error('CARD_POSITION_API_UNAVAILABLE') if (!SAPI_GetPrinterCardPosition) throw new Error('CARD_POSITION_API_UNAVAILABLE')
const position = [0] const out = [0]
const queryCode = SAPI_GetPrinterCardPosition!(position) as number const ret = SAPI_GetPrinterCardPosition!(out) as number
return { queryCode, position: position[0] } 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
}) })
} }
+21
View File
@@ -0,0 +1,21 @@
import koffi from 'koffi'
/** Windows 控制台默认多为 GBKDLL 日志为 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 */
}
}
+56
View File
@@ -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
}
+25 -5
View File
@@ -5,16 +5,30 @@ export interface TemplateFieldRow {
label: string label: string
value: string value: string
originName: string originName: string
/** soon frontData/backData 的 type1=图片,3/4/5=文本可编辑 */
fieldType: number
} }
export interface ParsedSoonTemplate { export interface ParsedSoonTemplate {
frontImageUrl: string frontImageUrl: string
backImageUrl: string backImageUrl: string
fields: TemplateFieldRow[] fields: TemplateFieldRow[]
/** soon 模板 flag1 双面 / 2 正面 / 3 背面 */
templateFlag: number
} }
const SOON_FIELD_TYPES = new Set([1, 3, 4, 5]) 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>[] {
const entry = Object.entries(obj).find(([k]) => k.toLowerCase() === key.toLowerCase()) const entry = Object.entries(obj).find(([k]) => k.toLowerCase() === key.toLowerCase())
if (!Array.isArray(entry?.[1])) return [] if (!Array.isArray(entry?.[1])) return []
@@ -70,7 +84,7 @@ function parseSoonWorkerDisk(soonPath: string, raw: Record<string, unknown>): Pa
const name = String(o.name ?? '').trim() const name = String(o.name ?? '').trim()
if (!name) continue if (!name) continue
const value = o.DefaultText == null ? '' : String(o.DefaultText) const value = o.DefaultText == null ? '' : String(o.DefaultText)
fields.push({ label: `${name}[${side}]`, value, originName: name }) fields.push({ label: `${name}[${side}]`, value, originName: name, fieldType: type })
} }
} }
@@ -83,7 +97,8 @@ function parseSoonWorkerDisk(soonPath: string, raw: Record<string, unknown>): Pa
return { return {
frontImageUrl: toImageUrl(soonPath, frontPic), frontImageUrl: toImageUrl(soonPath, frontPic),
backImageUrl: toImageUrl(soonPath, backPic), backImageUrl: toImageUrl(soonPath, backPic),
fields fields,
templateFlag: readSoonTemplateFlag(raw)
} }
} }
@@ -104,7 +119,7 @@ function parseSoonLegacy(soonPath: string, raw: Record<string, unknown>): Parsed
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, originName: name }) fields.push({ label: toFieldLabel(name, 'front'), value: fileRef, originName: name, fieldType: 1 })
} else if (!backImageUrl) { } else if (!backImageUrl) {
backImageUrl = url backImageUrl = url
} }
@@ -116,10 +131,15 @@ function parseSoonLegacy(soonPath: string, raw: Record<string, unknown>): Parsed
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, originName: name }) 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 { export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
-3
View File
@@ -51,7 +51,6 @@ function stageSoonAssets(taskDir: string, soonSrc: string, soonDest: string): vo
} }
} }
/** 保留目录级 path_file,不展开为单文件列表 */
function normalizeCopyPaths(payload: Record<string, unknown>): void { function normalizeCopyPaths(payload: Record<string, unknown>): void {
if (!Array.isArray(payload.path_file)) { if (!Array.isArray(payload.path_file)) {
throw new Error('拷贝任务缺少 path_file') throw new Error('拷贝任务缺少 path_file')
@@ -87,7 +86,6 @@ function normalizeCopyPaths(payload: Record<string, unknown>): void {
payload.path_file = normalized payload.path_file = normalized
} }
/** staging 后按 has_* 裁剪字段,并校验 RestJobEx 必填项 */
function finalizeRestJobPayload(payload: Record<string, unknown>): void { function finalizeRestJobPayload(payload: Record<string, unknown>): void {
const hasCopy = payload.has_copy_task === true const hasCopy = payload.has_copy_task === true
const hasPrint = payload.has_print_task === true const hasPrint = payload.has_print_task === true
@@ -119,7 +117,6 @@ function finalizeRestJobPayload(payload: Record<string, unknown>): void {
} }
} }
/** 通过 SAPI_UploadFile + 本地落盘,准备 RestJobEx 所需路径 */
export function stageJobPayloadJson( export function stageJobPayloadJson(
json: string, json: string,
sharedDir: string, sharedDir: string,
+6
View File
@@ -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',
@@ -21,7 +24,10 @@ const channels = {
'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', 'fs:write-job-csv',
'secrets:get',
'secrets:set',
'config:get', 'config:get',
'config:set', 'config:set',
'shell:open-path', 'shell:open-path',
+2
View File
@@ -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>
+53 -4
View File
@@ -14,6 +14,14 @@ 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 {
return parsePrinterInfoFromDll(json) return parsePrinterInfoFromDll(json)
} }
@@ -26,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 }>>
} }
@@ -65,8 +83,10 @@ export async function pollUsbStop(opts?: { resetMode?: boolean }): Promise<IpcRe
return api().invoke('poll:usb-stop', opts) as Promise<IpcResult> return api().invoke('poll:usb-stop', opts) as Promise<IpcResult>
} }
export async function pollCardPositionStart(): Promise<IpcResult> { export async function pollCardPositionStart(
return api().invoke('poll:card-position-start') 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> { export async function pollCardPositionStop(): Promise<IpcResult> {
@@ -95,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[] }>>
} }
@@ -118,18 +144,41 @@ export async function fsParseSoon(filePath: string): Promise<
IpcResult<{ IpcResult<{
frontImageUrl: string frontImageUrl: string
backImageUrl: string backImageUrl: string
fields: { label: string; value: string; originName: 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<{ IpcResult<{
frontImageUrl: string frontImageUrl: string
backImageUrl: string backImageUrl: string
fields: { label: string; value: string; originName: 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<{ IpcResult<{
sharedDir: string sharedDir: string
+10 -2
View File
@@ -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'
} }
+11 -3
View File
@@ -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>
+65 -9
View File
@@ -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>
@@ -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,10 +1,15 @@
import { onMounted } from 'vue' import { onMounted } from 'vue'
import i18n from '@/i18n'
import { notify } from '@/composables/useNotify' import { notify } from '@/composables/useNotify'
import { applyPrinterPayload, refreshPrinterHeader } from '@/composables/usePrinterStatus' import { refreshPrinterAfterInit } from '@/composables/usePrinterStatus'
import { configGet, dllInit, dllRejectAvailable } from '@/api/cardsoon' import { configGet, dllInit, dllRejectAvailable } 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
function placeholderStatus(configStore: ReturnType<typeof useConfigStore>, text: string): void { function placeholderStatus(configStore: ReturnType<typeof useConfigStore>, text: string): void {
@@ -41,9 +46,9 @@ export function useAppBootstrap(): void {
const cfg = await hydrateFromConfig() const cfg = await hydrateFromConfig()
if (cfg.ok && cfg.data?.dllInitialized) { if (cfg.ok && cfg.data?.dllInitialized) {
appStore.setInitialized(true) appStore.setInitialized(true)
placeholderStatus(configStore, '就绪') placeholderStatus(configStore, t('printerStatus.ready'))
await syncRejectApi() await syncRejectApi()
window.setTimeout(() => void refreshPrinterHeader(configStore), 1500) window.setTimeout(() => void refreshPrinterAfterInit(), 1500)
return return
} }
@@ -51,18 +56,18 @@ export function useAppBootstrap(): void {
configStore.setSharedDir(sharedDir) configStore.setSharedDir(sharedDir)
const init = await dllInit({ sharedDir }) const init = await dllInit({ sharedDir })
if (!init.ok) { if (!init.ok) {
appStore.setInitialized(false, init.message || 'Init 失败') appStore.setInitialized(false, init.message || t('printerStatus.initFailed'))
configStore.setPrinter({ ...configStore.printer, statusText: '初始化失败' }) configStore.setPrinter({ ...configStore.printer, statusText: t('printerStatus.initFailed') })
notify.error(init.message || '初始化失败,请检查任务目录权限') notify.error(init.message || t('notify.initFailedHint'))
return return
} }
appStore.setInitialized(true) appStore.setInitialized(true)
const initMeta = init.data as { warning?: string } | undefined const initMeta = init.data as { warning?: string } | undefined
if (initMeta?.warning) notify.warning(initMeta.warning) if (initMeta?.warning) notify.warning(initMeta.warning)
placeholderStatus(configStore, initMeta?.warning ? '未连接打印机' : '就绪') placeholderStatus(configStore, initMeta?.warning ? t('printerStatus.notConnected') : t('printerStatus.ready'))
await syncRejectApi() await syncRejectApi()
window.setTimeout(() => void refreshPrinterHeader(configStore), 1500) window.setTimeout(() => void refreshPrinterAfterInit(), 1500)
} }
onMounted(() => { onMounted(() => {
+10 -3
View File
@@ -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 = '系统未就绪,请重启应用或检查打印机与任务目录'
/** 未初始化等业务拦截时的统一提示 */ /** 未初始化等业务拦截时的统一提示 */
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'))
}
} }
@@ -1,39 +1,57 @@
import { dllPrinterInfo, parsePrinterInfo } from '@/api/cardsoon' import { dllPrinterInfo, dllPrinterStatus, parsePrinterInfo } from '@/api/cardsoon'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import type { PrinterStatusDisplay } from '@/types/printer'
export function applyPrinterPayload( /** 状态是否表示打印机未连接/不可用 */
configStore: ReturnType<typeof useConfigStore>, function isDisconnectedStatus(text: string): boolean {
data: Record<string, unknown> return !text || text === '—' || text.includes('未连接') || text.includes('Not connected')
): void {
const snapshot = data.snapshot as PrinterStatusDisplay | undefined
if (snapshot) {
configStore.setPrinter(snapshot)
return
}
if (data.printerList != null || data.serial_no != null || data.SerialNo != null) {
configStore.setPrinter(parsePrinterInfo(data))
return
}
if (data.fromCache) {
configStore.setPrinter({
ribbonType: String(data.ribbonType ?? configStore.printer.ribbonType),
statusText: String(data.statusText ?? configStore.printer.statusText),
serialNo: String(data.serialNo ?? configStore.printer.serialNo),
printedCount: Number(data.printedCount ?? configStore.printer.printedCount)
})
}
} }
export async function refreshPrinterHeader( let prevStatusText = ''
configStore: ReturnType<typeof useConfigStore>
): Promise<void> { export async function refreshLiveStatus(): Promise<void> {
const store = useConfigStore()
try { try {
const info = await dllPrinterInfo() const r = await dllPrinterStatus()
if (info.ok && info.data) { if (r.ok && r.data?.statusText) {
applyPrinterPayload(configStore, info.data) 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 { } 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()
}
)
}
+32 -19
View File
@@ -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: 'Fat32', value: 'fat32' }, return [
{ label: 'exFat', value: 'exfat' }, { label: t('copyType.fileCopy'), value: 0 },
{ label: 'NTFS', 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: 'YMCKO', value: 'YMCKO' }, { label: t('common.low'), value: 'low' },
{ label: 'YMCK', value: 'YMCK' } { 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: 'YMCK', value: 'YMCK' }
]
}
+2
View File
@@ -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
} }
+45
View File
@@ -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
+199
View File
@@ -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: '—'
}
}
+201
View File
@@ -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: '—'
}
}
+199
View File
@@ -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: '—'
}
}
+97 -16
View File
@@ -1,17 +1,39 @@
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 { 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'
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 ?? '') // preload 未注入时(如普通浏览器调试/ preload 加载失败)不再白屏,直接跳过桌面侧能力
else console.log(p.message, p.data ?? '') 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> { async function setTrace(on: boolean): Promise<void> {
await window.cardsoonApi.invoke('config:set', { traceEnabled: on }) await window.cardsoonApi.invoke('config:set', { traceEnabled: on })
@@ -19,16 +41,75 @@ async function setTrace(on: boolean): Promise<void> {
} }
const w = window as Window & { trace?: (on?: boolean) => Promise<void>; dllTrace?: (on?: boolean) => Promise<void> } const w = window as Window & { trace?: (on?: boolean) => Promise<void>; dllTrace?: (on?: boolean) => Promise<void> }
w.trace = async (on = true) => setTrace(on)
w.dllTrace = w.trace
void (async () => { if (hasApi) {
const cfg = await configGet() w.trace = async (on = true) => setTrace(on)
const on = cfg.ok && cfg.data?.traceEnabled === true w.dllTrace = w.trace
console.info(`[trace] 控制台日志: ${on ? '已开启' : '已关闭'},执行 trace(false) 关闭`)
})() 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)
}
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+38 -42
View File
@@ -1,43 +1,39 @@
import type { Router } from 'vue-router' import type { Router } from 'vue-router'
import { useJobStore } from '@/stores/job'
import { useJobStore } from '@/stores/job' import { useAppStore } from '@/stores/app'
import { useAppStore } from '@/stores/app' export function setupRouterGuards(router: Router): void {
router.beforeEach((to, from) => {
const job = useJobStore()
const app = useAppStore()
export function setupRouterGuards(router: Router): void {
if (to.path === '/distribute/running' && !job.jobId && app.mode !== 'distributing') {
router.beforeEach((to, from) => { return { path: '/distribute/config' }
}
const job = useJobStore()
if (to.path === '/collect/running' && app.mode !== 'usbCopying' && app.mode !== 'ready') {
const app = useAppStore() return { path: '/collect' }
}
if (to.path === '/collect' && app.mode === 'distributing') {
if (to.path === '/distribute/running' && !job.jobId && app.mode !== 'distributing') { return { path: '/home' }
}
return { path: '/distribute/config' }
if (app.mode === 'usbCopying' && to.path.startsWith('/distribute')) {
} return { path: '/collect/running' }
}
if (from.path === '/distribute/running' && app.mode === 'distributing') {
if (to.path === '/distribute/failed') { if (to.path === '/distribute/config') return true
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' }
}
if (to.path === '/collect/running' && app.mode !== 'usbCopying' && app.mode !== 'ready') {
return true
return { path: '/collect' } })
}
}
-5
View File
@@ -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',
+97 -3
View File
@@ -1,68 +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 originName: string
fieldType: number
} }
export interface TemplatePreview { export interface TemplatePreview {
frontImageUrl: string frontImageUrl: string
backImageUrl: string backImageUrl: string
fields: TemplateFieldRow[] fields: TemplateFieldRow[]
/** soon 模板 flag1 双面 / 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_flag1 双面 / 2 仅正面 / 3 仅背面 */
printFlag: number
copyType: 0 | 1 copyType: 0 | 1
formatType: 'fat32' | 'exfat' | 'ntfs'
dongleUnlimited: boolean formatType: 'none' | 'fat32' | 'exfat' | 'ntfs'
dongleEnabled: boolean
/** 勾选加密狗时有效: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: 'fat32', formatType: 'fat32',
dongleUnlimited: false,
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
} }
} }
}) })
+30
View File
@@ -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)
}
}
}
})
+10
View File
@@ -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';
}
@@ -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;
+97 -11
View File
@@ -48,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;
@@ -76,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;
@@ -103,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) */
@@ -567,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;
+2
View File
@@ -25,6 +25,8 @@ export interface JobPollPayload {
failed: boolean failed: boolean
cancelled: boolean cancelled: boolean
finished: boolean finished: boolean
/** 任务失败当刻由主进程取到的打印机错误串 */
errorMessage?: string
} }
export interface UsbPollPayload { export interface UsbPollPayload {
+13
View File
@@ -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
}
+8 -2
View File
@@ -1,13 +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
} }
export const defaultPrinterStatus: PrinterStatusDisplay = { export const defaultPrinterStatus: PrinterStatusDisplay = {
ribbonType: '—', ribbonType: '—',
ribbonAmount: '—',
statusText: '—', statusText: '—',
serialNo: '—', serialNo: '—',
printedCount: 0 printerName: '—',
isSingleSide: false
} }
+17 -5
View File
@@ -1,13 +1,15 @@
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' import { resolveJobTasks } from '@/utils/validateJobConfig'
export interface BuildJobOptions { export interface BuildJobOptions {
taskId: string taskId: string
udfFile?: string udfFile?: string
netInfo?: NetInfoCredential[]
} }
function formatFileForApi(formatType: DistributeFormState['formatType']): string { function formatFileForApi(formatType: Exclude<DistributeFormState['formatType'], 'none'>): string {
switch (formatType) { switch (formatType) {
case 'exfat': case 'exfat':
return 'exFAT' return 'exFAT'
@@ -23,6 +25,7 @@ export function buildJobConfig(
opts: BuildJobOptions opts: BuildJobOptions
): Record<string, unknown> { ): Record<string, unknown> {
const { hasCopy, hasPrint } = resolveJobTasks(form) const { hasCopy, hasPrint } = resolveJobTasks(form)
const needFormat = form.formatType !== 'none'
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
task_id: opts.taskId, task_id: opts.taskId,
@@ -32,10 +35,17 @@ export function buildJobConfig(
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: true, need_format: needFormat,
format_file: formatFileForApi(form.formatType),
disk_size: '16GB', disk_size: '16GB',
dongle_install_count: form.dongleUnlimited ? 255 : 0 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) {
@@ -44,7 +54,7 @@ export function buildJobConfig(
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() const udf = opts.udfFile?.trim()
if (udf) body.udf_file = udf if (udf) body.udf_file = udf
} }
@@ -53,5 +63,7 @@ export function buildJobConfig(
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)
)
}
@@ -1,9 +1,16 @@
import i18n from '@/i18n'
import { genTaskId } from '@shared/gen-task-id' import { genTaskId } from '@shared/gen-task-id'
import { buildJobConfig } from '@/utils/buildJobConfig' import { buildJobConfig } from '@/utils/buildJobConfig'
import { resolveJobTasks } from '@/utils/validateJobConfig' import { resolveJobTasks } from '@/utils/validateJobConfig'
import { buildNetInfoForForm } from '@/utils/copyNetworkHosts'
import { dllJobCreate, fsWriteJobCsv } from '@/api/cardsoon' import { dllJobCreate, fsWriteJobCsv } from '@/api/cardsoon'
import { useJobStore } from '@/stores/job'
import type { DistributeFormState } from '@/stores/distributeForm' 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) { function printFieldRows(form: DistributeFormState) {
return (form.templatePreview?.fields ?? []).map((f) => ({ return (form.templatePreview?.fields ?? []).map((f) => ({
originName: f.originName || f.label.replace(/\[.*\]$/, ''), originName: f.originName || f.label.replace(/\[.*\]$/, ''),
@@ -11,13 +18,12 @@ function printFieldRows(form: DistributeFormState) {
})) }))
} }
export async function createDistributeJob( async function buildJobJson(
form: DistributeFormState, form: DistributeFormState
opts?: { resubmit?: boolean } ): Promise<{ ok: true; json: string } | { ok: false; message: string }> {
): Promise<{ ok: true; jobId: string } | { ok: false; message: string }> {
const { hasCopy, hasPrint } = resolveJobTasks(form) const { hasCopy, hasPrint } = resolveJobTasks(form)
if (!hasCopy && !hasPrint) { if (!hasCopy && !hasPrint) {
return { ok: false, message: '请配置拷贝路径或打印模板' } return { ok: false, message: t('validation.noTask') }
} }
const taskId = genTaskId() const taskId = genTaskId()
@@ -27,16 +33,50 @@ export async function createDistributeJob(
if (rows.length > 0) { if (rows.length > 0) {
const csv = await fsWriteJobCsv({ taskId, rows }) const csv = await fsWriteJobCsv({ taskId, rows })
if (!csv.ok || !csv.data?.path) { if (!csv.ok || !csv.data?.path) {
return { ok: false, message: csv.message || '生成打印变量 CSV 失败' } return { ok: false, message: csv.message || t('validation.csvGenFailed') }
} }
udfFile = csv.data.path udfFile = csv.data.path
} }
} }
const json = JSON.stringify(buildJobConfig(form, { taskId, udfFile })) 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) const created = await dllJobCreate(json, opts)
if (!created.ok || !created.data?.jobId) { if (!created.ok || !created.data?.jobId) {
return { ok: false, message: created.message || '创建任务失败' } return { ok: false, message: created.message || t('validation.createJobFailed') }
} }
return { ok: true, jobId: created.data.jobId } return { ok: true, jobId: created.data.jobId }
} }
@@ -1,4 +1,9 @@
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 } { export function resolveJobTasks(f: DistributeFormState): { hasCopy: boolean; hasPrint: boolean } {
const hasCopy = f.pathList.some((p) => !!p.path.trim()) const hasCopy = f.pathList.some((p) => !!p.path.trim())
@@ -8,8 +13,8 @@ export function resolveJobTasks(f: DistributeFormState): { hasCopy: boolean; has
export function validateJobConfig(f: DistributeFormState): string | null { export function validateJobConfig(f: DistributeFormState): string | null {
const { hasCopy, hasPrint } = resolveJobTasks(f) const { hasCopy, hasPrint } = resolveJobTasks(f)
if (!hasCopy && !hasPrint) return '请配置拷贝路径或打印模板' if (!hasCopy && !hasPrint) return t('validation.noTask')
if (hasPrint && !/\.soon$/i.test(f.templateFile.trim())) return '请选择 .soon 模板' if (hasPrint && !/\.soon$/i.test(f.templateFile.trim())) return t('validation.invalidTemplate')
if (hasCopy && f.pathList.some((p) => !p.path.trim())) return '路径不能为空' if (hasCopy && f.pathList.some((p) => !p.path.trim())) return t('validation.pathEmpty')
return null return null
} }
@@ -1,33 +1,126 @@
import type { DistributeFormState } from '@/stores/distributeForm' import type { DistributeFormState } from '@/stores/distributeForm'
import { fsPathExists } from '@/api/cardsoon' import i18n from '@/i18n'
import { fsPathExists, dllPrinterStatus } from '@/api/cardsoon'
import { resolveJobTasks, validateJobConfig } from '@/utils/validateJobConfig' 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 { function totalCopyBytes(form: DistributeFormState): number {
return form.pathList.reduce((sum, item) => sum + (item.sizeBytes || 0), 0) 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> { export async function validateJobPreflight(form: DistributeFormState): Promise<string | null> {
const err = validateJobConfig(form) const err = validateJobConfig(form)
if (err) return err if (err) return err
// 打印机硬件预检:打印/拷贝任务都需要设备,未连接或故障码(如夹卡)时拦截提交
const { hasCopy, hasPrint } = resolveJobTasks(form) const { hasCopy, hasPrint } = resolveJobTasks(form)
if (hasPrint || hasCopy) {
const fault = await preflightPrinterStatus()
if (fault) return fault
}
if (hasCopy) { if (hasCopy) {
const paths = form.pathList.map((x) => x.path) const paths = form.pathList.map((x) => x.path)
const ex = await fsPathExists(paths) if (paths.length > 0) {
if (ex.ok && ex.data?.missing.length) { const ex = await fsPathExists(paths)
return `路径不存在: ${ex.data.missing.join(', ')}` if (ex.ok && ex.data?.missing.length) {
return t('validation.pathNotExist', { paths: ex.data.missing.join(', ') })
}
if (totalCopyBytes(form) <= 0) {
return t('validation.noFilesToCopy')
}
} }
if (totalCopyBytes(form) <= 0) {
return '拷贝路径下没有可拷贝的文件' 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) { if (hasPrint) {
const soon = form.templateFile.trim() const soon = form.templateFile.trim()
const ex = await fsPathExists([soon]) const ex = await fsPathExists([soon])
if (ex.ok && ex.data?.missing.length) { if (ex.ok && ex.data?.missing.length) {
return '模板文件不存在' 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 return null
} }
+25 -16
View File
@@ -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,34 +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" :title="collectStore.destPath || undefined">{{ <span class="m-path-text" :title="collectStore.destPath || undefined">{{
collectStore.destPath || '未选择目录' collectStore.destPath || t('dataCollect.noDirSelected')
}}</span> }}</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>
@@ -57,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'
@@ -66,8 +67,10 @@ 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 } 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()
@@ -79,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]
@@ -88,21 +91,27 @@ 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 || '启动 USB 收集失败') notify.error(r.message || t('notify.startUsbFailed'))
return return
} }
appStore.setMode('usbCopying') appStore.setMode('usbCopying')
@@ -111,7 +120,7 @@ async function onSubmit(): Promise<void> {
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,36 +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.dongleUnlimited" type="checkbox" /> <input
<span>不限次数</span> v-model="formStore.dongleEnabled"
type="checkbox"
@change="onDongleToggle"
/>
<span>{{ t('distributeConfig.dongle') }}</span>
<input
:value="dongleInputValue"
type="number"
class="c-input dog-count"
min="0"
max="101"
step="1"
:disabled="!formStore.dongleEnabled"
@input="onDongleCountInput"
/>
<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>
@@ -70,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" @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"
@@ -87,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"
@@ -102,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>
@@ -112,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'
@@ -125,32 +232,63 @@ 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 { useConfigStore } from '@/stores/config'
import { useNetworkAuthStore } from '@/stores/networkAuth'
import { useDongleAuthStore } from '@/stores/dongleAuth'
import { validateJobPreflight } from '@/utils/validateJobPreflight' import { validateJobPreflight } from '@/utils/validateJobPreflight'
import { createDistributeJob } from '@/utils/createDistributeJob' import { createDistributeJob } from '@/utils/createDistributeJob'
import { formatBytesAsGb, formatBytesCompact } from '@/utils/formatBytes' import { formatBytesAsGb, formatBytesCompact } from '@/utils/formatBytes'
import type { TemplateFieldRow } from '@/stores/distributeForm'
import { import {
dialogOpenDirectory, dialogOpenDirectory,
dialogOpenImage,
dialogOpenSoon, dialogOpenSoon,
dllJobCancel, dllJobCancel,
fsDirSize, fsDirSize,
fsParseSoon fsParseSoon,
fsResolveNetworkHosts
} from '@/api/cardsoon' } 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)
) )
@@ -165,20 +303,123 @@ 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'))
}
function removeTemplate(): void {
formStore.templateFile = ''
formStore.templatePreview = null
formStore.printFlag = 1
notify.info(t('distributeConfig.templateRemoved'))
}
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
@@ -186,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)
@@ -210,6 +451,41 @@ function removePath(idx: number): void {
formStore.pathList.splice(idx, 1) formStore.pathList.splice(idx, 1)
} }
async function openNetworkDialog(): Promise<void> {
networkDialogHost.value = ''
networkDialogShare.value = ''
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> { async function pickTemplate(): Promise<void> {
const r = await dialogOpenSoon() const r = await dialogOpenSoon()
if (!r.ok) { if (!r.ok) {
@@ -225,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> {
@@ -249,6 +529,8 @@ async function onSubmit(): Promise<void> {
return return
} }
jobStore.submitting = true jobStore.submitting = true
// 新任务开始前重置次数计数器(不重置 jobId/lastJobJson,避免影响并发逻辑)
jobStore.resetCounts()
try { try {
const created = await createDistributeJob(formStore) const created = await createDistributeJob(formStore)
if (!created.ok) { if (!created.ok) {
@@ -264,7 +546,7 @@ async function onSubmit(): Promise<void> {
await dllJobCancel(newJobId) await dllJobCancel(newJobId)
jobStore.clearActiveJob() jobStore.clearActiveJob()
appStore.setMode('ready') appStore.setMode('ready')
notify.error('无法进入运行页,已取消任务') notify.error(t('notify.cannotEnterRunning'))
} }
} finally { } finally {
jobStore.submitting = false jobStore.submitting = false
@@ -275,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;
@@ -302,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,89 +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()
jobStore.clearActiveJob()
try {
const r = await dllPrinterErrorStr(-1)
errorText.value = r.ok && r.data?.text ? r.data.text : ''
} catch {
errorText.value = ''
}
})
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>
@@ -2,29 +2,26 @@
<AppShell> <AppShell>
<AppHeader :mode="headerMode"> <AppHeader :mode="headerMode">
<div v-if="phase === 'failed'" class="c-nav-group"> <div v-if="phase === 'failed'" class="c-nav-group">
<NavButton icon="arrow-left" label="返回" @click="onFailedBack" /> <NavButton icon="arrow-left" :label="t('common.back')" @click="onFailedBack" />
<NavButton icon="redo" label="重置" variant="primary" @click="onFailedReset" /> <NavButton icon="redo" :label="t('common.reset')" variant="primary" @click="onFailedReset" />
</div> </div>
<NavButton <NavButton
v-else-if="phase === 'completed'" v-else-if="phase === 'completed'"
icon="home" icon="home"
label="返回" :label="t('common.back')"
@click="onReturn" @click="onReturn"
/> />
<NavButton v-else icon="stop" label="停止" variant="stop" @click="onStop" /> <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">
<template v-if="phase === 'failed'"> <template v-if="phase === 'failed'">
<h2 class="c-status-title is-error">任务失败</h2> <h2 class="c-status-title is-error">{{ t('running.taskFailed') }}</h2>
<p class="c-status-sub">{{ failedSub }}</p> <p class="c-status-sub">{{ failedSub }}</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>
<p v-if="failureErrorText" class="m-error-detail">{{ failureErrorText }}</p> <p v-if="failureErrorText" class="m-error-detail">{{ failureErrorText }}</p>
</template> </template>
@@ -34,10 +31,7 @@
</h2> </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> </template>
</div> </div>
@@ -81,7 +75,9 @@
<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'
@@ -108,7 +104,7 @@ import {
import { createDistributeJob } from '@/utils/createDistributeJob' import { createDistributeJob } from '@/utils/createDistributeJob'
import { validateJobPreflight } from '@/utils/validateJobPreflight' import { validateJobPreflight } from '@/utils/validateJobPreflight'
import { mapJobStateToUi } from '@/utils/job-state' import { mapJobStateToUi } from '@/utils/job-state'
import { POSITION_PREPARE } from '@shared/card-position' import { POSITION_PREPARE, resolveCardPosition } from '@shared/card-position'
import { import {
USB_TASK_COPYING, USB_TASK_COPYING,
USB_TASK_PREPARING, USB_TASK_PREPARING,
@@ -119,15 +115,21 @@ import type { CardPositionPollPayload, JobPollPayload, UsbPollPayload } from '@/
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 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
) )
@@ -150,31 +152,38 @@ let lastCardPosition = -1
let usbAwaitNewCycle = false let usbAwaitNewCycle = false
let queryFailStreak = 0 let queryFailStreak = 0
let usbQueryFailStreak = 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 failedSub = computed(() => const failedSub = computed(() =>
isCollect.value ? '请检查读卡器与卡片后重新尝试' : '请检查设备故障后重新插入数据卡' isCollect.value
? t('running.failedSubCollect')
: t('running.failedSubDistribute')
) )
const statusTitle = computed(() => { const statusTitle = computed(() => {
if (phase.value === 'completed') { if (phase.value === 'completed') {
return isCollect.value ? '收集已完成' : '任务已完成' return isCollect.value ? t('running.collectCompleted') : t('running.taskCompleted')
} }
if (isCollect.value) { if (isCollect.value) {
return collectHint.value || '数据收集中' return collectHint.value || t('running.dataCollecting')
} }
return waitCard.value ? '等待插卡' : '任务执行中' return waitCard.value ? t('running.waitingCard') : t('running.taskRunning')
}) })
const statusSub = computed(() => { const statusSub = computed(() => {
if (phase.value === 'completed') { if (phase.value === 'completed') {
return '请点击返回,或插入数据卡继续下一张' return isCollect.value
? t('running.completedSubCollect')
: t('running.completedSubDistribute')
} }
if (isCollect.value) { if (isCollect.value) {
return collectHint.value || '正在从卡片读取并写入导入目录' return collectHint.value || t('running.collectingHint')
} }
return waitCard.value ? '请插入数据卡,任务将自动执行' : '任务执行中,请稍候' return waitCard.value ? t('running.waitCardHint') : t('running.runningHint')
}) })
function setProgress(value: number): void { function setProgress(value: number): void {
@@ -192,7 +201,41 @@ function clearPollListeners(): void {
unsubCard = null unsubCard = null
} }
/** 停止主进程轮询;resetMode=false 时保留 usbCopying/distributing 会话(完成态续做 */ /** 进入"等待进卡/读 U 盘"回合(已在等待中则不重复计时 */
function markWaiting(): void {
if (waitSince === 0) {
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> { async function releasePolls(resetMode: boolean): Promise<void> {
clearPollListeners() clearPollListeners()
await pollCardPositionStop() await pollCardPositionStop()
@@ -203,50 +246,79 @@ async function releasePolls(resetMode: boolean): Promise<void> {
} }
} }
async function enterFailedPhase(errorText = ''): Promise<void> { async function stopTaskPollsOnly(): Promise<void> {
if (phase.value === 'failed') return unsubJob?.()
phase.value = 'failed' unsubJob = null
failureStep.value = workflowStep.value unsubUsb?.()
failureErrorText.value = errorText unsubUsb = null
if (!failureErrorText.value && !isCollect.value) { if (isCollect.value) {
try { await pollUsbStop({ resetMode: false })
const r = await dllPrinterErrorStr(-1) } else {
failureErrorText.value = r.ok && r.data?.text ? r.data.text : '' await pollJobStop({ resetMode: false })
} catch {
failureErrorText.value = ''
}
} }
await releasePolls(true) }
jobStore.clearActiveJob()
appStore.setMode('ready') 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> { async function enterCompletedPhase(): Promise<void> {
if (phase.value === 'completed') return await enterWaitPhase('completed')
phase.value = 'completed' }
setProgress(100)
if (isCollect.value) { function canAutoResubmit(): boolean {
workflowStep.value = 3 return phase.value === 'completed' || phase.value === 'failed'
await pollUsbStop({ resetMode: false })
} else {
workflowStep.value = 4
await pollJobStop({ resetMode: false })
}
const cardStarted = await pollCardPositionStart()
if (!cardStarted.ok) {
notify.warning(cardStarted.message || '无法监控卡位,请手动点击返回或重新提交')
}
if (!unsubCard) {
unsubCard = onCardPositionTick((payload) =>
applyCardPosition(payload as CardPositionPollPayload)
)
}
} }
function applyCardPosition(p: CardPositionPollPayload): void { function applyCardPosition(p: CardPositionPollPayload): void {
if (phase.value !== 'completed' || resubmitting) return if (!canAutoResubmit() || resubmitting) return
if (p.queryCode !== 0) return const pos = resolveCardPosition(p.queryCode, p.position)
const pos = p.position if (pos === null) return
const enteredPrepare = pos === POSITION_PREPARE && lastCardPosition !== POSITION_PREPARE const enteredPrepare = pos === POSITION_PREPARE && lastCardPosition !== POSITION_PREPARE
lastCardPosition = pos lastCardPosition = pos
if (!enteredPrepare) return if (!enteredPrepare) return
@@ -254,59 +326,78 @@ function applyCardPosition(p: CardPositionPollPayload): void {
} }
async function resubmitTask(): Promise<void> { async function resubmitTask(): Promise<void> {
if (resubmitting || phase.value !== 'completed') return if (resubmitting || !canAutoResubmit()) return
const fromWaitPhase = phase.value
resubmitting = true resubmitting = true
await pollCardPositionStop() await pollCardPositionStop()
phase.value = 'running' unsubCard?.()
setProgress(0) unsubCard = null
workflowStep.value = 1
collectHint.value = '' const backToWait = async (message?: string): Promise<void> => {
waitCard.value = false if (message) notify.error(message)
if (fromWaitPhase === 'failed') {
await enterFailedPhase(message || failureErrorText.value)
} else {
await enterCompletedPhase()
}
}
try { try {
if (isCollect.value) { if (isCollect.value) {
const dest = collectStore.destPath.trim() const dest = collectStore.destPath.trim()
if (!dest) { if (!dest) {
notify.error('导入目录无效,无法继续') await backToWait(t('notify.dirInvalid'))
await enterCompletedPhase()
return 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 }) const r = await dllUsbCopy(dest, collectStore.cardOutput, { resubmit: true })
if (!r.ok) { if (!r.ok) {
notify.error(r.message || '重新提交收集任务失败') await backToWait(r.message || t('notify.resubmitCollectFailed'))
await enterCompletedPhase()
return return
} }
usbAwaitNewCycle = true usbAwaitNewCycle = true
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING) collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
if (!unsubUsb) { markWaiting()
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload)) unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
} void refreshLiveStatus()
return return
} }
const err = await validateJobPreflight(formStore) if (!jobStore.lastJobJson) {
if (err) { const err = await validateJobPreflight(formStore)
notify.warning(err) if (err) {
await enterCompletedPhase() notify.warning(err)
return await backToWait()
return
}
} }
phase.value = 'running'
setProgress(0)
workflowStep.value = 1
waitCard.value = false
queryFailStreak = 0
clearWaiting()
const created = await createDistributeJob(formStore, { resubmit: true }) const created = await createDistributeJob(formStore, { resubmit: true })
if (!created.ok) { if (!created.ok) {
notify.error(created.message) await backToWait(created.message)
await enterCompletedPhase()
return return
} }
jobStore.setActiveJob(created.jobId) jobStore.setActiveJob(created.jobId)
const started = await pollJobStart(created.jobId) const started = await pollJobStart(created.jobId)
if (!started.ok) { if (!started.ok) {
notify.error(started.message || '启动任务轮询失败') await backToWait(started.message || t('notify.pollStartFailed'))
await enterCompletedPhase()
return return
} }
if (!unsubJob) { unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload)) void refreshLiveStatus()
} } catch (e) {
await backToWait(String(e))
} finally { } finally {
resubmitting = false resubmitting = false
} }
@@ -318,7 +409,7 @@ function applyJobProgress(p: JobPollPayload): void {
queryFailStreak += 1 queryFailStreak += 1
if (queryFailStreak < 3) return if (queryFailStreak < 3) return
jobStore.failCount += 1 jobStore.failCount += 1
void enterFailedPhase(`查询任务失败: ${p.queryErrorCode}`) void enterFailedPhase(t('notify.queryJobFailed', { code: p.queryErrorCode }))
return return
} }
queryFailStreak = 0 queryFailStreak = 0
@@ -327,9 +418,12 @@ function applyJobProgress(p: JobPollPayload): void {
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 (ui.hint === 'waitCard') markWaiting()
else clearWaiting()
if (p.failed) { if (p.failed) {
jobStore.failCount += 1 jobStore.failCount += 1
void enterFailedPhase() // 优先使用主进程在失败当刻取到的错误串,避免事后取到通用文案
void enterFailedPhase(p.errorMessage || '')
return return
} }
if (p.cancelled) { if (p.cancelled) {
@@ -349,22 +443,25 @@ function applyUsbProgress(p: UsbPollPayload): void {
usbQueryFailStreak += 1 usbQueryFailStreak += 1
if (usbQueryFailStreak < 3) return if (usbQueryFailStreak < 3) return
collectStore.failCount += 1 collectStore.failCount += 1
void enterFailedPhase(`查询 USB 任务失败: ${p.queryCode}`) void enterFailedPhase(t('notify.queryUsbFailed', { code: p.queryCode }))
return return
} }
usbQueryFailStreak = 0 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
void enterFailedPhase(p.errorMessage || usbTaskStatusHint(p.taskStatus)) void enterFailedPhase(p.errorMessage || usbTaskStatusHint(p.taskStatus))
return return
} }
if (p.success) { if (p.success) {
if (usbAwaitNewCycle) return
collectStore.successCount += 1 collectStore.successCount += 1
workflowStep.value = 3 workflowStep.value = 3
collectHint.value = usbTaskStatusHint(p.taskStatus) collectHint.value = usbTaskStatusHint(p.taskStatus)
notify.success('USB 收集完成') notify.success(t('notify.usbCollectComplete'))
void enterCompletedPhase() void enterCompletedPhase()
return return
} }
@@ -372,10 +469,10 @@ function applyUsbProgress(p: UsbPollPayload): void {
const copyProgress = clampUsbCopyProgress(p.progress) const copyProgress = clampUsbCopyProgress(p.progress)
workflowStep.value = p.taskStatus === USB_TASK_COPYING ? 2 : 1 workflowStep.value = p.taskStatus === USB_TASK_COPYING ? 2 : 1
collectHint.value = usbTaskStatusHint(p.taskStatus) collectHint.value = usbTaskStatusHint(p.taskStatus)
// PREPARING(等待插卡/读 U 盘)开始计时;进入 COPYING 等后续阶段后清除
if (p.taskStatus === USB_TASK_PREPARING) markWaiting()
else clearWaiting()
setProgress(copyProgress) setProgress(copyProgress)
if (usbAwaitNewCycle && p.taskStatus === USB_TASK_PREPARING) {
usbAwaitNewCycle = false
}
} }
async function finishDistribute( async function finishDistribute(
@@ -384,13 +481,13 @@ async function finishDistribute(
): Promise<void> { ): Promise<void> {
if (finishing) return if (finishing) return
finishing = true finishing = true
const skipCancel = phase.value === 'completed' const skipCancel = phase.value === 'completed' || phase.value === 'failed'
await releasePolls(true) await releasePolls(true)
const id = jobStore.jobId const id = jobStore.jobId
if (id && !skipCancel) { if (id && !skipCancel) {
const r = await dllJobCancel(id) const r = await dllJobCancel(id)
if (!r.ok) { if (!r.ok) {
notify.warning(r.message || '取消任务时出现问题') notify.warning(r.message || t('notify.cancelIssue'))
} }
} }
jobStore.clearActiveJob() jobStore.clearActiveJob()
@@ -415,7 +512,10 @@ onMounted(async () => {
workflowStep.value = 1 workflowStep.value = 1
setProgress(0) setProgress(0)
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING) collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
startWaitWatchdog()
markWaiting()
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload)) unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
void refreshLiveStatus()
return return
} }
@@ -425,41 +525,52 @@ onMounted(async () => {
} }
appStore.setMode('distributing') appStore.setMode('distributing')
setProgress(0) setProgress(0)
clearWaiting()
startWaitWatchdog()
const started = await pollJobStart(jobStore.jobId) const started = await pollJobStart(jobStore.jobId)
if (!started.ok) { if (!started.ok) {
notify.error(started.message || '启动任务轮询失败') notify.error(started.message || t('notify.pollStartFailed'))
await finishDistribute(false) await finishDistribute(false)
return return
} }
unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload)) unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
void refreshLiveStatus()
}) })
onUnmounted(() => { onUnmounted(() => {
stopWaitWatchdog()
if (finishing || phase.value !== 'running') return if (finishing || phase.value !== 'running') return
void releasePolls(true) void releasePolls(true)
if (!isCollect.value && appStore.mode === 'distributing') { if (isCollect.value && appStore.mode === 'usbCopying') {
appStore.setMode('ready')
} else if (!isCollect.value && appStore.mode === 'distributing') {
appStore.setMode('ready') appStore.setMode('ready')
} }
}) })
async function onFailedBack(): Promise<void> { async function leaveFailedPage(redirect: '/collect' | '/distribute/config' | '/home'): Promise<void> {
finishing = true finishing = true
await releasePolls(true)
appStore.setMode('ready')
await router.push(redirect)
}
async function onFailedBack(): Promise<void> {
if (isCollect.value) { if (isCollect.value) {
await router.push('/collect') await leaveFailedPage('/collect')
return return
} }
await router.push('/distribute/config') await leaveFailedPage('/distribute/config')
} }
async function onFailedReset(): Promise<void> { async function onFailedReset(): Promise<void> {
finishing = true
if (isCollect.value) { if (isCollect.value) {
collectStore.reset() collectStore.reset()
} else { } else {
formStore.reset() formStore.reset()
jobStore.reset() jobStore.reset()
} }
await router.push('/home') await leaveFailedPage('/home')
} }
async function onReturn(): Promise<void> { async function onReturn(): Promise<void> {
@@ -484,11 +595,12 @@ async function onStop(): Promise<void> {
<style scoped> <style scoped>
.m-error-detail { .m-error-detail {
margin-top: 6px; margin-top: 8px;
font-size: 10px; font-size: 12px;
line-height: 1.5;
color: #dc3545; color: #dc3545;
font-weight: 600; font-weight: 600;
max-width: 320px; max-width: 340px;
word-break: break-all; word-break: break-all;
} }
+74 -30
View File
@@ -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>
@@ -49,18 +75,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } 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 { refreshPrinterHeader } from '@/composables/usePrinterStatus' 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, openDesignApp } 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()
@@ -73,41 +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) { if (r.ok) {
notify.success('已发送重置指令') notify.success(t('notify.resetSent'))
await refreshPrinterHeader(configStore) await refreshLiveStatus()
} else notify.error(r.message || '重置失败') } 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> {
const r = await openDesignApp() const r = await openDesignApp()
if (!r.ok) { if (!r.ok) {
notify.error(r.message || '打开设计软件失败,请检查 cardsoon.config.json') notify.warning(r.message || t('notify.designCancelled'))
return return
} }
notify.success('已启动设计软件') 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
@@ -122,10 +170,6 @@ function goCollect(): void {
if (!guardBusy()) return if (!guardBusy()) return
router.push('/collect') router.push('/collect')
} }
onMounted(() => {
if (canUse.value) void refreshPrinterHeader(configStore)
})
</script> </script>
<style src="@/styles/pages/page2.css"></style> <style src="@/styles/pages/page2.css"></style>
+32 -1
View File
@@ -1,2 +1,33 @@
/** workDll 卡位:备卡位,检测到后可自动重提任务 */ /** workDll 卡位:检测到后可自动续做 */
export const POSITION_PREPARE = 13 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 }
}
-1
View File
@@ -1,4 +1,3 @@
/** RestJobEx JSON 根字段 task_id */
export function genTaskId(): string { export function genTaskId(): string {
return `T${Date.now()}` return `T${Date.now()}`
} }
+81
View File
@@ -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
}
+53 -26
View File
@@ -1,8 +1,21 @@
export interface PrinterStatusSnapshot { export interface PrinterStatusSnapshot {
ribbonType: string ribbonType: string
ribbonAmount: string
statusText: string statusText: string
serialNo: string serialNo: string
printedCount: number /** 打印机型号/名称(如 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> = { const PRINTER_STATUS_MAP: Record<string, string> = {
@@ -30,46 +43,60 @@ function normalizeStatus(raw: unknown): string {
return PRINTER_STATUS_MAP[s] ?? s 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 { function snapshotFromRecord(row: Record<string, unknown>): PrinterStatusSnapshot {
const serial = pickFirst(row, [ const serial = pickFirst(row, [
'serial_no', 'serial_no',
'SerialNo', 'SerialNo',
'serialNo', 'serialNo',
'szPrinterSerial', 'szPrinterSerial',
'PrinterSerial', 'PrinterSerial'
'PrinterName'
]) ])
const statusRaw = pickFirst(row, [ const printerName = pickFirst(row, [
'printer_status', 'PrinterName',
'PrinterStatus', 'printer_name',
'PrinterType', 'PrinterModel',
'Status' 'model',
'szPrinterName',
'szPrinterModel'
]) ])
const ribbon = pickFirst(row, ['ribbon_type', 'RibbonType', 'RibbonAmount']) const ribbonType = pickFirst(row, ['ribbon_type', 'RibbonType'])
const printed = pickFirst(row, ['printed_count', 'PrintedCount', 'PrintCount', 'printedCount']) const ribbonAmount = pickFirst(row, ['RibbonAmount', 'ribbon_amount'])
const nameStr = String(printerName ?? '—')
let statusText = normalizeStatus(statusRaw)
if (statusText === '—') {
const remain = row.RibbonRemain ?? row.RemainCount
const capacity = row.RibbonCapacity ?? row.Capacity ?? row.MaxCount
if (remain != null && capacity != null) {
statusText = `${remain}/${capacity}`
}
}
return { return {
ribbonType: String(ribbon ?? '—'), ribbonType: String(ribbonType ?? '—'),
statusText, ribbonAmount: String(ribbonAmount ?? '—'),
serialNo: String(serial ?? '—'), statusText: '—',
printedCount: Number(printed ?? 0) serialNo: String(serial ?? printerName ?? '—'),
printerName: nameStr,
isSingleSide: detectSingleSide(nameStr)
} }
} }
/** SAPI_GetPrinterInfoEx(扁平 JSON)与 SAPI_GetPrinterInfoprinterList */
export function parsePrinterInfoFromDll(json: Record<string, unknown>): PrinterStatusSnapshot { export function parsePrinterInfoFromDll(json: Record<string, unknown>): PrinterStatusSnapshot {
const flatSerial = pickFirst(json, ['serial_no', 'SerialNo', 'serialNo', 'szPrinterSerial']) const flatSerial = pickFirst(json, ['serial_no', 'SerialNo', 'serialNo', 'szPrinterSerial'])
const flatStatus = pickFirst(json, ['printer_status', 'PrinterStatus']) if (flatSerial != null) {
if (flatSerial != null || flatStatus != null) {
return snapshotFromRecord(json) return snapshotFromRecord(json)
} }
File diff suppressed because one or more lines are too long