Compare commits

..

14 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
65 changed files with 4297 additions and 715 deletions
+16 -1
View File
@@ -1,6 +1,10 @@
import { resolve } from 'path'
import { readFileSync } from 'fs'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
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') }
@@ -19,6 +23,17 @@ export default defineConfig({
...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
})
]
}
})
+1031 -312
View File
File diff suppressed because it is too large Load Diff
+31 -14
View File
@@ -12,30 +12,42 @@
"build": "electron-vite build",
"preview": "electron-vite preview",
"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: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: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": {
"@fortawesome/fontawesome-free": "^6.4.0",
"electron-log": "^5.1.2",
"electron-store": "^8.1.0",
"element-plus": "^2.4.4",
"koffi": "^2.9.0",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
"vue-i18n": "^9.14.4"
},
"build": {
"appId": "com.cardsoon.machine",
"productName": "卡树数据卡打印系统",
"asar": true,
"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": {
"output": "release"
},
@@ -53,18 +65,19 @@
{
"from": "resources/native/CapSettings.json",
"to": "CapSettings.json"
},
{
"from": "resources/cardsoon.config.json",
"to": "cardsoon.config.json"
}
],
"win": {
"target": [
"nsis"
"zip"
],
"signAndEditExecutable": false
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"perMachine": false
},
"mac": {
"target": [
"dmg"
@@ -77,13 +90,17 @@
}
},
"devDependencies": {
"@intlify/unplugin-vue-i18n": "^1.6.0",
"@vitejs/plugin-vue": "^4.6.2",
"electron": "20.3.12",
"electron-builder": "^24.13.3",
"electron-vite": "^1.0.29",
"pinia": "^2.1.7",
"sass": "^1.69.5",
"typescript": "^5.3.3",
"vite": "^4.5.2",
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"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'
]
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)))
if (missing.length) {
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'
app.commandLine.appendSwitch('disable-gpu-shader-disk-cache')
@@ -14,10 +14,12 @@ if (!gotSingleInstanceLock) {
app.quit()
}
import { ensureConsoleUtf8 } from './utils/ensure-console-utf8'
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'
ensureConsoleUtf8()
suppressKnownDllStderr()
process.on('uncaughtException', (err) => {
@@ -41,24 +43,12 @@ function focusMainWindow(): void {
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 {
const { width, height } = getDefaultWindowSize()
// 目标设备屏幕为 720x360,窗口默认 1:1 显示设计稿大小
mainWindow = new BrowserWindow({
useContentSize: true,
width,
height,
width: DESIGN_WIDTH,
height: DESIGN_HEIGHT,
minWidth: MIN_CONTENT_WIDTH,
minHeight: contentHeightForWidth(MIN_CONTENT_WIDTH),
show: false,
@@ -94,6 +84,8 @@ function createWindow(): void {
mainWindow.on('resize', () => {
if (!mainWindow) return
// 最大化时让窗口填满屏幕,不强制 2:1 内容比例
if (mainWindow.isMaximized()) return
const [cw, ch] = mainWindow.getContentSize()
const wantH = contentHeightForWidth(cw)
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) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
@@ -133,6 +139,7 @@ app.whenReady().then(async () => {
try {
migrateTraceConfig()
loadAppFileConfig()
applyFileConfigToStore()
log.info('app startup', { packaged: app.isPackaged, execPath: process.execPath })
registerIpcHandlers()
try {
+79 -5
View File
@@ -12,6 +12,8 @@ import { openDesignApp } from '../services/open-design-app'
import { writeJobCsv, type JobCsvRow } from '../utils/job-csv'
import { parseSoonTemplate } from '../utils/parse-soon'
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 { loadDllModule } from '../services/dll-loader'
import { tracedHandle } from './traced-handler'
@@ -66,7 +68,7 @@ export function registerIpcHandlers(): void {
return ok({
...cached,
fromCache: true,
liveError: r.code <= 0 ? '未连接打印机' : `GetPrinterInfoEx code=${r.code}`
liveError: r.code <= 0 ? '未连接打印机' : `GetPrinterInfo code=${r.code}`
})
}
return fail(0, '未连接打印机')
@@ -103,9 +105,11 @@ export function registerIpcHandlers(): void {
if (parsed.ok) {
const snapshot: PrinterStatusSnapshot = {
ribbonType: cached?.ribbonType ?? '—',
ribbonAmount: cached?.ribbonAmount ?? '—',
statusText: parsed.statusText,
serialNo: cached?.serialNo ?? '—',
printedCount: cached?.printedCount ?? 0
printerName: cached?.printerName ?? '—',
isSingleSide: cached?.isSingleSide ?? false
}
configStore.set('lastPrinterStatus', snapshot)
return ok({ statusText: parsed.statusText, statusCode: code })
@@ -132,7 +136,12 @@ export function registerIpcHandlers(): void {
assertReady()
const dll = await loadDllModule()
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) {
return fail(CS_FAIL, String(err))
}
@@ -144,7 +153,46 @@ export function registerIpcHandlers(): void {
const dll = await loadDllModule()
if (!dll.isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE')
const code = dll.dllPrinterReject()
return code === CS_OK ? ok() : fail(code, '废卡失败')
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) {
return fail(CS_FAIL, String(err))
}
@@ -287,9 +335,12 @@ export function registerIpcHandlers(): void {
return ok()
})
tracedHandle('poll:card-position-start', async () => {
tracedHandle('poll:card-position-start', async (_e, sessionMode?: 'distributing' | 'usbCopying') => {
try {
assertReady()
if (sessionMode === 'distributing' || sessionMode === 'usbCopying') {
mainAppState.mode = sessionMode
}
const dll = await loadDllModule()
if (!dll.isCardPositionApiAvailable()) {
return fail(CS_FAIL, '当前 DLL 不支持卡位查询,无法自动续做')
@@ -348,6 +399,29 @@ export function registerIpcHandlers(): void {
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(
'fs:write-job-csv',
(_e, payload: { taskId: string; rows: JobCsvRow[] }) => {
+54 -3
View File
@@ -2,20 +2,59 @@ import { app } from 'electron'
import fs from 'fs'
import path from 'path'
import log from 'electron-log'
import { LOG_FATAL_FLAG } from '../constants'
import { configStore } from './config-store'
import { getProcessExecDir } from './native-path'
export const APP_CONFIG_FILENAME = 'cardsoon.config.json'
const DEFAULT_SHARED_DIR = path.join('C:', 'PrintTasks')
export interface AppFileConfig {
designAppPath: string
sharedDir: string
logLevel: number
cleanTaskFile: boolean
}
const defaults: AppFileConfig = {
designAppPath: ''
designAppPath: '',
sharedDir: DEFAULT_SHARED_DIR,
logLevel: LOG_FATAL_FLAG,
cleanTaskFile: false
}
let cached: AppFileConfig | null = null
function pickRaw(raw: Record<string, unknown>, ...keys: string[]): unknown {
for (const key of keys) {
const hit = Object.entries(raw).find(([name]) => name.toLowerCase() === key.toLowerCase())
if (hit && hit[1] != null && String(hit[1]).trim() !== '') return hit[1]
}
return undefined
}
function parseCleanTaskFile(raw: unknown): boolean {
if (raw === undefined || raw === null) return defaults.cleanTaskFile
if (typeof raw === 'boolean') return raw
if (typeof raw === 'number') return raw !== 0
const s = String(raw).trim().toLowerCase()
if (s === '0' || s === 'false') return false
if (s === '1' || s === 'true') return true
return defaults.cleanTaskFile
}
function parseLogLevel(raw: unknown): number {
if (raw === undefined || raw === null) return defaults.logLevel
const n = Number(raw)
return Number.isFinite(n) ? Math.round(n) : defaults.logLevel
}
function parseSharedDir(raw: unknown): string {
const s = String(raw ?? '').trim()
return s || DEFAULT_SHARED_DIR
}
function bundledConfigPath(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, APP_CONFIG_FILENAME)
@@ -33,7 +72,10 @@ function configSearchPaths(): string[] {
function parseConfigFile(filePath: string): AppFileConfig {
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, unknown>
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
try {
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
} catch (e) {
log.warn(`Skip invalid ${APP_CONFIG_FILENAME}: ${filePath}`, e)
@@ -58,6 +104,11 @@ export function loadAppFileConfig(): AppFileConfig {
return cached
}
export function applyFileConfigToStore(): void {
const cfg = loadAppFileConfig()
configStore.set('sharedDir', cfg.sharedDir)
}
export function getDesignAppPath(): string {
return loadAppFileConfig().designAppPath
}
+5 -3
View File
@@ -3,6 +3,7 @@ import log from 'electron-log'
import { CS_OK } from '../constants'
import { mainAppState } from './app-state'
import { configStore } from './config-store'
import { loadAppFileConfig } from './app-config'
import { loadDllModule } from './dll-loader'
import type { InitParams } from './work-dll.service'
@@ -18,8 +19,9 @@ export async function ensureDllInitialized(
if (dllInitAttempted) {
return { code: CS_OK }
}
const fileCfg = loadAppFileConfig()
const sharedDir =
params?.sharedDir || (configStore.get('sharedDir') as string) || 'C:\\PrintTasks'
params?.sharedDir?.trim() || fileCfg.sharedDir || (configStore.get('sharedDir') as string)
try {
const dll = await loadDllModule()
fs.mkdirSync(sharedDir, { recursive: true })
@@ -27,10 +29,10 @@ export async function ensureDllInitialized(
sharedDir,
keepCombinedImage: params?.keepCombinedImage,
stopOnFailure: params?.stopOnFailure,
cleanTaskFile: params?.cleanTaskFile,
cleanTaskFile: params?.cleanTaskFile ?? fileCfg.cleanTaskFile,
autoRetryTimes: params?.autoRetryTimes,
rejectConfig: params?.rejectConfig,
logLevel: params?.logLevel,
logLevel: params?.logLevel ?? fileCfg.logLevel,
outBack: params?.outBack
})
dllInitAttempted = true
+3 -1
View File
@@ -23,7 +23,9 @@ export async function openDesignApp(
const target = path.resolve(exePath.trim())
const err = await shell.openPath(target)
if (err) {
return { ok: false, message: err }
// 用户在 UAC/系统弹窗中点击"否"或被系统拒绝时,shell.openPath 返回通用错误字符串
// 不把原始 "Failed to Open Path" 直接抛给用户,给出可读说明
return { ok: false, message: '打开设计软件失败,可能已被取消或需要管理员权限' }
}
return { ok: true }
}
+13 -2
View File
@@ -1,5 +1,6 @@
import { BrowserWindow } from 'electron'
import log from 'electron-log'
import { normalizeCardPositionQuery } from '@shared/card-position'
import { POLL_INTERVAL_MS, CS_OK } from '../constants'
import {
USB_TASK_COMPLETED,
@@ -79,6 +80,15 @@ export function startJobPoll(id: string): void {
const cancelled = r.jobState === 6
const finished = r.jobState === 100
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 = {
jobId,
queryErrorCode: r.queryErrorCode,
@@ -87,7 +97,8 @@ export function startJobPoll(id: string): void {
terminal,
failed,
cancelled,
finished
finished,
errorMessage
}
emitTrace('[poll] job:poll-tick', tick)
send('job:poll-tick', tick)
@@ -159,7 +170,7 @@ async function pollCardPositionOnce(): Promise<void> {
const dll = await loadDllModule()
if (!dll.isCardPositionApiAvailable()) return
const r = dll.dllGetPrinterCardPosition()
const tick = { queryCode: r.queryCode, position: r.position }
const tick = normalizeCardPositionQuery(r.queryCode, r.position)
emitTrace('[poll] card:position-tick', tick)
send('card:position-tick', tick)
} catch (e) {
+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()
}
+49 -30
View File
@@ -1,6 +1,7 @@
import path from 'path'
import koffi from 'koffi'
import log from 'electron-log'
import { normalizeCardPositionQuery } from '@shared/card-position'
import { CS_OK, JOB_ID_BUF_SIZE, LOG_FATAL_FLAG } from '../constants'
import { emitTrace, isTraceEnabled } from '../utils/trace-bridge'
import { getNativeDir } from './native-path'
@@ -23,8 +24,6 @@ let SAPI_Init: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterInfo: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterInfoEx: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_FreePrinterInfo: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterErrorStr: any = null
@@ -45,6 +44,8 @@ let SAPI_PrinterMovetoreject: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterMovetousbreader: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterMovetohopper: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterCardPosition: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterCheckstatus: any = null
@@ -55,8 +56,8 @@ let hasCardPositionApi = false
let hasCheckstatusApi = false
let hasCancelApi = false
let hasUploadApi = false
let hasPrinterInfoEx = false
let hasUsbReaderApi = false
let hasHopperApi = false
let loggedCancelMissing = false
let loggedRejectMissing = false
@@ -81,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> } {
if (len <= 0) return { code: len }
const ptr = koffi.decode(outPtr, 0, 'void *') as number
@@ -94,15 +109,7 @@ function readPrinterJsonFromOutPtr(len: number, outPtr: Buffer): { code: number;
return { code: len }
}
} finally {
if (SAPI_FreePrinterInfo) {
try {
SAPI_FreePrinterInfo(ptr)
} catch (e) {
log.warn('SAPI_FreePrinterInfo', e)
}
} else {
koffi.free(ptr)
}
freePrinterInfoPtr(ptr)
}
}
@@ -123,13 +130,10 @@ function loadLibrary(): void {
SAPI_PrinterResetprinter = lib.func('int __stdcall SAPI_PrinterResetprinter()')
try {
SAPI_GetPrinterInfoEx = lib.func('int __stdcall SAPI_GetPrinterInfoEx(_Out_ void **)')
SAPI_FreePrinterInfo = lib.func('void __stdcall SAPI_FreePrinterInfo(void *)')
hasPrinterInfoEx = true
} catch {
SAPI_GetPrinterInfoEx = null
SAPI_FreePrinterInfo = null
hasPrinterInfoEx = false
log.warn('SAPI_FreePrinterInfo not in workDll')
}
try {
@@ -173,6 +177,15 @@ function loadLibrary(): void {
emitTrace('[dll] SAPI_PrinterMovetousbreader not in workDll (optional)')
}
try {
SAPI_PrinterMovetohopper = lib.func('int __stdcall SAPI_PrinterMovetohopper()')
hasHopperApi = true
} catch {
SAPI_PrinterMovetohopper = null
hasHopperApi = false
emitTrace('[dll] SAPI_PrinterMovetohopper not in workDll (optional)')
}
try {
SAPI_GetPrinterCardPosition = lib.func('int __stdcall SAPI_GetPrinterCardPosition(_Out_ int *)')
hasCardPositionApi = true
@@ -193,10 +206,10 @@ function loadLibrary(): void {
log.info('workDll loaded', {
upload: hasUploadApi,
printerInfoEx: hasPrinterInfoEx,
cancel: hasCancelApi,
reject: hasRejectApi,
usbReader: hasUsbReaderApi,
hopper: hasHopperApi,
cardPosition: hasCardPositionApi,
checkstatus: hasCheckstatusApi
})
@@ -222,6 +235,11 @@ export function isUsbReaderApiAvailable(): boolean {
return hasUsbReaderApi
}
export function isHopperApiAvailable(): boolean {
loadLibrary()
return hasHopperApi
}
export function isCardPositionApiAvailable(): boolean {
loadLibrary()
return hasCardPositionApi
@@ -239,7 +257,7 @@ export function dllInit(params: InitParams): number {
sharedDir: params.sharedDir,
keepCombinedImage: params.keepCombinedImage ?? true,
stopOnFailure: params.stopOnFailure ?? false,
cleanTaskFile: params.cleanTaskFile ?? true,
cleanTaskFile: params.cleanTaskFile ?? false,
autoRetryTimes: params.autoRetryTimes ?? 0,
rejectConfig: params.rejectConfig ?? false,
logLevel: params.logLevel ?? LOG_FATAL_FLAG,
@@ -251,7 +269,7 @@ export function dllInit(params: InitParams): number {
params.sharedDir,
params.keepCombinedImage ?? true,
params.stopOnFailure ?? false,
params.cleanTaskFile ?? true,
params.cleanTaskFile ?? false,
params.autoRetryTimes ?? 0,
params.rejectConfig ?? false,
params.logLevel ?? LOG_FATAL_FLAG,
@@ -262,7 +280,7 @@ export function dllInit(params: InitParams): number {
}
function dllGetPrinterInfoInternal(
apiName: 'SAPI_GetPrinterInfo' | 'SAPI_GetPrinterInfoEx',
apiName: 'SAPI_GetPrinterInfo',
fn: (outPtr: Buffer) => number
): { code: number; json?: Record<string, unknown> } {
return traceCall(apiName, undefined, () => {
@@ -278,13 +296,6 @@ function dllGetPrinterInfoInternal(
}
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))
}
@@ -384,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 {
return traceCall('SAPI_PrinterMovetoreject', undefined, () => {
loadLibrary()
@@ -396,9 +415,9 @@ export function dllGetPrinterCardPosition(): { queryCode: number; position: numb
return traceCall('SAPI_GetPrinterCardPosition', undefined, () => {
loadLibrary()
if (!SAPI_GetPrinterCardPosition) throw new Error('CARD_POSITION_API_UNAVAILABLE')
const position = [0]
const queryCode = SAPI_GetPrinterCardPosition!(position) as number
return { queryCode, position: position[0] }
const out = [0]
const ret = SAPI_GetPrinterCardPosition!(out) as number
return normalizeCardPositionQuery(ret, out[0])
})
}
+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
}
+18 -9
View File
@@ -5,24 +5,28 @@ export interface TemplateFieldRow {
label: string
value: string
originName: string
/** soon frontData/backData 的 type1=图片,3/4/5=文本可编辑 */
fieldType: number
}
export interface ParsedSoonTemplate {
frontImageUrl: string
backImageUrl: string
fields: TemplateFieldRow[]
printFlag: number
/** soon 模板 flag1 双面 / 2 正面 / 3 背面 */
templateFlag: number
}
const SOON_FIELD_TYPES = new Set([1, 3, 4, 5])
export function readSoonPrintFlag(raw: Record<string, unknown>): number {
/** soon 文件元数据 flag,用于与任务 print_flag 校验 */
export function readSoonTemplateFlag(raw: Record<string, unknown>): number {
const flag = Number(raw.flag)
if (flag === 1 || flag === 2) return 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 ? 2 : 1
return hasBack ? 1 : 2
}
function pickArray(obj: Record<string, unknown>, key: string): Record<string, unknown>[] {
@@ -80,7 +84,7 @@ function parseSoonWorkerDisk(soonPath: string, raw: Record<string, unknown>): Pa
const name = String(o.name ?? '').trim()
if (!name) continue
const value = o.DefaultText == null ? '' : String(o.DefaultText)
fields.push({ label: `${name}[${side}]`, value, originName: name })
fields.push({ label: `${name}[${side}]`, value, originName: name, fieldType: type })
}
}
@@ -94,7 +98,7 @@ function parseSoonWorkerDisk(soonPath: string, raw: Record<string, unknown>): Pa
frontImageUrl: toImageUrl(soonPath, frontPic),
backImageUrl: toImageUrl(soonPath, backPic),
fields,
printFlag: readSoonPrintFlag(raw)
templateFlag: readSoonTemplateFlag(raw)
}
}
@@ -115,7 +119,7 @@ function parseSoonLegacy(soonPath: string, raw: Record<string, unknown>): Parsed
if (side === 'front') {
if (!frontImageUrl) frontImageUrl = url
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) {
backImageUrl = url
}
@@ -127,10 +131,15 @@ function parseSoonLegacy(soonPath: string, raw: Record<string, unknown>): Parsed
const value = pickStr(item, ['value', 'text', 'default', 'content', 'data'])
let side = sideOf(item)
if (!side) side = /image|img|front/i.test(name) ? 'front' : 'back'
fields.push({ label: toFieldLabel(name, side), value, originName: name })
fields.push({ label: toFieldLabel(name, side), value, originName: name, fieldType: 5 })
})
return { frontImageUrl, backImageUrl, fields, printFlag: readSoonPrintFlag(raw) }
return {
frontImageUrl,
backImageUrl,
fields,
templateFlag: readSoonTemplateFlag(raw)
}
}
export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
+5
View File
@@ -7,6 +7,8 @@ const channels = {
'dll:printer-status',
'dll:printer-reset',
'dll:printer-reject',
'dll:printer-read-card',
'dll:printer-eject-card',
'dll:printer-error-str',
'dll:job-create',
'dll:job-cancel',
@@ -22,7 +24,10 @@ const channels = {
'fs:path-exists',
'fs:dir-size',
'fs:parse-soon',
'fs:resolve-network-hosts',
'fs:write-job-csv',
'secrets:get',
'secrets:set',
'config:get',
'config:set',
'shell:open-path',
+2
View File
@@ -4,6 +4,8 @@
<script setup lang="ts">
import { useAppBootstrap } from '@/composables/useAppBootstrap'
import { usePrinterStatusPoll } from '@/composables/usePrinterStatusPoll'
useAppBootstrap()
usePrinterStatusPoll()
</script>
+45 -6
View File
@@ -34,6 +34,16 @@ export async function dllPrinterReject(): 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 }>> {
return api().invoke('dll:reject-available') as Promise<IpcResult<{ available: boolean }>>
}
@@ -73,8 +83,10 @@ export async function pollUsbStop(opts?: { resetMode?: boolean }): Promise<IpcRe
return api().invoke('poll:usb-stop', opts) as Promise<IpcResult>
}
export async function pollCardPositionStart(): Promise<IpcResult> {
return api().invoke('poll:card-position-start') as Promise<IpcResult>
export async function pollCardPositionStart(
sessionMode?: 'distributing' | 'usbCopying'
): Promise<IpcResult> {
return api().invoke('poll:card-position-start', sessionMode) as Promise<IpcResult>
}
export async function pollCardPositionStop(): Promise<IpcResult> {
@@ -103,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[] }>> {
return api().invoke('fs:path-exists', paths) as Promise<IpcResult<{ missing: string[] }>>
}
@@ -126,20 +144,41 @@ export async function fsParseSoon(filePath: string): Promise<
IpcResult<{
frontImageUrl: string
backImageUrl: string
fields: { label: string; value: string; originName: string }[]
printFlag: number
fields: { label: string; value: string; originName: string; fieldType: number }[]
templateFlag: number
}>
> {
return api().invoke('fs:parse-soon', filePath) as Promise<
IpcResult<{
frontImageUrl: string
backImageUrl: string
fields: { label: string; value: string; originName: string }[]
printFlag: number
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<
IpcResult<{
sharedDir: string
+10 -2
View File
@@ -13,7 +13,11 @@ export const ICON_NAMES = [
'plus',
'exchange',
'stop',
'warning'
'warning',
'id-card',
'eject',
'eye',
'eye-slash'
] as const
export type IconName = (typeof ICON_NAMES)[number]
@@ -34,5 +38,9 @@ export const ICON_FA_CLASS: Record<IconName, string> = {
plus: 'fas fa-plus',
exchange: 'fas fa-exchange-alt',
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>
<footer class="c-footer">
<div>版本V1.0</div>
<div>www.cardsoon.com</div>
<div>版权所有 © 2026 卡树科技</div>
<div class="c-footer__version">{{ t('footer.version', { version: appVersion }) }}</div>
<div>{{ t('footer.website') }}</div>
<div>{{ t('footer.copyright') }}</div>
</footer>
</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>
+64 -15
View File
@@ -5,16 +5,24 @@
<div class="c-header__center">
<div v-if="mode" class="c-mode-badge c-mode-badge--home">{{ mode }}</div>
<div class="c-status-capsule">
<span>色带: <b>{{ status.ribbonType }}</b></span>
<span
>状态: <b :class="statusTone">{{ status.statusText }}</b></span
>
<span>序列号: <b>{{ status.serialNo }}</b></span>
<span>已发行: <b>{{ status.printedCount }}</b></span>
<span>{{ t('header.ribbon') }}: <b>{{ status.ribbonType }}</b></span>
<span>{{ t('header.ribbonAmount') }}: <b>{{ status.ribbonAmount }}</b></span>
<span>{{ t('header.status') }}: <b :class="statusTone">{{ displayStatusText }}</b></span>
<span>{{ t('header.serialNo') }}: <b>{{ status.serialNo }}</b></span>
</div>
</div>
<div class="c-header__actions-slot">
<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 />
</div>
</div>
@@ -23,24 +31,65 @@
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfigStore } from '@/stores/config'
import { useAppStore } from '@/stores/app'
import { refreshPrinterHeader } from '@/composables/usePrinterStatus'
import { LOCALE_OPTIONS, persistLocale, type AppLocale } from '@/i18n'
defineProps<{ mode?: string }>()
const configStore = useConfigStore()
const appStore = useAppStore()
const status = computed(() => configStore.printer)
const { t, locale } = useI18n()
onMounted(() => {
if (appStore.initialized) void refreshPrinterHeader(configStore)
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 t = status.value.statusText
if (t.includes('未初始化') || t.includes('未连接')) return 'c-status-warn'
return ''
const toneMap: Record<string, string> = {
[t('printerStatus.notInitialized')]: 'c-status-warn',
[t('printerStatus.notConnected')]: 'c-status-warn',
[t('printerStatus.initFailed')]: 'c-status-warn'
}
return toneMap[displayStatusText.value] || ''
})
</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">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = withDefaults(
defineProps<{
@@ -23,20 +24,22 @@ const props = withDefaults(
{ activeStep: 2, mode: 'running', variant: 'distribute' }
)
const distributeSteps = [
{ key: 'prep', label: '任务准备' },
{ key: 'copy', label: '拷贝数据' },
{ key: 'print', label: '打印卡片' },
{ key: 'done', label: '完成' }
] as const
const { t } = useI18n()
const collectSteps = [
{ key: 'prep', label: '任务准备' },
{ key: 'copy', label: '拷贝数据' },
{ key: 'done', label: '完成' }
] as const
const distributeSteps = computed(() => [
{ key: 'prep', label: t('workflow.taskPrep') },
{ key: 'copy', label: t('workflow.copyData') },
{ key: 'print', label: t('workflow.printCard') },
{ 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))
@@ -1,10 +1,15 @@
import { onMounted } from 'vue'
import i18n from '@/i18n'
import { notify } from '@/composables/useNotify'
import { applyPrinterPayload, refreshPrinterFullInfo } from '@/composables/usePrinterStatus'
import { refreshPrinterAfterInit } from '@/composables/usePrinterStatus'
import { configGet, dllInit, dllRejectAvailable } from '@/api/cardsoon'
import { useAppStore } from '@/stores/app'
import { useConfigStore } from '@/stores/config'
function t(key: string): string {
return i18n.global.t(key)
}
let bootstrapped = false
function placeholderStatus(configStore: ReturnType<typeof useConfigStore>, text: string): void {
@@ -41,9 +46,9 @@ export function useAppBootstrap(): void {
const cfg = await hydrateFromConfig()
if (cfg.ok && cfg.data?.dllInitialized) {
appStore.setInitialized(true)
placeholderStatus(configStore, '就绪')
placeholderStatus(configStore, t('printerStatus.ready'))
await syncRejectApi()
window.setTimeout(() => void refreshPrinterFullInfo(configStore), 1500)
window.setTimeout(() => void refreshPrinterAfterInit(), 1500)
return
}
@@ -51,18 +56,18 @@ export function useAppBootstrap(): void {
configStore.setSharedDir(sharedDir)
const init = await dllInit({ sharedDir })
if (!init.ok) {
appStore.setInitialized(false, init.message || 'Init 失败')
configStore.setPrinter({ ...configStore.printer, statusText: '初始化失败' })
notify.error(init.message || '初始化失败,请检查任务目录权限')
appStore.setInitialized(false, init.message || t('printerStatus.initFailed'))
configStore.setPrinter({ ...configStore.printer, statusText: t('printerStatus.initFailed') })
notify.error(init.message || t('notify.initFailedHint'))
return
}
appStore.setInitialized(true)
const initMeta = init.data as { warning?: string } | undefined
if (initMeta?.warning) notify.warning(initMeta.warning)
placeholderStatus(configStore, initMeta?.warning ? '未连接打印机' : '就绪')
placeholderStatus(configStore, initMeta?.warning ? t('printerStatus.notConnected') : t('printerStatus.ready'))
await syncRejectApi()
window.setTimeout(() => void refreshPrinterFullInfo(configStore), 1500)
window.setTimeout(() => void refreshPrinterAfterInit(), 1500)
}
onMounted(() => {
+10 -3
View File
@@ -1,5 +1,10 @@
import i18n from '@/i18n'
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 {
useToastStore().push(type, message, durationMs)
}
@@ -11,9 +16,11 @@ export const notify = {
info: (message: string, durationMs?: number) => push('info', message, durationMs)
}
const INIT_HINT = '系统未就绪,请重启应用或检查打印机与任务目录'
/** 未初始化等业务拦截时的统一提示 */
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,68 +1,57 @@
import { dllPrinterInfo, dllPrinterStatus, parsePrinterInfo } from '@/api/cardsoon'
import { useConfigStore } from '@/stores/config'
import type { PrinterStatusDisplay } from '@/types/printer'
function isFullPrinterPayload(data: Record<string, unknown>): boolean {
return (
data.snapshot != null ||
data.printerList != null ||
data.serial_no != null ||
data.SerialNo != null
)
/** 状态是否表示打印机未连接/不可用 */
function isDisconnectedStatus(text: string): boolean {
return !text || text === '—' || text.includes('未连接') || text.includes('Not connected')
}
export function applyPrinterPayload(
configStore: ReturnType<typeof useConfigStore>,
data: Record<string, unknown>
): void {
const snapshot = data.snapshot as PrinterStatusDisplay | undefined
if (snapshot) {
configStore.setPrinter(snapshot)
return
}
if (isFullPrinterPayload(data)) {
configStore.setPrinter(parsePrinterInfo(data))
return
}
if (typeof data.statusText === 'string') {
configStore.setPrinter({
...configStore.printer,
statusText: data.statusText
})
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)
})
}
}
let prevStatusText = ''
export async function refreshPrinterHeader(
configStore: ReturnType<typeof useConfigStore>
): Promise<void> {
export async function refreshLiveStatus(): Promise<void> {
const store = useConfigStore()
try {
const r = await dllPrinterStatus()
if (r.ok && r.data) {
applyPrinterPayload(configStore, r.data as Record<string, unknown>)
if (r.ok && r.data?.statusText) {
const newStatus = r.data.statusText
// 检测状态变化:从未连接 → 已连接时,立即刷新打印机信息(型号/isSingleSide等)
const wasDisconnected = isDisconnectedStatus(prevStatusText)
const nowConnected = !isDisconnectedStatus(newStatus)
store.setPrinter({ ...store.printer, statusText: newStatus })
if (wasDisconnected && nowConnected) {
// 打印机刚连上,立即拉取型号/色带等信息
void refreshPrinterInfo()
}
prevStatusText = newStatus
}
} catch {
/* 无打印机时不阻塞 */
/* ignore */
}
}
export async function refreshPrinterFullInfo(
configStore: ReturnType<typeof useConfigStore>
): Promise<void> {
export async function refreshPrinterInfo(): Promise<void> {
const store = useConfigStore()
try {
const info = await dllPrinterInfo()
if (info.ok && info.data) {
applyPrinterPayload(configStore, info.data)
}
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()
}
)
}
+27 -15
View File
@@ -1,28 +1,40 @@
import type { ComposerTranslation } from 'vue-i18n'
export interface SelectOption {
label: string
value: string | number
}
export const COPY_TYPE_OPTIONS: SelectOption[] = [
{ label: '文件拷贝', value: 0 },
{ label: '镜像刻录', value: 1 }
]
type T = ComposerTranslation
export const FORMAT_TYPE_OPTIONS: SelectOption[] = [
{ label: '不格式化', value: 'none' },
{ label: 'Fat32', value: 'fat32' },
{ label: 'exFat', value: 'exfat' },
{ label: 'NTFS', value: 'ntfs' }
export function copyTypeOptions(t: T): SelectOption[] {
return [
{ label: t('copyType.fileCopy'), value: 0 },
{ label: t('copyType.imageBurn'), value: 1 }
]
}
export const PRIORITY_OPTIONS: SelectOption[] = [
{ label: '低', value: 'low' },
{ label: '中', value: 'mid' },
{ label: '高', value: 'high' }
export function formatTypeOptions(t: T): SelectOption[] {
return [
{ label: t('formatType.none'), value: 'none' },
{ label: t('formatType.fat32'), value: 'fat32' },
{ label: t('formatType.exfat'), value: 'exfat' },
{ label: t('formatType.ntfs'), value: 'ntfs' }
]
}
export const RIBBON_TYPE_OPTIONS: SelectOption[] = [
{ label: '任何', value: 'any' },
export function priorityOptions(t: T): SelectOption[] {
return [
{ label: t('common.low'), value: 'low' },
{ label: t('common.mid'), value: 'mid' },
{ label: t('common.high'), value: 'high' }
]
}
export function ribbonTypeOptions(t: T): SelectOption[] {
return [
{ label: t('common.any'), value: 'any' },
{ label: 'YMCKO', value: 'YMCKO' },
{ label: 'YMCK', value: 'YMCK' }
]
}
+2
View File
@@ -14,6 +14,8 @@ interface CardsoonApi {
}
declare global {
/** 构建时由 electron.vite.config.ts 从 package.json version 注入 */
const __APP_VERSION__: string
interface Window {
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: '—'
}
}
+82 -1
View File
@@ -1,17 +1,39 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import i18n from '@/i18n'
import { configGet } from '@/api/cardsoon'
import { useNetworkAuthStore } from '@/stores/networkAuth'
import { useDongleAuthStore } from '@/stores/dongleAuth'
import { useDistributeFormStore } from '@/stores/distributeForm'
import App from './App.vue'
import router from './router'
import './styles/design-base.css'
import './styles/icons-font.css'
import './styles/shell.css'
// preload 未注入时(如普通浏览器调试/ preload 加载失败)不再白屏,直接跳过桌面侧能力
const hasApi = typeof window.cardsoonApi !== 'undefined'
if (hasApi) {
window.cardsoonApi.on('app:trace', (payload) => {
const p = payload as { level: string; message: string; data?: Record<string, unknown> }
if (p.level === 'error') console.error(p.message, p.data ?? '')
else console.log(p.message, p.data ?? '')
})
}
async function setTrace(on: boolean): Promise<void> {
await window.cardsoonApi.invoke('config:set', { traceEnabled: on })
@@ -19,6 +41,8 @@ async function setTrace(on: boolean): Promise<void> {
}
const w = window as Window & { trace?: (on?: boolean) => Promise<void>; dllTrace?: (on?: boolean) => Promise<void> }
if (hasApi) {
w.trace = async (on = true) => setTrace(on)
w.dllTrace = w.trace
@@ -27,8 +51,65 @@ void (async () => {
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.use(createPinia())
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)
}
+92 -2
View File
@@ -1,72 +1,162 @@
import { defineStore } from 'pinia'
export interface TemplateFieldRow {
label: string
value: string
originName: string
fieldType: number
}
export interface TemplatePreview {
frontImageUrl: string
backImageUrl: string
fields: TemplateFieldRow[]
printFlag: number
/** soon 模板 flag1 双面 / 2 正面 / 3 背面 */
templateFlag: number
}
export interface PathListItem {
path: string
meta: string
sizeBytes: number
}
export interface DistributeFormState {
pathList: PathListItem[]
volumeLabel: string
templateFile: string
templatePreview: TemplatePreview | null
/** 任务 print_flag1 双面 / 2 仅正面 / 3 仅背面 */
printFlag: number
copyType: 0 | 1
formatType: 'none' | 'fat32' | 'exfat' | 'ntfs'
dongleEnabled: boolean
/** 勾选加密狗时有效:0 默认,1-101 为次数(101=不限次数) */
dongleInstallCount: number
dongleAuthCode: string
priority: 'low' | 'mid' | 'high'
ribbonType: 'any' | 'YMCKO' | 'YMCK'
generateIso: boolean
generateZip: boolean
printCmdToHasi: boolean
presetCopy: boolean
generateHasi: boolean
dongleCountCheck: boolean
failPrintLabel: boolean
}
function createDefaultForm(): DistributeFormState {
return {
pathList: [],
volumeLabel: 'DATA_CARD',
templateFile: '',
templatePreview: null,
printFlag: 1,
copyType: 0,
formatType: 'fat32',
dongleEnabled: false,
dongleInstallCount: 0,
dongleAuthCode: '',
priority: 'low',
ribbonType: 'any',
generateIso: false,
generateZip: false,
printCmdToHasi: false,
presetCopy: false,
generateHasi: false,
dongleCountCheck: false,
failPrintLabel: false
}
}
}
export const useDistributeFormStore = defineStore('distributeForm', {
state: (): DistributeFormState => createDefaultForm(),
actions: {
reset() {
const preservedAuth = this.dongleAuthCode
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', {
state: () => ({
jobId: '',
lastJobJson: '',
submitting: false,
successCount: 0,
failCount: 0
@@ -11,11 +12,20 @@ export const useJobStore = defineStore('job', {
setActiveJob(jobId: string) {
this.jobId = jobId
},
setLastJobJson(json: string) {
this.lastJobJson = json
},
clearActiveJob() {
this.jobId = ''
},
/** 重置次数计数器(不影响当前 jobId/lastJobJson */
resetCounts() {
this.successCount = 0
this.failCount = 0
},
reset() {
this.jobId = ''
this.lastJobJson = ''
this.submitting = false
this.successCount = 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 {
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);
}
/* 不支持的工具按钮(如当前 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 {
width: 1px;
@@ -110,6 +110,56 @@
margin-left: 3px;
}
.m-panel-toolbar .c-checkbox-item .c-input.dog-auth {
width: 140px;
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;
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;
margin-left: 6px;
flex-shrink: 0;
white-space: nowrap;
}
/* 提示文字 */
.m-panel-toolbar .c-checkbox-item .dog-hint {
font-size: 9px;
@@ -566,6 +616,43 @@
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 {
flex: 1;
min-width: 0;
+2
View File
@@ -25,6 +25,8 @@ export interface JobPollPayload {
failed: boolean
cancelled: boolean
finished: boolean
/** 任务失败当刻由主进程取到的打印机错误串 */
errorMessage?: string
}
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 {
ribbonType: string
ribbonAmount: string
statusText: string
serialNo: string
printedCount: number
/** 打印机型号/名称(如 TH80),用于判断单/双面能力 */
printerName: string
/** 是否为单面打印机(如 TH80),单面打印机不能选"双面"打印 */
isSingleSide: boolean
}
export const defaultPrinterStatus: PrinterStatusDisplay = {
ribbonType: '—',
ribbonAmount: '—',
statusText: '—',
serialNo: '—',
printedCount: 0
printerName: '—',
isSingleSide: false
}
+9 -1
View File
@@ -1,10 +1,12 @@
import type { DistributeFormState } from '@/stores/distributeForm'
import type { NetInfoCredential } from '@shared/network-host'
import { cleanPathPattern } from '@shared/path-pattern'
import { resolveJobTasks } from '@/utils/validateJobConfig'
export interface BuildJobOptions {
taskId: string
udfFile?: string
netInfo?: NetInfoCredential[]
}
function formatFileForApi(formatType: Exclude<DistributeFormState['formatType'], 'none'>): string {
@@ -38,6 +40,10 @@ export function buildJobConfig(
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'>)
}
@@ -48,7 +54,7 @@ export function buildJobConfig(
if (hasPrint) {
body.json_file = form.templateFile.trim()
body.print_flag = form.templatePreview?.printFlag ?? 1
body.print_flag = form.printFlag
const udf = opts.udfFile?.trim()
if (udf) body.udf_file = udf
}
@@ -57,5 +63,7 @@ export function buildJobConfig(
if (form.generateZip) body.is_generate_zip = true
if (form.failPrintLabel) body.is_printer_record_logo = true
if (opts.netInfo?.length) body.net_info = opts.netInfo
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 { buildJobConfig } from '@/utils/buildJobConfig'
import { resolveJobTasks } from '@/utils/validateJobConfig'
import { buildNetInfoForForm } from '@/utils/copyNetworkHosts'
import { dllJobCreate, fsWriteJobCsv } from '@/api/cardsoon'
import { useJobStore } from '@/stores/job'
import type { DistributeFormState } from '@/stores/distributeForm'
function t(key: string, named?: Record<string, unknown>): string {
return i18n.global.t(key, named ?? {})
}
function printFieldRows(form: DistributeFormState) {
return (form.templatePreview?.fields ?? []).map((f) => ({
originName: f.originName || f.label.replace(/\[.*\]$/, ''),
@@ -11,13 +18,12 @@ function printFieldRows(form: DistributeFormState) {
}))
}
export async function createDistributeJob(
form: DistributeFormState,
opts?: { resubmit?: boolean }
): Promise<{ ok: true; jobId: string } | { ok: false; message: string }> {
async function buildJobJson(
form: DistributeFormState
): Promise<{ ok: true; json: string } | { ok: false; message: string }> {
const { hasCopy, hasPrint } = resolveJobTasks(form)
if (!hasCopy && !hasPrint) {
return { ok: false, message: '请配置拷贝路径或打印模板' }
return { ok: false, message: t('validation.noTask') }
}
const taskId = genTaskId()
@@ -27,16 +33,50 @@ export async function createDistributeJob(
if (rows.length > 0) {
const csv = await fsWriteJobCsv({ taskId, rows })
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
}
}
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)
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 }
}
@@ -1,4 +1,9 @@
import type { DistributeFormState } from '@/stores/distributeForm'
import i18n from '@/i18n'
function t(key: string, named?: Record<string, unknown>): string {
return i18n.global.t(key, named ?? {})
}
export function resolveJobTasks(f: DistributeFormState): { hasCopy: boolean; hasPrint: boolean } {
const hasCopy = f.pathList.some((p) => !!p.path.trim())
@@ -8,8 +13,8 @@ export function resolveJobTasks(f: DistributeFormState): { hasCopy: boolean; has
export function validateJobConfig(f: DistributeFormState): string | null {
const { hasCopy, hasPrint } = resolveJobTasks(f)
if (!hasCopy && !hasPrint) return '请配置拷贝路径或打印模板'
if (hasPrint && !/\.soon$/i.test(f.templateFile.trim())) return '请选择 .soon 模板'
if (hasCopy && f.pathList.some((p) => !p.path.trim())) return '路径不能为空'
if (!hasCopy && !hasPrint) return t('validation.noTask')
if (hasPrint && !/\.soon$/i.test(f.templateFile.trim())) return t('validation.invalidTemplate')
if (hasCopy && f.pathList.some((p) => !p.path.trim())) return t('validation.pathEmpty')
return null
}
@@ -1,38 +1,126 @@
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 { resolveCopyNetworkHosts } from '@/utils/copyNetworkHosts'
import { useNetworkAuthStore } from '@/stores/networkAuth'
import { useConfigStore } from '@/stores/config'
function t(key: string, named?: Record<string, unknown>): string {
return i18n.global.t(key, named ?? {})
}
function totalCopyBytes(form: DistributeFormState): number {
return form.pathList.reduce((sum, item) => sum + (item.sizeBytes || 0), 0)
}
export function printFlagMismatch(printFlag: number, templateFlag: number): boolean {
return (
(printFlag === 1 && templateFlag !== 1) ||
(printFlag === 2 && templateFlag === 3) ||
(printFlag === 3 && templateFlag === 2)
)
}
/**
* 提交前打印机硬件状态预检(分发/收集共用)。
* - 返回非 null 字符串:拦截提交并提示(未连接 / 设备故障码,如夹卡)
* - 返回 null:放行(含 DLL 不支持状态查询、查询异常等无法判断的情况,交给后续流程)
* 注:夹卡等具体故障码表待 DLL 侧提供后,可在此按 statusCode 精确映射文案。
*/
export async function preflightPrinterStatus(): Promise<string | null> {
try {
const r = await dllPrinterStatus()
if (!r.ok) {
const msg = r.message || ''
// DLL 不支持状态查询时无法判断,放行交给后续流程
if (msg.includes('不支持') || msg.toLowerCase().includes('not supported')) {
return null
}
return t('validation.printerNotReady')
}
const data = r.data
if (!data) return t('validation.printerNotReady')
// 健康:主进程返回了明确的非负状态码(0 空闲 / 忙碌 / 打印中)
if (typeof data.statusCode === 'number' && data.statusCode >= 0) return null
// 缓存兜底 + liveError:实时查询失败(断连 / 故障码)
if (data.fromCache && data.liveError) return t('validation.printerNotReady')
// 无状态码(未初始化 / 未知异常):保守拦截
return t('validation.printerNotReady')
} catch {
// 查询异常时不阻塞,让后续流程处理
return null
}
}
export async function validateJobPreflight(form: DistributeFormState): Promise<string | null> {
const err = validateJobConfig(form)
if (err) return err
// 打印机硬件预检:打印/拷贝任务都需要设备,未连接或故障码(如夹卡)时拦截提交
const { hasCopy, hasPrint } = resolveJobTasks(form)
if (hasPrint || hasCopy) {
const fault = await preflightPrinterStatus()
if (fault) return fault
}
if (hasCopy) {
const paths = form.pathList.map((x) => x.path)
if (paths.length > 0) {
const ex = await fsPathExists(paths)
if (ex.ok && ex.data?.missing.length) {
return `路径不存在: ${ex.data.missing.join(', ')}`
return t('validation.pathNotExist', { paths: ex.data.missing.join(', ') })
}
if (totalCopyBytes(form) <= 0) {
return '拷贝路径下没有可拷贝的文件'
return t('validation.noFilesToCopy')
}
}
const hosts = await resolveCopyNetworkHosts(form)
if (hosts.length > 0) {
const netStore = useNetworkAuthStore()
for (const host of hosts) {
const cred = netStore.getCredential(host)
if (!cred?.userName?.trim() || !cred.password) {
return t('validation.netCredMissing', { host })
}
}
}
}
if (hasPrint) {
const soon = form.templateFile.trim()
const ex = await fsPathExists([soon])
if (ex.ok && ex.data?.missing.length) {
return '模板文件不存在'
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 '加密狗次数须为 0 或 1-101'
return t('validation.dongleCountInvalid')
}
}
return null
}
+25 -16
View File
@@ -1,12 +1,12 @@
<template>
<AppShell>
<AppHeader mode="数据收集模式">
<AppHeader :mode="t('header.modeCollect')">
<div class="c-nav-group">
<NavButton icon="home" label="首页" @click="goHome" />
<NavButton icon="trash" label="清空" @click="collectStore.reset()" />
<NavButton icon="home" :label="t('common.home')" @click="goHome" />
<NavButton icon="trash" :label="t('common.clear')" @click="collectStore.reset()" />
<NavButton
icon="check-circle"
label="提交"
:label="t('common.submit')"
variant="primary"
:active="true"
:disabled="!canSubmit"
@@ -18,34 +18,34 @@
<section class="m-config-panel">
<h3 class="m-panel-title">
<AppIcon name="folder-open" size="sm" />
数据导入地址
{{ t('dataCollect.importPath') }}
</h3>
<div class="m-path-box">
<span class="m-path-text" :title="collectStore.destPath || undefined">{{
collectStore.destPath || '未选择目录'
collectStore.destPath || t('dataCollect.noDirSelected')
}}</span>
</div>
<button type="button" class="m-path-btn" @click="addPath">
<AppIcon name="plus" size="sm" />
添加路径
{{ t('common.addPath') }}
</button>
</section>
<div class="m-divider-v" />
<section class="m-config-panel">
<h3 class="m-panel-title">
<AppIcon name="exchange" size="sm" />
出卡方向
{{ t('dataCollect.cardOutput') }}
</h3>
<div class="m-radio-group">
<label class="m-radio-item">
<input v-model="collectStore.cardOutput" type="radio" :value="1" />
<span class="radio-custom" />
<span>向前出卡</span>
<span>{{ t('dataCollect.forwardOutput') }}</span>
</label>
<label class="m-radio-item">
<input v-model="collectStore.cardOutput" type="radio" :value="2" />
<span class="radio-custom" />
<span>向后出卡</span>
<span>{{ t('dataCollect.backwardOutput') }}</span>
</label>
</div>
</section>
@@ -57,6 +57,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { notify, notifyRequireInit } from '@/composables/useNotify'
import AppShell from '@/layouts/AppShell.vue'
import AppHeader from '@/components/AppHeader.vue'
@@ -66,8 +67,10 @@ import AppIcon from '@/components/AppIcon.vue'
import { useCollectStore } from '@/stores/collect'
import { useAppStore } from '@/stores/app'
import { dialogOpenDirectory, dllUsbCopy } from '@/api/cardsoon'
import { preflightPrinterStatus } from '@/utils/validateJobPreflight'
const router = useRouter()
const { t } = useI18n()
const collectStore = useCollectStore()
const appStore = useAppStore()
@@ -79,7 +82,7 @@ const canSubmit = computed(
async function addPath(): Promise<void> {
const r = await dialogOpenDirectory()
if (!r.ok) {
notify.error(r.message || '打开目录选择失败')
notify.error(r.message || t('notify.pathOpenFailed'))
return
}
const picked = r.data?.paths[0]
@@ -88,21 +91,27 @@ async function addPath(): Promise<void> {
async function onSubmit(): Promise<void> {
if (!canUse.value) {
notifyRequireInit('开始 USB 收集')
notifyRequireInit(t('notify.startUsbFailed'))
return
}
if (appStore.mode === 'distributing') {
notify.warning('请先停止数据分发任务')
notify.warning(t('notify.stopDistribute'))
return
}
const dest = collectStore.destPath.trim()
if (!dest) {
notify.warning('请先选择数据导入目录')
notify.warning(t('notify.selectDirFirst'))
return
}
// 打印机硬件预检:未连接或故障码(如夹卡)时拦截提交
const fault = await preflightPrinterStatus()
if (fault) {
notify.warning(fault)
return
}
const r = await dllUsbCopy(dest, collectStore.cardOutput)
if (!r.ok) {
notify.error(r.message || '启动 USB 收集失败')
notify.error(r.message || t('notify.startUsbFailed'))
return
}
appStore.setMode('usbCopying')
@@ -111,7 +120,7 @@ async function onSubmit(): Promise<void> {
function goHome(): void {
if (appStore.mode === 'usbCopying') {
notify.warning('数据收集进行中,请先在任务页停止')
notify.warning(t('notify.usbCollectInProgress'))
return
}
router.push('/home')
@@ -1,12 +1,12 @@
<template>
<AppShell>
<AppHeader mode="数据分发模式">
<AppHeader :mode="t('header.modeDistribute')">
<div class="c-nav-group">
<NavButton icon="home" label="首页" @click="router.push('/home')" />
<NavButton icon="trash" label="清空" @click="onClear" />
<NavButton icon="home" :label="t('common.home')" @click="router.push('/home')" />
<NavButton icon="trash" :label="t('common.clear')" @click="onClear" />
<NavButton
icon="check-circle"
label="提交"
:label="t('common.submit')"
variant="primary"
:active="true"
:disabled="!canSubmit"
@@ -17,32 +17,38 @@
<main class="app-shell__main l-main-flex">
<section class="c-panel m-panel--left">
<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">
<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>
</div>
</div>
<div class="m-path-hint">
<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 class="m-panel-toolbar">
<div class="toolbar-row">
<div class="toolbar-item">
<span>卷标</span>
<span>{{ t('distributeConfig.volumeLabel') }}</span>
<input v-model="formStore.volumeLabel" type="text" class="c-input" />
</div>
<div class="toolbar-item">
<span>拷贝类型</span>
<AppSelect v-model="formStore.copyType" :items="COPY_TYPE_OPTIONS" />
<span>{{ t('distributeConfig.copyType') }}</span>
<AppSelect v-model="formStore.copyType" :items="copyTypeItems" />
</div>
</div>
<div class="toolbar-row">
<div class="toolbar-item">
<span>格式化类型</span>
<AppSelect v-model="formStore.formatType" :items="FORMAT_TYPE_OPTIONS" />
<span>{{ t('distributeConfig.formatType') }}</span>
<AppSelect v-model="formStore.formatType" :items="formatTypeItems" />
</div>
<label class="c-checkbox-item">
<input
@@ -50,7 +56,7 @@
type="checkbox"
@change="onDongleToggle"
/>
<span>加密狗</span>
<span>{{ t('distributeConfig.dongle') }}</span>
<input
:value="dongleInputValue"
type="number"
@@ -62,6 +68,26 @@
@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>
</div>
</div>
@@ -85,15 +111,51 @@
</section>
<section class="c-panel m-panel--right">
<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">
<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>
</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-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
v-if="formStore.templatePreview?.frontImageUrl"
class="c-card-small__img"
@@ -102,7 +164,14 @@
/>
<span v-else class="c-card-side-label">FRONT</span>
</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
v-if="formStore.templatePreview?.backImageUrl"
class="c-card-small__img"
@@ -117,7 +186,21 @@
<table class="c-data-table-mini">
<tr v-for="(row, idx) in formStore.templatePreview!.fields" :key="idx">
<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>
</td>
</tr>
@@ -127,12 +210,21 @@
</section>
</main>
<AppFooter />
<NetworkPathDialog
v-model:visible="networkDialogVisible"
:initial-host="networkDialogHost"
:initial-share="networkDialogShare"
@confirm="onNetworkConfirm"
/>
</AppShell>
</template>
<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 { cleanPathPattern } from '@shared/path-pattern'
import { extractDriveLetter } from '@shared/network-host'
import { notify, notifyRequireInit } from '@/composables/useNotify'
import AppShell from '@/layouts/AppShell.vue'
import AppHeader from '@/components/AppHeader.vue'
@@ -140,32 +232,63 @@ import AppFooter from '@/components/AppFooter.vue'
import NavButton from '@/components/NavButton.vue'
import AppIcon from '@/components/AppIcon.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 { useDistributeFormStore } from '@/stores/distributeForm'
import { useJobStore } from '@/stores/job'
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 { createDistributeJob } from '@/utils/createDistributeJob'
import { formatBytesAsGb, formatBytesCompact } from '@/utils/formatBytes'
import type { TemplateFieldRow } from '@/stores/distributeForm'
import {
dialogOpenDirectory,
dialogOpenImage,
dialogOpenSoon,
dllJobCancel,
fsDirSize,
fsParseSoon
fsParseSoon,
fsResolveNetworkHosts
} from '@/api/cardsoon'
const router = useRouter()
const { t } = useI18n()
const formStore = useDistributeFormStore()
const jobStore = useJobStore()
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 canSubmit = computed(
() => canUse.value && !jobStore.submitting && appStore.mode !== 'usbCopying'
)
const configuredHosts = computed(() => netStore.configuredHosts)
const totalLoadedBytes = computed(() =>
formStore.pathList.reduce((sum, item) => sum + (item.sizeBytes || 0), 0)
)
@@ -180,14 +303,85 @@ const hasTemplatePreview = computed(
() => !!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 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 {
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(() =>
@@ -215,10 +409,17 @@ function onDongleCountInput(e: Event): void {
formStore.dongleInstallCount = clampDongleCount(raw)
}
function onDongleAuthInput(): void {
if (dongleAuthPersistTimer) clearTimeout(dongleAuthPersistTimer)
dongleAuthPersistTimer = setTimeout(() => {
void dongleStore.persist(formStore.dongleAuthCode)
}, 300)
}
async function addPath(): Promise<void> {
const r = await dialogOpenDirectory()
if (!r.ok) {
notify.error(r.message || '打开目录选择失败')
notify.error(r.message || t('notify.pathOpenFailed'))
return
}
if (!r.data?.paths.length) return
@@ -226,7 +427,7 @@ async function addPath(): Promise<void> {
const idx = formStore.pathList.length
formStore.pathList.push({
path: `${dir}\\*.*`,
meta: '计算中…',
meta: t('distributeConfig.calculating'),
sizeBytes: 0
})
await refreshPathSize(idx, dir)
@@ -250,6 +451,41 @@ function removePath(idx: number): void {
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> {
const r = await dialogOpenSoon()
if (!r.ok) {
@@ -265,12 +501,16 @@ async function pickTemplate(): Promise<void> {
}
formStore.templateFile = soonPath
formStore.templatePreview = parsed.data
formStore.printFlag = defaultPrintFlagForTemplate(
parsed.data.templateFlag,
isSingleSidePrinter.value
)
const { fields, frontImageUrl, backImageUrl } = parsed.data
if (!fields.length && !frontImageUrl && !backImageUrl) {
notify.warning('模板已打开,但未解析到可预览内容')
notify.warning(t('distributeConfig.templateNoPreview'))
return
}
notify.success('已加载标签模板')
notify.success(t('distributeConfig.templateLoaded'))
}
async function onSubmit(): Promise<void> {
@@ -289,6 +529,8 @@ async function onSubmit(): Promise<void> {
return
}
jobStore.submitting = true
// 新任务开始前重置次数计数器(不重置 jobId/lastJobJson,避免影响并发逻辑)
jobStore.resetCounts()
try {
const created = await createDistributeJob(formStore)
if (!created.ok) {
@@ -304,7 +546,7 @@ async function onSubmit(): Promise<void> {
await dllJobCancel(newJobId)
jobStore.clearActiveJob()
appStore.setMode('ready')
notify.error('无法进入运行页,已取消任务')
notify.error(t('notify.cannotEnterRunning'))
}
} finally {
jobStore.submitting = false
@@ -315,6 +557,32 @@ async function onSubmit(): Promise<void> {
<style src="@/styles/pages/page4.css"></style>
<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 {
display: flex;
align-items: center;
@@ -342,10 +610,15 @@ async function onSubmit(): Promise<void> {
background: #f8f9fa;
}
.c-field-value {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.c-card-side-pick {
cursor: pointer;
}
.c-card-side-pick--off {
cursor: not-allowed;
}
.c-card-side--dim {
opacity: 0.35;
}
</style>
@@ -2,29 +2,26 @@
<AppShell>
<AppHeader :mode="headerMode">
<div v-if="phase === 'failed'" class="c-nav-group">
<NavButton icon="arrow-left" label="返回" @click="onFailedBack" />
<NavButton icon="redo" label="重置" variant="primary" @click="onFailedReset" />
<NavButton icon="arrow-left" :label="t('common.back')" @click="onFailedBack" />
<NavButton icon="redo" :label="t('common.reset')" variant="primary" @click="onFailedReset" />
</div>
<NavButton
v-else-if="phase === 'completed'"
icon="home"
label="返回"
:label="t('common.back')"
@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>
<main class="app-shell__main l-main-full">
<section class="l-hero-container">
<div class="m-left-panel">
<div class="c-status-panel">
<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-counter">
任务已经完成<span class="ok">{{ successCount }}</span>其中失败次数是<span
class="err"
>{{ failCount }}</span
>
{{ t('running.progressCounter', { success: successCount, fail: failCount }) }}
</p>
<p v-if="failureErrorText" class="m-error-detail">{{ failureErrorText }}</p>
</template>
@@ -34,10 +31,7 @@
</h2>
<p class="c-status-sub">{{ statusSub }}</p>
<p class="c-status-counter">
任务已经完成<span class="ok">{{ successCount }}</span>其中失败次数是<span
class="err"
>{{ failCount }}</span
>
{{ t('running.progressCounter', { success: successCount, fail: failCount }) }}
</p>
</template>
</div>
@@ -81,7 +75,9 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { notify } from '@/composables/useNotify'
import { refreshLiveStatus } from '@/composables/usePrinterStatus'
import AppShell from '@/layouts/AppShell.vue'
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
@@ -108,7 +104,7 @@ import {
import { createDistributeJob } from '@/utils/createDistributeJob'
import { validateJobPreflight } from '@/utils/validateJobPreflight'
import { mapJobStateToUi } from '@/utils/job-state'
import { POSITION_PREPARE } from '@shared/card-position'
import { POSITION_PREPARE, resolveCardPosition } from '@shared/card-position'
import {
USB_TASK_COPYING,
USB_TASK_PREPARING,
@@ -119,15 +115,21 @@ import type { CardPositionPollPayload, JobPollPayload, UsbPollPayload } from '@/
const CIRCLE_LEN = 283
/** 等待进卡/读 U 盘超过该时长仍无进展,给出可操作提示(每个等待回合提示一次) */
const WAIT_WARN_MS = 45000
/** 看门狗检查间隔 */
const WAIT_WATCHDOG_MS = 5000
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const jobStore = useJobStore()
const collectStore = useCollectStore()
const formStore = useDistributeFormStore()
const appStore = useAppStore()
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(() =>
isCollect.value ? collectStore.successCount : jobStore.successCount
)
@@ -150,35 +152,38 @@ let lastCardPosition = -1
let usbAwaitNewCycle = false
let queryFailStreak = 0
let usbQueryFailStreak = 0
let waitSince = 0
let waitWarned = false
let waitWatchdog: ReturnType<typeof setInterval> | null = null
const displayProgress = computed(() => Math.round(progress.value))
const failedSub = computed(() =>
isCollect.value
? '请检查读卡器与卡片,插入备卡位可自动重试'
: '请检查设备故障,插入备卡位可自动重试'
? t('running.failedSubCollect')
: t('running.failedSubDistribute')
)
const statusTitle = computed(() => {
if (phase.value === 'completed') {
return isCollect.value ? '收集已完成' : '任务已完成'
return isCollect.value ? t('running.collectCompleted') : t('running.taskCompleted')
}
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(() => {
if (phase.value === 'completed') {
return isCollect.value
? '请点击返回,或插入备卡位继续下一张'
: '请点击返回,或插入备卡位自动开始下一张'
? t('running.completedSubCollect')
: t('running.completedSubDistribute')
}
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 {
@@ -196,6 +201,41 @@ function clearPollListeners(): void {
unsubCard = null
}
/** 进入"等待进卡/读 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> {
clearPollListeners()
await pollCardPositionStop()
@@ -218,19 +258,22 @@ async function stopTaskPollsOnly(): Promise<void> {
}
}
async function startCardPositionWatch(): Promise<void> {
async function startCardPositionWatch(
sessionMode: 'distributing' | 'usbCopying'
): Promise<void> {
await pollCardPositionStop()
const cardStarted = await pollCardPositionStart()
if (!cardStarted.ok) {
notify.warning(cardStarted.message || '无法监控卡位,请手动点击返回或重新提交')
}
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') {
@@ -253,9 +296,11 @@ async function enterWaitPhase(next: 'completed' | 'failed', errorText = ''): Pro
failureErrorText.value = errorText
}
await stopTaskPollsOnly()
appStore.setMode(isCollect.value ? 'usbCopying' : 'distributing')
const sessionMode = isCollect.value ? 'usbCopying' : 'distributing'
appStore.setMode(sessionMode)
lastCardPosition = -1
await startCardPositionWatch()
await startCardPositionWatch(sessionMode)
void refreshLiveStatus()
}
async function enterFailedPhase(errorText = ''): Promise<void> {
@@ -272,8 +317,8 @@ function canAutoResubmit(): boolean {
function applyCardPosition(p: CardPositionPollPayload): void {
if (!canAutoResubmit() || resubmitting) return
if (p.queryCode !== 0) return
const pos = p.position
const pos = resolveCardPosition(p.queryCode, p.position)
if (pos === null) return
const enteredPrepare = pos === POSITION_PREPARE && lastCardPosition !== POSITION_PREPARE
lastCardPosition = pos
if (!enteredPrepare) return
@@ -287,16 +332,8 @@ async function resubmitTask(): Promise<void> {
await pollCardPositionStop()
unsubCard?.()
unsubCard = null
phase.value = 'running'
setProgress(0)
workflowStep.value = 1
collectHint.value = ''
waitCard.value = false
queryFailStreak = 0
usbQueryFailStreak = 0
const backToWait = async (message?: string): Promise<void> => {
resubmitting = false
if (message) notify.error(message)
if (fromWaitPhase === 'failed') {
await enterFailedPhase(message || failureErrorText.value)
@@ -309,26 +346,43 @@ async function resubmitTask(): Promise<void> {
if (isCollect.value) {
const dest = collectStore.destPath.trim()
if (!dest) {
await backToWait('导入目录无效,无法继续')
await backToWait(t('notify.dirInvalid'))
return
}
phase.value = 'running'
setProgress(0)
workflowStep.value = 1
collectHint.value = ''
queryFailStreak = 0
usbQueryFailStreak = 0
clearWaiting()
const r = await dllUsbCopy(dest, collectStore.cardOutput, { resubmit: true })
if (!r.ok) {
await backToWait(r.message || '重新提交收集任务失败')
await backToWait(r.message || t('notify.resubmitCollectFailed'))
return
}
usbAwaitNewCycle = true
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
markWaiting()
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
void refreshLiveStatus()
return
}
if (!jobStore.lastJobJson) {
const err = await validateJobPreflight(formStore)
if (err) {
notify.warning(err)
await backToWait()
return
}
}
phase.value = 'running'
setProgress(0)
workflowStep.value = 1
waitCard.value = false
queryFailStreak = 0
clearWaiting()
const created = await createDistributeJob(formStore, { resubmit: true })
if (!created.ok) {
await backToWait(created.message)
@@ -337,10 +391,13 @@ async function resubmitTask(): Promise<void> {
jobStore.setActiveJob(created.jobId)
const started = await pollJobStart(created.jobId)
if (!started.ok) {
await backToWait(started.message || '启动任务轮询失败')
await backToWait(started.message || t('notify.pollStartFailed'))
return
}
unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
void refreshLiveStatus()
} catch (e) {
await backToWait(String(e))
} finally {
resubmitting = false
}
@@ -352,7 +409,7 @@ function applyJobProgress(p: JobPollPayload): void {
queryFailStreak += 1
if (queryFailStreak < 3) return
jobStore.failCount += 1
void enterFailedPhase(`查询任务失败: ${p.queryErrorCode}`)
void enterFailedPhase(t('notify.queryJobFailed', { code: p.queryErrorCode }))
return
}
queryFailStreak = 0
@@ -361,9 +418,12 @@ function applyJobProgress(p: JobPollPayload): void {
const ui = mapJobStateToUi(p.jobState)
workflowStep.value = ui.workflowStep
waitCard.value = ui.hint === 'waitCard'
if (ui.hint === 'waitCard') markWaiting()
else clearWaiting()
if (p.failed) {
jobStore.failCount += 1
void enterFailedPhase()
// 优先使用主进程在失败当刻取到的错误串,避免事后取到通用文案
void enterFailedPhase(p.errorMessage || '')
return
}
if (p.cancelled) {
@@ -383,7 +443,7 @@ function applyUsbProgress(p: UsbPollPayload): void {
usbQueryFailStreak += 1
if (usbQueryFailStreak < 3) return
collectStore.failCount += 1
void enterFailedPhase(`查询 USB 任务失败: ${p.queryCode}`)
void enterFailedPhase(t('notify.queryUsbFailed', { code: p.queryCode }))
return
}
usbQueryFailStreak = 0
@@ -401,7 +461,7 @@ function applyUsbProgress(p: UsbPollPayload): void {
collectStore.successCount += 1
workflowStep.value = 3
collectHint.value = usbTaskStatusHint(p.taskStatus)
notify.success('USB 收集完成')
notify.success(t('notify.usbCollectComplete'))
void enterCompletedPhase()
return
}
@@ -409,6 +469,9 @@ function applyUsbProgress(p: UsbPollPayload): void {
const copyProgress = clampUsbCopyProgress(p.progress)
workflowStep.value = p.taskStatus === USB_TASK_COPYING ? 2 : 1
collectHint.value = usbTaskStatusHint(p.taskStatus)
// PREPARING(等待插卡/读 U 盘)开始计时;进入 COPYING 等后续阶段后清除
if (p.taskStatus === USB_TASK_PREPARING) markWaiting()
else clearWaiting()
setProgress(copyProgress)
}
@@ -424,7 +487,7 @@ async function finishDistribute(
if (id && !skipCancel) {
const r = await dllJobCancel(id)
if (!r.ok) {
notify.warning(r.message || '取消任务时出现问题')
notify.warning(r.message || t('notify.cancelIssue'))
}
}
jobStore.clearActiveJob()
@@ -449,7 +512,10 @@ onMounted(async () => {
workflowStep.value = 1
setProgress(0)
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
startWaitWatchdog()
markWaiting()
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
void refreshLiveStatus()
return
}
@@ -459,16 +525,20 @@ onMounted(async () => {
}
appStore.setMode('distributing')
setProgress(0)
clearWaiting()
startWaitWatchdog()
const started = await pollJobStart(jobStore.jobId)
if (!started.ok) {
notify.error(started.message || '启动任务轮询失败')
notify.error(started.message || t('notify.pollStartFailed'))
await finishDistribute(false)
return
}
unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
void refreshLiveStatus()
})
onUnmounted(() => {
stopWaitWatchdog()
if (finishing || phase.value !== 'running') return
void releasePolls(true)
if (isCollect.value && appStore.mode === 'usbCopying') {
@@ -525,11 +595,12 @@ async function onStop(): Promise<void> {
<style scoped>
.m-error-detail {
margin-top: 6px;
font-size: 10px;
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: #dc3545;
font-weight: 600;
max-width: 320px;
max-width: 340px;
word-break: break-all;
}
+73 -25
View File
@@ -1,35 +1,61 @@
<template>
<AppShell>
<AppHeader mode="卡树数据卡打印机软件" />
<AppHeader :mode="t('header.modeHome')" />
<main class="app-shell__main l-dashboard">
<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">
<button type="button" class="m-tool-btn" @click="onReset">
<AppIcon name="redo" />
<span>重置打印机</span>
<span>{{ t('common.resetPrinter') }}</span>
</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" />
<span>废弃卡片</span>
<span>{{ t('common.discardCard') }}</span>
</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">
<AppIcon name="paint-brush" />
<span>模板设计</span>
<span>{{ t('common.templateDesign') }}</span>
</button>
</div>
</section>
<div class="m-divider" />
<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">
<button type="button" class="m-task-card" @click="goDistribute">
<div class="m-task-icon">
<AppIcon name="share" />
</div>
<div class="m-task-info">
<h4>数据分发</h4>
<p>分发数据到打印卡片</p>
<h4>{{ t('home.dataDistribute') }}</h4>
<p>{{ t('home.dataDistributeDesc') }}</p>
</div>
</button>
<button type="button" class="m-task-card" @click="goCollect">
@@ -37,8 +63,8 @@
<AppIcon name="download" />
</div>
<div class="m-task-info">
<h4>数据收集</h4>
<p>从卡片收集导入数据</p>
<h4>{{ t('home.dataCollect') }}</h4>
<p>{{ t('home.dataCollectDesc') }}</p>
</div>
</button>
</div>
@@ -50,17 +76,19 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { notify, notifyRequireInit } from '@/composables/useNotify'
import { refreshPrinterHeader } from '@/composables/usePrinterStatus'
import { refreshLiveStatus } from '@/composables/usePrinterStatus'
import AppShell from '@/layouts/AppShell.vue'
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
import AppIcon from '@/components/AppIcon.vue'
import { useAppStore } from '@/stores/app'
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 appStore = useAppStore()
const configStore = useConfigStore()
@@ -73,41 +101,61 @@ function guardInit(action?: string): boolean {
}
async function onReset(): Promise<void> {
if (!guardInit('重置打印机')) return
if (!guardInit(t('common.resetPrinter'))) return
const r = await dllPrinterReset()
if (r.ok) {
notify.success('已发送重置指令')
await refreshPrinterHeader(configStore)
} else notify.error(r.message || '重置失败')
notify.success(t('notify.resetSent'))
await refreshLiveStatus()
} else notify.error(r.message || t('notify.resetFailed'))
}
async function onReject(): Promise<void> {
if (!guardInit('废弃卡片')) return
if (!guardInit(t('common.discardCard'))) return
if (!configStore.rejectApiAvailable) {
notify.warning('当前环境不支持废卡接口')
notify.warning(t('notify.rejectUnavailable'))
return
}
const r = await dllPrinterReject()
if (r.ok) notify.success('已废弃卡片')
else notify.error(r.message || '操作失败')
if (r.ok) {
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> {
const r = await openDesignApp()
if (!r.ok) {
notify.error(r.message || '打开设计软件失败,请检查 cardsoon.config.json')
notify.warning(r.message || t('notify.designCancelled'))
return
}
notify.success('已启动设计软件')
notify.success(t('notify.designStarted'))
}
function guardBusy(): boolean {
if (appStore.mode === 'distributing') {
notify.warning('请先停止数据分发任务')
notify.warning(t('notify.stopDistribute'))
return false
}
if (appStore.mode === 'usbCopying') {
notify.warning('USB 收集进行中,请等待完成')
notify.warning(t('notify.usbCollecting'))
return false
}
return true
+32
View File
@@ -1 +1,33 @@
/** workDll 备卡位:检测到后可自动续做 */
export const POSITION_PREPARE = 13
/** 卡位编号合理上限(用于解析 DLL 返回值) */
export const POSITION_MAX = 32
/**
* 解析 SAPI_GetPrinterCardPosition。
* 真机常见:ret=13,out=0(返回值即卡位);或 ret=0,out=卡位。
*/
export function resolveCardPosition(queryCode: number, position: number): number | null {
if (queryCode === 0 && position >= 0 && position <= POSITION_MAX) {
return position
}
if (queryCode > 0 && queryCode <= POSITION_MAX) {
return queryCode
}
if (position > 0 && position <= POSITION_MAX) {
return position
}
return null
}
export function normalizeCardPositionQuery(
ret: number,
outPosition: number
): { queryCode: number; position: number } {
const pos = resolveCardPosition(ret, outPosition)
if (pos !== null) {
return { queryCode: 0, position: pos }
}
return { queryCode: ret, position: outPosition }
}
+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
}
+33 -25
View File
@@ -1,8 +1,21 @@
export interface PrinterStatusSnapshot {
ribbonType: string
ribbonAmount: string
statusText: 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> = {
@@ -30,6 +43,7 @@ function normalizeStatus(raw: unknown): string {
return PRINTER_STATUS_MAP[s] ?? s
}
/** SAPI_PrinterCheckstatus 返回值 → 展示文案 */
export function statusTextFromCheckstatus(code: number): {
ok: boolean
statusText: string
@@ -56,39 +70,33 @@ function snapshotFromRecord(row: Record<string, unknown>): PrinterStatusSnapshot
'SerialNo',
'serialNo',
'szPrinterSerial',
'PrinterSerial',
'PrinterName'
'PrinterSerial'
])
const statusRaw = pickFirst(row, [
'printer_status',
'PrinterStatus',
'PrinterType',
'Status'
const printerName = pickFirst(row, [
'PrinterName',
'printer_name',
'PrinterModel',
'model',
'szPrinterName',
'szPrinterModel'
])
const ribbon = pickFirst(row, ['ribbon_type', 'RibbonType', 'RibbonAmount'])
const printed = pickFirst(row, ['printed_count', 'PrintedCount', 'PrintCount', 'printedCount'])
let statusText = normalizeStatus(statusRaw)
if (statusText === '—') {
const remain = row.RibbonRemain ?? row.RemainCount
const capacity = row.RibbonCapacity ?? row.Capacity ?? row.MaxCount
if (remain != null && capacity != null) {
statusText = `${remain}/${capacity}`
}
}
const ribbonType = pickFirst(row, ['ribbon_type', 'RibbonType'])
const ribbonAmount = pickFirst(row, ['RibbonAmount', 'ribbon_amount'])
const nameStr = String(printerName ?? '—')
return {
ribbonType: String(ribbon ?? '—'),
statusText,
serialNo: String(serial ?? '—'),
printedCount: Number(printed ?? 0)
ribbonType: String(ribbonType ?? '—'),
ribbonAmount: String(ribbonAmount ?? '—'),
statusText: '—',
serialNo: String(serial ?? printerName ?? '—'),
printerName: nameStr,
isSingleSide: detectSingleSide(nameStr)
}
}
export function parsePrinterInfoFromDll(json: Record<string, unknown>): PrinterStatusSnapshot {
const flatSerial = pickFirst(json, ['serial_no', 'SerialNo', 'serialNo', 'szPrinterSerial'])
const flatStatus = pickFirst(json, ['printer_status', 'PrinterStatus'])
if (flatSerial != null || flatStatus != null) {
if (flatSerial != null) {
return snapshotFromRecord(json)
}
File diff suppressed because one or more lines are too long