Compare commits

..

19 Commits

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

- 添加 OpenCV Release 版 opencv_world490.dll

- 添加 simhei.ttf 字体资源

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

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

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

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 12:16:03 +08:00
24kycj ece0a06a80 调整分发配置:格式化类型与加密狗参数
- 格式化类型改为 Fat32 / exFat / NTFS
- 加密狗改为「不限次数」勾选,提交 dongle_install_count 为 255 或 0

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 11:59:44 +08:00
24kycj 574cb353e8 完善分发与收集任务全流程
- RestJobEx 对齐 task_id,path_file 提交目录路径
- 运行页进度仅跟接口轮询,失败态停留 page3 UI
- 完成态卡位续做,USB/任务轮询生命周期优化
- 拆分 DLL 加载、任务 staging 与提交前校验
- 移除 mock 与冗余样式,补充 native 依赖

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 09:43:24 +08:00
24kycj 505e72ce28 更新 2026-05-28 08:41:10 +08:00
91 changed files with 6329 additions and 2898 deletions
+1
View File
@@ -2,6 +2,7 @@ node_modules/
dist/ dist/
out/ out/
release/ release/
*.tsbuildinfo
*.log *.log
.env .env
.env.* .env.*
+16 -1
View File
@@ -1,6 +1,10 @@
import { resolve } from 'path' import { resolve } from 'path'
import { readFileSync } from 'fs'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
const pkg = JSON.parse(readFileSync(resolve('package.json'), 'utf-8')) as { version: string }
const sharedAlias = { '@shared': resolve('src/shared') } const sharedAlias = { '@shared': resolve('src/shared') }
@@ -19,6 +23,17 @@ export default defineConfig({
...sharedAlias ...sharedAlias
} }
}, },
plugins: [vue()] define: {
__APP_VERSION__: JSON.stringify(pkg.version)
},
plugins: [
vue(),
// 构建时预编译多语言消息 + 使用 vue-i18n runtime 构建,
// 避免运行时 new Function() 编译消息(会被 CSP script-src 'self' 拦截导致白屏)
VueI18nPlugin({
include: [resolve('src/renderer/src/i18n/locales/**')],
runtimeOnly: true
})
]
} }
}) })
+1032 -313
View File
File diff suppressed because it is too large Load Diff
+49 -10
View File
@@ -8,30 +8,46 @@
"node": "16.15.0" "node": "16.15.0"
}, },
"scripts": { "scripts": {
"dev": "electron-vite dev -- --skip-dll-init", "dev": "electron-vite dev",
"build": "electron-vite build", "build": "electron-vite build",
"preview": "electron-vite preview", "preview": "electron-vite preview",
"typecheck": "vue-tsc --noEmit -p tsconfig.web.json", "typecheck": "vue-tsc --noEmit -p tsconfig.web.json",
"predist": "node scripts/check-native-dlls.js", "predist": "node scripts/check-native-dlls.js && node scripts/clean-release-artifacts.js",
"predist:dir": "node scripts/check-native-dlls.js", "predist:dir": "node scripts/check-native-dlls.js",
"predist:zip": "node scripts/check-native-dlls.js", "predist:zip": "node scripts/check-native-dlls.js && node scripts/clean-release-artifacts.js",
"dist": "electron-vite build && electron-builder", "dist": "electron-vite build && electron-builder",
"dist:dir": "electron-vite build && electron-builder --dir", "dist:dir": "electron-vite build && electron-builder --dir",
"dist:zip": "electron-vite build && electron-builder --win zip" "dist:zip": "electron-vite build && electron-builder --win zip",
"prerelease": "node scripts/check-native-dlls.js && node scripts/clean-release-artifacts.js",
"release": "electron-vite build && electron-builder --win nsis"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.4.0",
"electron-log": "^5.1.2", "electron-log": "^5.1.2",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
"element-plus": "^2.4.4",
"koffi": "^2.9.0", "koffi": "^2.9.0",
"pinia": "^2.1.7", "vue-i18n": "^9.14.4"
"vue": "^3.4.21",
"vue-router": "^4.3.0"
}, },
"build": { "build": {
"appId": "com.cardsoon.machine", "appId": "com.cardsoon.machine",
"productName": "卡树数据卡打印系统", "productName": "卡树数据卡打印系统",
"asar": true,
"asarUnpack": [
"node_modules/koffi/build/koffi/win32_x64/**"
],
"files": [
"out/**/*",
"!src/**",
"!scripts/**",
"!resources/**",
"!release/**",
"!release-pack/**",
"!electron.vite.config.ts",
"!tsconfig*.json",
"!tsconfig.web.tsbuildinfo",
"!.nvmrc"
],
"afterPack": "scripts/prune-pack.js",
"npmRebuild": false,
"directories": { "directories": {
"output": "release" "output": "release"
}, },
@@ -39,6 +55,10 @@
{ {
"from": "resources/native", "from": "resources/native",
"to": "native" "to": "native"
},
{
"from": "resources/cardsoon.config.json",
"to": "cardsoon.config.json"
} }
], ],
"extraFiles": [ "extraFiles": [
@@ -49,19 +69,38 @@
], ],
"win": { "win": {
"target": [ "target": [
"nsis" "zip"
], ],
"signAndEditExecutable": false "signAndEditExecutable": false
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"perMachine": false
},
"mac": {
"target": [
"dmg"
]
},
"linux": {
"target": [
"AppImage"
]
} }
}, },
"devDependencies": { "devDependencies": {
"@intlify/unplugin-vue-i18n": "^1.6.0",
"@vitejs/plugin-vue": "^4.6.2", "@vitejs/plugin-vue": "^4.6.2",
"electron": "20.3.12", "electron": "20.3.12",
"electron-builder": "^24.13.3", "electron-builder": "^24.13.3",
"electron-vite": "^1.0.29", "electron-vite": "^1.0.29",
"pinia": "^2.1.7",
"sass": "^1.69.5", "sass": "^1.69.5",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^4.5.2", "vite": "^4.5.2",
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"vue-tsc": "^1.8.27" "vue-tsc": "^1.8.27"
} }
} }
+6
View File
@@ -0,0 +1,6 @@
{
"designAppPath": "D:\\SoonProject\\SoonDesign\\build\\win-unpacked\\SoonDesign.exe",
"sharedDir": "C:\\PrintTasks",
"logLevel": 3,
"cleanTaskFile": 0
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14 -3
View File
@@ -9,13 +9,24 @@ const required = [
'dcrf32.dll', 'dcrf32.dll',
'Entry.dll', 'Entry.dll',
'libpng16.dll', 'libpng16.dll',
'zint.dll' 'zint.dll',
'freetype.dll',
'opencv_world490d.dll'
] ]
const opencvRelease = path.join(nativeDir, 'opencv_world490.dll')
const opencvDebug = path.join(nativeDir, 'opencv_world490d.dll')
if (fs.existsSync(opencvRelease)) {
console.warn('提示: 检测到 opencv_world490.dll,生产环境建议用 Release 版替换 Debug 版 opencv_world490d.dll 以减小体积')
} else if (fs.existsSync(opencvDebug)) {
const mb = Math.round(fs.statSync(opencvDebug).size / 1024 / 1024)
console.warn(`提示: 当前使用 Debug 版 OpenCV (${mb} MB),可从 docs/API/lib 换 Release 版 opencv_world490.dll`)
}
const missing = required.filter((name) => !fs.existsSync(path.join(nativeDir, name))) const missing = required.filter((name) => !fs.existsSync(path.join(nativeDir, name)))
if (missing.length) { if (missing.length) {
console.error(`resources/native 缺少: ${missing.join(', ')}`) console.error(`resources/native 缺少: ${missing.join(', ')}`)
console.error('请从 docs/API/lib 复制 7 个 dll(不含 .lib') console.error('请从 docs/API/lib 复制完整 native 依赖(不含 .lib')
process.exit(1) process.exit(1)
} }
console.log('resources/native: 7 dll 齐全') console.log(`resources/native: ${required.length} dll 齐全`)
+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 })
}
}
}
+76 -28
View File
@@ -1,15 +1,34 @@
import { app, BrowserWindow, dialog, globalShortcut, screen } from 'electron' import { app, BrowserWindow, dialog, globalShortcut } from 'electron'
import { join } from 'path' import { join } from 'path'
app.commandLine.appendSwitch('disable-gpu-shader-disk-cache') app.commandLine.appendSwitch('disable-gpu-shader-disk-cache')
import log from 'electron-log' import log from 'electron-log'
import { suppressKnownDllStderr } from './utils/suppress-dll-stderr'
import { setupNativeWorkingDir } from './services/native-path'
import { configStore } from './services/config-store'
if (app.isPackaged) {
app.disableHardwareAcceleration()
}
const gotSingleInstanceLock = app.requestSingleInstanceLock()
if (!gotSingleInstanceLock) {
app.quit()
}
import { ensureConsoleUtf8 } from './utils/ensure-console-utf8'
import { suppressKnownDllStderr } from './utils/suppress-dll-stderr'
import { loadAppFileConfig, applyFileConfigToStore } from './services/app-config'
import { migrateTraceConfig, setTraceWebContents } from './utils/trace-bridge'
ensureConsoleUtf8()
suppressKnownDllStderr() suppressKnownDllStderr()
process.on('uncaughtException', (err) => {
log.error('uncaughtException', err)
dialog.showErrorBox('程序异常', err instanceof Error ? err.message : String(err))
})
import { registerIpcHandlers, handleBeforeQuit } from './ipc/register-handlers' import { registerIpcHandlers, handleBeforeQuit } from './ipc/register-handlers'
import { ensureDllInitialized } from './services/dll-bootstrap'
import { setPollMainWindow } from './services/poll-manager' import { setPollMainWindow } from './services/poll-manager'
import { DESIGN_WIDTH, DESIGN_HEIGHT, contentHeightForWidth } from '@shared/viewport' import { DESIGN_WIDTH, DESIGN_HEIGHT, contentHeightForWidth } from '@shared/viewport'
@@ -17,25 +36,19 @@ let mainWindow: BrowserWindow | null = null
const MIN_CONTENT_WIDTH = 960 const MIN_CONTENT_WIDTH = 960
/** 默认内容区:约 85% 工作区宽,高 720:360 */ function focusMainWindow(): void {
function getDefaultWindowSize(): { width: number; height: number } { if (!mainWindow) return
const { width: sw, height: sh } = screen.getPrimaryDisplay().workAreaSize if (mainWindow.isMinimized()) mainWindow.restore()
let w = Math.max(1280, Math.min(Math.floor(sw * 0.85), 1600)) mainWindow.show()
let h = contentHeightForWidth(w) mainWindow.focus()
const maxH = Math.floor(sh * 0.85)
if (h > maxH) {
h = Math.max(contentHeightForWidth(MIN_CONTENT_WIDTH), maxH)
w = Math.round((h * DESIGN_WIDTH) / DESIGN_HEIGHT)
}
return { width: w, height: h }
} }
function createWindow(): void { function createWindow(): void {
const { width, height } = getDefaultWindowSize() // 目标设备屏幕为 720x360,窗口默认 1:1 显示设计稿大小
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
useContentSize: true, useContentSize: true,
width, width: DESIGN_WIDTH,
height, height: DESIGN_HEIGHT,
minWidth: MIN_CONTENT_WIDTH, minWidth: MIN_CONTENT_WIDTH,
minHeight: contentHeightForWidth(MIN_CONTENT_WIDTH), minHeight: contentHeightForWidth(MIN_CONTENT_WIDTH),
show: false, show: false,
@@ -49,6 +62,7 @@ function createWindow(): void {
}) })
setPollMainWindow(mainWindow) setPollMainWindow(mainWindow)
setTraceWebContents(mainWindow.webContents)
mainWindow.on('ready-to-show', () => { mainWindow.on('ready-to-show', () => {
if (mainWindow) { if (mainWindow) {
@@ -68,9 +82,10 @@ function createWindow(): void {
} }
}) })
// 内容区 720:360,与 useScale 一致
mainWindow.on('resize', () => { mainWindow.on('resize', () => {
if (!mainWindow) return if (!mainWindow) return
// 最大化时让窗口填满屏幕,不强制 2:1 内容比例
if (mainWindow.isMaximized()) return
const [cw, ch] = mainWindow.getContentSize() const [cw, ch] = mainWindow.getContentSize()
const wantH = contentHeightForWidth(cw) const wantH = contentHeightForWidth(cw)
if (Math.abs(ch - wantH) > 2) { if (Math.abs(ch - wantH) > 2) {
@@ -79,9 +94,32 @@ function createWindow(): void {
}) })
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
setTraceWebContents(null)
mainWindow = null mainWindow = null
}) })
mainWindow.webContents.on('render-process-gone', (_event, details) => {
log.error('render-process-gone', details)
dialog.showErrorBox(
'界面进程异常退出',
`reason=${details.reason} exitCode=${details.exitCode}\n请查看 %APPDATA%\\cardsoon-machine\\logs\\main.log`
)
})
// 页面加载失败(dev server 未就绪 / 生产文件缺失)时记录并提示,避免静默白屏
mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
log.error('renderer did-fail-load', { errorCode, errorDescription, validatedURL })
if (errorCode !== -3) {
mainWindow?.webContents.executeJavaScript(
`document.getElementById('app').innerHTML = '<pre style="margin:16px;padding:16px;background:#fff5f5;border:1px solid #feb2b2;border-radius:8px;color:#c53030;font-size:13px;white-space:pre-wrap;">[main:did-fail-load] ${errorCode} ${errorDescription}\\n${validatedURL}</pre>'`
).catch(() => undefined)
}
})
mainWindow.webContents.on('preload-error', (_event, preloadPath, error) => {
log.error('preload-error', { preloadPath, error: String(error) })
})
if (process.env.ELECTRON_RENDERER_URL) { if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else { } else {
@@ -89,17 +127,27 @@ function createWindow(): void {
} }
} }
app.whenReady().then(() => { if (gotSingleInstanceLock) {
try { app.on('second-instance', () => {
if (app.isPackaged) { focusMainWindow()
configStore.set('skipDllInit', false) })
} else if (process.argv.includes('--skip-dll-init')) { }
configStore.set('skipDllInit', true)
log.warn('skipDllInit enabled by argv: --skip-dll-init (dev only)')
}
setupNativeWorkingDir() app.whenReady().then(async () => {
if (!gotSingleInstanceLock) return
try {
migrateTraceConfig()
loadAppFileConfig()
applyFileConfigToStore()
log.info('app startup', { packaged: app.isPackaged, execPath: process.execPath })
registerIpcHandlers() registerIpcHandlers()
try {
const r = await ensureDllInitialized()
if (r.warning) log.warn(r.warning)
} catch (e) {
log.error('startup DLL init failed', e)
}
createWindow() createWindow()
if (!app.isPackaged) { if (!app.isPackaged) {
globalShortcut.register('CommandOrControl+Shift+I', () => { globalShortcut.register('CommandOrControl+Shift+I', () => {
+330 -116
View File
@@ -1,33 +1,22 @@
import { app, dialog, ipcMain, shell } from 'electron' import { dialog, shell } from 'electron'
import fs from 'fs' import fs from 'fs'
import path from 'path'
import log from 'electron-log' import log from 'electron-log'
import { CS_FAIL, CS_OK } from '../constants' import { CS_FAIL, CS_OK } from '../constants'
import { assertNotBusy, assertReady, mainAppState } from '../services/app-state' import { assertNotBusy, assertReady, mainAppState } from '../services/app-state'
import { configStore } from '../services/config-store' import { configStore } from '../services/config-store'
import { import { startUsbPoll, startJobPoll, stopAllPolls, stopJobPoll, stopUsbPoll, stopCardPositionPoll, startCardPositionPoll, getPollMainWindow, isJobPollActive } from '../services/poll-manager'
getActiveJobId, import { parsePrinterInfoFromDll, statusTextFromCheckstatus, type PrinterStatusSnapshot } from '@shared/printer-info'
startJobPoll,
startUsbPoll,
stopAllPolls,
stopJobPoll,
stopUsbPoll,
getPollMainWindow
} from '../services/poll-manager'
import { cleanPathPattern, getDirectorySizeBytes } from '../utils/dir-size' import { cleanPathPattern, getDirectorySizeBytes } from '../utils/dir-size'
import { getDesignAppPath } from '../services/app-config'
import { openDesignApp } from '../services/open-design-app'
import { writeJobCsv, type JobCsvRow } from '../utils/job-csv'
import { parseSoonTemplate } from '../utils/parse-soon' import { parseSoonTemplate } from '../utils/parse-soon'
import { import { stageJobPayloadJson } from '../utils/stage-job-payload'
dllAdminJobCancel, import { resolveDriveHostMap, resolveHostsFromPaths } from '../utils/network-drive'
dllCopyFromUsb, import { getSecrets, setSecrets, type SecretsPayload } from '../services/secrets-store'
dllGetPrinterErrorStr, import { ensureDllInitialized, isDllInitAttempted } from '../services/dll-bootstrap'
dllGetPrinterInfo, import { loadDllModule } from '../services/dll-loader'
dllInit, import { tracedHandle } from './traced-handler'
dllPrinterReject,
dllPrinterReset,
dllRestJobEx,
isCancelApiAvailable,
isRejectApiAvailable
} from '../services/work-dll.service'
function ok<T>(data?: T) { function ok<T>(data?: T) {
return { ok: true as const, code: CS_OK, data } return { ok: true as const, code: CS_OK, data }
@@ -37,130 +26,288 @@ function fail(code: number, message: string) {
return { ok: false as const, code, message } return { ok: false as const, code, message }
} }
let dllInitAttempted = false function summarizeStagedPayload(json: string): Record<string, unknown> {
try {
const p = JSON.parse(json) as Record<string, unknown>
return {
task_id: p.task_id,
has_copy_task: p.has_copy_task,
has_print_task: p.has_print_task,
path_file_count: Array.isArray(p.path_file) ? p.path_file.length : 0,
json_file: p.json_file,
udf_file: p.udf_file
}
} catch {
return { parseError: true }
}
}
function parseBool(v: unknown): boolean {
return v === true || v === 'true' || v === 1 || v === '1' || String(v).toLowerCase() === 'true'
}
export function registerIpcHandlers(): void { export function registerIpcHandlers(): void {
ipcMain.handle('dll:init', (_e, params) => { tracedHandle('dll:init', async (_e, params) => {
if (dllInitAttempted) {
return fail(CS_FAIL, '请勿重复初始化,请完全退出应用后重新启动再试')
}
stopAllPolls() stopAllPolls()
try { try {
const sharedDir = params?.sharedDir || (configStore.get('sharedDir') as string) const r = await ensureDllInitialized(params)
fs.mkdirSync(sharedDir, { recursive: true }) return ok({ code: r.code, warning: r.warning })
log.info(`SAPI_Init starting, sharedDir=${sharedDir}`) } catch (err) {
const code = dllInit({ return fail(CS_FAIL, String(err))
sharedDir, }
keepCombinedImage: params?.keepCombinedImage, })
stopOnFailure: params?.stopOnFailure,
cleanTaskFile: params?.cleanTaskFile, tracedHandle('dll:printer-info', async () => {
autoRetryTimes: params?.autoRetryTimes, try {
rejectConfig: params?.rejectConfig, assertReady()
logLevel: params?.logLevel, const dll = await loadDllModule()
outBack: params?.outBack const r = dll.dllGetPrinterInfo()
}) if (!r.json) {
log.info(`SAPI_Init finished, code=${code}`) const cached = configStore.get('lastPrinterStatus')
dllInitAttempted = true if (cached) {
mainAppState.initialized = true return ok({
configStore.set('sharedDir', sharedDir) ...cached,
if (code === CS_OK) { fromCache: true,
return ok() liveError: r.code <= 0 ? '未连接打印机' : `GetPrinterInfo code=${r.code}`
})
}
return fail(0, '未连接打印机')
} }
log.warn(`SAPI_Init returned ${code}; UI ready, printer ops may fail until device connected`) const snapshot = parsePrinterInfoFromDll(r.json)
configStore.set('lastPrinterStatus', snapshot)
return ok({ ...r.json, snapshot })
} catch (err) {
const cached = configStore.get('lastPrinterStatus')
if (cached) {
return ok({ ...cached, fromCache: true, liveError: String(err) })
}
return fail(CS_FAIL, String(err))
}
})
tracedHandle('dll:printer-status', async () => {
if (!mainAppState.initialized) {
const cached = configStore.get('lastPrinterStatus')
return ok({ return ok({
warning: '打印机未连接或驱动未就绪,界面可浏览,接好设备后可在设置中重试 Init' statusText: cached?.statusText ?? '未初始化',
fromCache: true
}) })
}
try {
assertReady()
const dll = await loadDllModule()
if (!dll.isPrinterCheckstatusApiAvailable()) {
return fail(CS_FAIL, '当前 DLL 不支持打印机状态查询')
}
const code = dll.dllPrinterCheckstatus()
const parsed = statusTextFromCheckstatus(code)
const cached = configStore.get('lastPrinterStatus')
if (parsed.ok) {
const snapshot: PrinterStatusSnapshot = {
ribbonType: cached?.ribbonType ?? '—',
ribbonAmount: cached?.ribbonAmount ?? '—',
statusText: parsed.statusText,
serialNo: cached?.serialNo ?? '—',
printerName: cached?.printerName ?? '—',
isSingleSide: cached?.isSingleSide ?? false
}
configStore.set('lastPrinterStatus', snapshot)
return ok({ statusText: parsed.statusText, statusCode: code })
}
if (cached) {
return ok({
statusText: parsed.statusText,
fromCache: true,
liveError: parsed.liveError
})
}
return fail(code, parsed.liveError || parsed.statusText)
} catch (err) { } catch (err) {
mainAppState.initialized = false const cached = configStore.get('lastPrinterStatus')
log.error('dll:init', err) if (cached) {
return ok({ statusText: cached.statusText, fromCache: true, liveError: String(err) })
}
return fail(CS_FAIL, String(err)) return fail(CS_FAIL, String(err))
} }
}) })
ipcMain.handle('dll:printer-info', () => { tracedHandle('dll:printer-reset', async () => {
try { try {
assertReady() assertReady()
const r = dllGetPrinterInfo() const dll = await loadDllModule()
if (!r.json) return fail(0, '未连接打印机') const code = dll.dllPrinterReset()
return ok(r.json) // 物理动作指令:负数为错误,0 或正数(状态/警告码)均视为指令已被打印机接受
if (code < 0) {
log.warn('SAPI_PrinterResetprinter returned non-OK code', code)
return fail(code, '重置失败')
}
return ok({ code })
} catch (err) { } catch (err) {
return fail(CS_FAIL, String(err)) return fail(CS_FAIL, String(err))
} }
}) })
ipcMain.handle('dll:printer-reset', () => { tracedHandle('dll:printer-reject', async () => {
try { try {
assertReady() assertReady()
const code = dllPrinterReset() const dll = await loadDllModule()
return code === CS_OK ? ok() : fail(code, '重置失败') if (!dll.isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE')
const code = dll.dllPrinterReject()
if (code < 0) {
log.warn('SAPI_PrinterMovetoreject returned non-OK code', code)
return fail(code, '废卡失败')
}
return ok({ code })
} catch (err) { } catch (err) {
return fail(CS_FAIL, String(err)) return fail(CS_FAIL, String(err))
} }
}) })
ipcMain.handle('dll:printer-reject', () => { // 读卡:移动卡片到读取区(SAPI_PrinterMovetousbreader
tracedHandle('dll:printer-read-card', async () => {
try { try {
assertReady() assertReady()
if (!isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE') const dll = await loadDllModule()
const code = dllPrinterReject() if (!dll.isUsbReaderApiAvailable()) return fail(CS_FAIL, 'USB_READER_API_UNAVAILABLE')
return code === CS_OK ? ok() : fail(code, '废卡失败') const code = dll.dllPrinterMoveToUsbReader()
if (code < 0) {
log.warn('SAPI_PrinterMovetousbreader returned non-OK code', code)
return fail(code, '读卡失败')
}
return ok({ code })
} catch (err) { } catch (err) {
return fail(CS_FAIL, String(err)) return fail(CS_FAIL, String(err))
} }
}) })
ipcMain.handle('dll:printer-error-str', (_e, errorNo?: number) => { // 退卡:移动卡片到出卡区(SAPI_PrinterMovetohopper
tracedHandle('dll:printer-eject-card', async () => {
try { try {
assertReady() assertReady()
return ok({ text: dllGetPrinterErrorStr(errorNo ?? -1) }) const dll = await loadDllModule()
if (!dll.isHopperApiAvailable()) return fail(CS_FAIL, 'HOPPER_API_UNAVAILABLE')
const code = dll.dllPrinterMoveToHopper()
if (code < 0) {
log.warn('SAPI_PrinterMovetohopper returned non-OK code', code)
return fail(code, '退卡失败')
}
// 卡已物理退出但返回正数状态码时,不再误报失败
return ok({ code })
} catch (err) { } catch (err) {
return fail(CS_FAIL, String(err)) return fail(CS_FAIL, String(err))
} }
}) })
ipcMain.handle('dll:job-create', (_e, json: string) => { tracedHandle('dll:printer-error-str', async (_e, errorNo?: number) => {
if (!mainAppState.initialized) return ok({ text: '' })
try {
const dll = await loadDllModule()
return ok({ text: dll.dllGetPrinterErrorStr(errorNo ?? -1) })
} catch (err) {
log.warn('dll:printer-error-str', err)
return ok({ text: '' })
}
})
tracedHandle('dll:job-create', async (_e, json: string, opts?: { resubmit?: boolean }) => {
try { try {
assertReady() assertReady()
assertNotBusy() if (opts?.resubmit) {
const r = dllRestJobEx(json) if (mainAppState.mode !== 'distributing') {
if (r.code !== CS_OK) return fail(r.code, 'RestJobEx 失败') return fail(CS_FAIL, '当前不在分发任务会话中')
}
stopCardPositionPoll()
} else {
assertNotBusy()
}
const dll = await loadDllModule()
const sharedDir = configStore.get('sharedDir') as string
let staged: { json: string; taskDir: string }
try {
staged = stageJobPayloadJson(json, sharedDir, dll)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return fail(CS_FAIL, msg)
}
log.info('RestJobEx staging', {
taskDir: staged.taskDir,
summary: summarizeStagedPayload(staged.json)
})
const r = dll.dllRestJobEx(staged.json)
if (r.code !== CS_OK) {
log.warn('RestJobEx rejected', { code: r.code, json: staged.json.slice(0, 800) })
const errText = dll.dllGetPrinterErrorStr(r.code)
let detail = errText ? `${errText} (code=${r.code})` : `RestJobEx 失败 (code=${r.code})`
if (r.code === -1 && !errText) {
detail +=
':请确认模板路径、变量 CSV(udf_file)及拷贝路径有效,且打印机/任务目录已就绪'
}
return fail(r.code, detail)
}
if (!r.jobId?.trim()) {
return fail(CS_FAIL, 'RestJobEx 未返回 jobId')
}
mainAppState.mode = 'distributing' mainAppState.mode = 'distributing'
mainAppState.activeJobId = r.jobId mainAppState.activeJobId = r.jobId
return ok({ jobId: r.jobId }) return ok({ jobId: r.jobId })
} catch (err) { } catch (err) {
if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行') const msg = String(err)
return fail(CS_FAIL, String(err)) if (msg.includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
if (msg.includes('NOT_INITIALIZED')) {
return fail(CS_FAIL, '系统未初始化,请重启应用')
}
return fail(CS_FAIL, msg)
} }
}) })
ipcMain.handle('dll:job-cancel', (_e, jobId: string) => { tracedHandle('dll:job-cancel', async (_e, jobId: string) => {
const id = String(jobId || mainAppState.activeJobId || '').trim()
stopJobPoll(true)
mainAppState.mode = 'ready'
mainAppState.activeJobId = ''
if (!id) return ok()
if (!mainAppState.initialized) return ok()
try { try {
assertReady() const dll = await loadDllModule()
const id = jobId || mainAppState.activeJobId if (!dll.isCancelApiAvailable()) return ok()
stopJobPoll(true) const code = dll.dllAdminJobCancel(id)
let code = CS_OK
if (isCancelApiAvailable()) {
code = dllAdminJobCancel(id)
} else {
log.info('dll:job-cancel: SAPI_AdminJobCancel not in DLL, poll stopped only')
}
mainAppState.mode = 'ready'
mainAppState.activeJobId = ''
return code === CS_OK ? ok() : fail(code, '取消失败') return code === CS_OK ? ok() : fail(code, '取消失败')
} catch (err) { } catch (err) {
return fail(CS_FAIL, String(err)) log.warn('dll:job-cancel', err)
return ok()
} }
}) })
ipcMain.handle('dll:usb-copy', (_e, req: { destFolder: string; cardOutput: number }) => { tracedHandle('dll:usb-copy', async (_e, req: { destFolder: string; cardOutput: number; resubmit?: boolean }) => {
try { try {
assertReady() assertReady()
assertNotBusy() if (req.resubmit) {
const code = dllCopyFromUsb(req.destFolder, req.cardOutput) if (mainAppState.mode !== 'usbCopying') {
return fail(CS_FAIL, '当前不在数据收集会话中')
}
stopCardPositionPoll()
} else {
assertNotBusy()
}
const destFolder = String(req.destFolder || '').trim()
if (!destFolder) return fail(CS_FAIL, '请先选择数据导入目录')
fs.mkdirSync(destFolder, { recursive: true })
const dll = await loadDllModule()
if (dll.isUsbReaderApiAvailable()) {
const moveCode = dll.dllPrinterMoveToUsbReader()
if (moveCode !== CS_OK) {
log.warn('MoveToUsbReader before copy', { moveCode })
}
}
const code = dll.dllCopyFromUsb(destFolder, req.cardOutput)
if (code !== CS_OK) { if (code !== CS_OK) {
return fail(code, '可能已有任务在执行') const errText = dll.dllGetPrinterErrorStr(code)
return fail(code, errText || '启动 USB 收集失败')
} }
mainAppState.mode = 'usbCopying' mainAppState.mode = 'usbCopying'
startUsbPoll()
return ok() return ok()
} catch (err) { } catch (err) {
if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行') if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
@@ -168,28 +315,49 @@ export function registerIpcHandlers(): void {
} }
}) })
ipcMain.handle('poll:job-start', (_e, jobId: string) => { tracedHandle('poll:job-start', (_e, jobId: string) => {
startJobPoll(jobId) startJobPoll(jobId)
return ok() return ok()
}) })
ipcMain.handle('poll:job-stop', () => { tracedHandle('poll:job-stop', (_e, opts?: { resetMode?: boolean }) => {
stopJobPoll(true) stopJobPoll(opts?.resetMode !== false)
return ok() return ok()
}) })
ipcMain.handle('poll:usb-start', () => { tracedHandle('poll:usb-start', () => {
startUsbPoll() startUsbPoll()
return ok() return ok()
}) })
ipcMain.handle('poll:usb-stop', () => { tracedHandle('poll:usb-stop', (_e, opts?: { resetMode?: boolean }) => {
stopUsbPoll() stopUsbPoll(opts?.resetMode !== false)
mainAppState.mode = 'ready'
return ok() return ok()
}) })
ipcMain.handle('dialog:open-directory', async () => { tracedHandle('poll:card-position-start', async (_e, sessionMode?: 'distributing' | 'usbCopying') => {
try {
assertReady()
if (sessionMode === 'distributing' || sessionMode === 'usbCopying') {
mainAppState.mode = sessionMode
}
const dll = await loadDllModule()
if (!dll.isCardPositionApiAvailable()) {
return fail(CS_FAIL, '当前 DLL 不支持卡位查询,无法自动续做')
}
startCardPositionPoll()
return ok()
} catch (err) {
return fail(CS_FAIL, String(err))
}
})
tracedHandle('poll:card-position-stop', () => {
stopCardPositionPoll()
return ok()
})
tracedHandle('dialog:open-directory', async () => {
const win = getPollMainWindow() const win = getPollMainWindow()
const r = await dialog.showOpenDialog(win ?? undefined, { const r = await dialog.showOpenDialog(win ?? undefined, {
properties: ['openDirectory', 'multiSelections'] properties: ['openDirectory', 'multiSelections']
@@ -198,7 +366,7 @@ export function registerIpcHandlers(): void {
return ok({ paths: r.filePaths }) return ok({ paths: r.filePaths })
}) })
ipcMain.handle('dialog:open-file', async (_e, filters?: { name: string; extensions: string[] }[]) => { tracedHandle('dialog:open-file', async (_e, filters?: { name: string; extensions: string[] }[]) => {
const win = getPollMainWindow() const win = getPollMainWindow()
const r = await dialog.showOpenDialog(win ?? undefined, { const r = await dialog.showOpenDialog(win ?? undefined, {
properties: ['openFile'], properties: ['openFile'],
@@ -208,7 +376,7 @@ export function registerIpcHandlers(): void {
return ok({ path: r.filePaths[0] }) return ok({ path: r.filePaths[0] })
}) })
ipcMain.handle('fs:path-exists', (_e, paths: string[]) => { tracedHandle('fs:path-exists', (_e, paths: string[]) => {
const missing = paths const missing = paths
.map((raw) => ({ raw, dir: cleanPathPattern(raw) })) .map((raw) => ({ raw, dir: cleanPathPattern(raw) }))
.filter(({ dir }) => !dir || !fs.existsSync(dir)) .filter(({ dir }) => !dir || !fs.existsSync(dir))
@@ -216,7 +384,7 @@ export function registerIpcHandlers(): void {
return ok({ missing }) return ok({ missing })
}) })
ipcMain.handle('fs:dir-size', (_e, paths: string[]) => { tracedHandle('fs:dir-size', (_e, paths: string[]) => {
const items = paths.map((raw) => { const items = paths.map((raw) => {
const dir = cleanPathPattern(raw) const dir = cleanPathPattern(raw)
if (!fs.existsSync(dir)) return { path: raw, bytes: 0, missing: true as const } if (!fs.existsSync(dir)) return { path: raw, bytes: 0, missing: true as const }
@@ -231,7 +399,43 @@ export function registerIpcHandlers(): void {
return ok({ items }) return ok({ items })
}) })
ipcMain.handle('fs:parse-soon', (_e, filePath: string) => { tracedHandle('fs:resolve-network-hosts', (_e, paths: string[]) => {
const list = Array.isArray(paths) ? paths.map((p) => String(p || '')) : []
const driveHostMap = resolveDriveHostMap(list)
const hosts = resolveHostsFromPaths(list)
return ok({ hosts, driveHostMap })
})
tracedHandle('secrets:get', () => {
return ok(getSecrets())
})
tracedHandle('secrets:set', (_e, patch: Partial<SecretsPayload>) => {
if (!patch || typeof patch !== 'object') return ok(getSecrets())
const toMerge: Partial<SecretsPayload> = {}
if (patch.networkCredentials !== undefined) {
toMerge.networkCredentials = patch.networkCredentials
}
if (patch.dongleAuthCode !== undefined) {
toMerge.dongleAuthCode = patch.dongleAuthCode
}
return ok(setSecrets(toMerge))
})
tracedHandle(
'fs:write-job-csv',
(_e, payload: { taskId: string; rows: JobCsvRow[] }) => {
try {
const sharedDir = configStore.get('sharedDir') as string
const csvPath = writeJobCsv(sharedDir, payload.taskId, payload.rows || [])
return ok({ path: csvPath })
} catch (err) {
return fail(CS_FAIL, err instanceof Error ? err.message : String(err))
}
}
)
tracedHandle('fs:parse-soon', (_e, filePath: string) => {
try { try {
const soonPath = String(filePath || '').trim() const soonPath = String(filePath || '').trim()
if (!soonPath) return fail(CS_FAIL, '模板路径为空') if (!soonPath) return fail(CS_FAIL, '模板路径为空')
@@ -244,28 +448,27 @@ export function registerIpcHandlers(): void {
} }
}) })
ipcMain.handle('config:get', () => { tracedHandle('config:get', () => {
const payload: { const payload: {
sharedDir: string sharedDir: string
templateDir: string templateDir: string
skipDllInit?: boolean traceEnabled: boolean
lastPrinterStatus?: PrinterStatusSnapshot
dllInitialized: boolean
} = { } = {
sharedDir: configStore.get('sharedDir'), sharedDir: configStore.get('sharedDir'),
templateDir: configStore.get('templateDir') templateDir: configStore.get('templateDir'),
} traceEnabled: configStore.get('traceEnabled', true),
if (!app.isPackaged) { lastPrinterStatus: configStore.get('lastPrinterStatus'),
payload.skipDllInit = configStore.get('skipDllInit', false) dllInitialized: mainAppState.initialized
} }
return ok(payload) return ok(payload)
}) })
ipcMain.handle('config:set', (_e, patch: Record<string, unknown>) => { tracedHandle('config:set', (_e, patch: Record<string, unknown>) => {
Object.entries(patch).forEach(([k, v]) => { Object.entries(patch).forEach(([k, v]) => {
if (k === 'skipDllInit') { if (k === 'traceEnabled' || k === 'dllTraceEnabled') {
if (app.isPackaged) return configStore.set('traceEnabled', parseBool(v))
const b =
v === true || v === 'true' || v === 1 || v === '1' || String(v).toLowerCase() === 'true'
configStore.set(k, b)
return return
} }
configStore.set(k, v as string) configStore.set(k, v as string)
@@ -273,26 +476,37 @@ export function registerIpcHandlers(): void {
return ok(configStore.store) return ok(configStore.store)
}) })
ipcMain.handle('shell:open-path', (_e, target: string) => { tracedHandle('shell:open-path', (_e, target: string) => {
const dir = target || (configStore.get('templateDir') as string) const dir = target || (configStore.get('templateDir') as string)
fs.mkdirSync(dir, { recursive: true }) fs.mkdirSync(dir, { recursive: true })
shell.openPath(dir) shell.openPath(dir)
return ok() return ok()
}) })
ipcMain.handle('dll:reject-available', () => ok({ available: isRejectApiAvailable() })) tracedHandle('design:open', async () => {
const r = await openDesignApp(getDesignAppPath())
if (!r.ok) return fail(CS_FAIL, r.message)
return ok()
})
tracedHandle('dll:reject-available', async () => {
const dll = await loadDllModule()
return ok({ available: dll.isRejectApiAvailable() })
})
} }
export async function handleBeforeQuit(): Promise<void> { export async function handleBeforeQuit(): Promise<void> {
const dll = isDllInitAttempted() ? await loadDllModule().catch(() => null) : null
const shouldCancel = const shouldCancel =
mainAppState.mode === 'distributing' && mainAppState.mode === 'distributing' &&
!!mainAppState.activeJobId && !!mainAppState.activeJobId &&
isCancelApiAvailable() isJobPollActive() &&
!!dll?.isCancelApiAvailable()
const cancelJobId = mainAppState.activeJobId const cancelJobId = mainAppState.activeJobId
stopAllPolls() stopAllPolls()
if (shouldCancel && cancelJobId) { if (shouldCancel && cancelJobId && dll) {
try { try {
dllAdminJobCancel(cancelJobId) dll.dllAdminJobCancel(cancelJobId)
} catch (e) { } catch (e) {
log.warn('before-quit cancel', e) log.warn('before-quit cancel', e)
} }
+46
View File
@@ -0,0 +1,46 @@
import { ipcMain, type IpcMainInvokeEvent } from 'electron'
import { emitTrace } from '../utils/trace-bridge'
type IpcHandler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown | Promise<unknown>
function summarizeArgs(args: unknown[]): Record<string, unknown> {
if (args.length === 0) return {}
const a = args[0]
if (typeof a === 'string') {
if (a.length > 240) return { text: `${a.slice(0, 240)}`, bytes: a.length }
return { arg0: a }
}
if (typeof a === 'object' && a !== null) return { ...(a as Record<string, unknown>) }
return { arg0: a }
}
function summarizeResult(r: unknown): Record<string, unknown> {
if (!r || typeof r !== 'object') return { value: r }
const o = r as { ok?: boolean; code?: number; message?: string; data?: unknown }
const out: Record<string, unknown> = {}
if (o.ok !== undefined) out.ok = o.ok
if (o.code !== undefined) out.code = o.code
if (o.message) out.message = o.message
if (o.data !== undefined && o.data !== null && typeof o.data === 'object') {
const d = o.data as Record<string, unknown>
for (const k of ['warning', 'skipped', 'jobId', 'path', 'paths', 'missing', 'available']) {
if (k in d) out[k] = d[k]
}
}
return out
}
export function tracedHandle(channel: string, handler: IpcHandler): void {
ipcMain.handle(channel, async (event, ...args) => {
const start = Date.now()
emitTrace(`[ipc] ${channel}`, summarizeArgs(args))
try {
const result = await handler(event, ...args)
emitTrace(`[ipc] ${channel}`, { ms: Date.now() - start, ...summarizeResult(result) })
return result
} catch (e) {
emitTrace(`[ipc] ${channel}`, { ms: Date.now() - start, error: String(e) }, 'error')
throw e
}
})
}
+114
View File
@@ -0,0 +1,114 @@
import { app } from 'electron'
import fs from 'fs'
import path from 'path'
import log from 'electron-log'
import { LOG_FATAL_FLAG } from '../constants'
import { configStore } from './config-store'
import { getProcessExecDir } from './native-path'
export const APP_CONFIG_FILENAME = 'cardsoon.config.json'
const DEFAULT_SHARED_DIR = path.join('C:', 'PrintTasks')
export interface AppFileConfig {
designAppPath: string
sharedDir: string
logLevel: number
cleanTaskFile: boolean
}
const defaults: AppFileConfig = {
designAppPath: '',
sharedDir: DEFAULT_SHARED_DIR,
logLevel: LOG_FATAL_FLAG,
cleanTaskFile: false
}
let cached: AppFileConfig | null = null
function pickRaw(raw: Record<string, unknown>, ...keys: string[]): unknown {
for (const key of keys) {
const hit = Object.entries(raw).find(([name]) => name.toLowerCase() === key.toLowerCase())
if (hit && hit[1] != null && String(hit[1]).trim() !== '') return hit[1]
}
return undefined
}
function parseCleanTaskFile(raw: unknown): boolean {
if (raw === undefined || raw === null) return defaults.cleanTaskFile
if (typeof raw === 'boolean') return raw
if (typeof raw === 'number') return raw !== 0
const s = String(raw).trim().toLowerCase()
if (s === '0' || s === 'false') return false
if (s === '1' || s === 'true') return true
return defaults.cleanTaskFile
}
function parseLogLevel(raw: unknown): number {
if (raw === undefined || raw === null) return defaults.logLevel
const n = Number(raw)
return Number.isFinite(n) ? Math.round(n) : defaults.logLevel
}
function parseSharedDir(raw: unknown): string {
const s = String(raw ?? '').trim()
return s || DEFAULT_SHARED_DIR
}
function bundledConfigPath(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, APP_CONFIG_FILENAME)
}
return path.join(app.getAppPath(), 'resources', APP_CONFIG_FILENAME)
}
function configSearchPaths(): string[] {
const besideExe = path.join(getProcessExecDir(), APP_CONFIG_FILENAME)
const bundled = bundledConfigPath()
if (besideExe === bundled) return [besideExe]
return [besideExe, bundled]
}
function parseConfigFile(filePath: string): AppFileConfig {
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, unknown>
return {
designAppPath: String(pickRaw(raw, 'designAppPath') ?? '').trim(),
sharedDir: parseSharedDir(pickRaw(raw, 'sharedDir', 'shareddir')),
logLevel: parseLogLevel(pickRaw(raw, 'logLevel', 'loglevel')),
cleanTaskFile: parseCleanTaskFile(pickRaw(raw, 'cleanTaskFile', 'cleantaskfile'))
}
}
export function loadAppFileConfig(): AppFileConfig {
if (cached) return cached
for (const filePath of configSearchPaths()) {
if (!fs.existsSync(filePath)) continue
try {
cached = parseConfigFile(filePath)
log.info(`Loaded ${APP_CONFIG_FILENAME} from ${filePath}`, {
sharedDir: cached.sharedDir,
logLevel: cached.logLevel,
cleanTaskFile: cached.cleanTaskFile
})
return cached
} catch (e) {
log.warn(`Skip invalid ${APP_CONFIG_FILENAME}: ${filePath}`, e)
}
}
cached = { ...defaults }
log.warn(
`${APP_CONFIG_FILENAME} not found (checked: ${configSearchPaths().join(', ')}), using defaults`
)
return cached
}
export function applyFileConfigToStore(): void {
const cfg = loadAppFileConfig()
configStore.set('sharedDir', cfg.sharedDir)
}
export function getDesignAppPath(): string {
return loadAppFileConfig().designAppPath
}
+5 -7
View File
@@ -1,22 +1,20 @@
import Store from 'electron-store' import Store from 'electron-store'
import { app } from 'electron' import { app } from 'electron'
import path from 'path' import path from 'path'
import type { PrinterStatusSnapshot } from '@shared/printer-info'
interface AppConfig { interface AppConfig {
sharedDir: string sharedDir: string
templateDir: string templateDir: string
/** G2 门禁 false:启动即 SAPI_Init;仅调试可改 true */ traceEnabled: boolean
skipDllInit: boolean lastPrinterStatus?: PrinterStatusSnapshot
} }
const defaultShared = path.join('C:', 'PrintTasks')
export const configStore = new Store<AppConfig>({ export const configStore = new Store<AppConfig>({
name: 'cardsoon-config', name: 'cardsoon-config',
defaults: { defaults: {
sharedDir: defaultShared, sharedDir: path.join('C:', 'PrintTasks'),
templateDir: path.join(app.getPath('userData'), 'Cardsoon', 'templates'), templateDir: path.join(app.getPath('userData'), 'Cardsoon', 'templates'),
// 正式版始终 Init;仅开发时可通过 --skip-dll-init 临时跳过 traceEnabled: true
skipDllInit: false
} }
}) })
+53
View File
@@ -0,0 +1,53 @@
import fs from 'fs'
import log from 'electron-log'
import { CS_OK } from '../constants'
import { mainAppState } from './app-state'
import { configStore } from './config-store'
import { loadAppFileConfig } from './app-config'
import { loadDllModule } from './dll-loader'
import type { InitParams } from './work-dll.service'
let dllInitAttempted = false
export function isDllInitAttempted(): boolean {
return dllInitAttempted
}
export async function ensureDllInitialized(
params?: Partial<InitParams>
): Promise<{ code: number; warning?: string }> {
if (dllInitAttempted) {
return { code: CS_OK }
}
const fileCfg = loadAppFileConfig()
const sharedDir =
params?.sharedDir?.trim() || fileCfg.sharedDir || (configStore.get('sharedDir') as string)
try {
const dll = await loadDllModule()
fs.mkdirSync(sharedDir, { recursive: true })
const code = dll.dllInit({
sharedDir,
keepCombinedImage: params?.keepCombinedImage,
stopOnFailure: params?.stopOnFailure,
cleanTaskFile: params?.cleanTaskFile ?? fileCfg.cleanTaskFile,
autoRetryTimes: params?.autoRetryTimes,
rejectConfig: params?.rejectConfig,
logLevel: params?.logLevel ?? fileCfg.logLevel,
outBack: params?.outBack
})
dllInitAttempted = true
mainAppState.initialized = true
configStore.set('sharedDir', sharedDir)
log.info('DLL initialized', { sharedDir, code })
if (code === CS_OK) return { code }
return {
code,
warning: '打印机未连接或驱动未就绪,可继续配置任务,接好设备后重启应用'
}
} catch (err) {
mainAppState.initialized = false
dllInitAttempted = false
log.error('DLL init failed', err)
throw err
}
}
+20
View File
@@ -0,0 +1,20 @@
import { setupNativeWorkingDir } from './native-path'
type DllModule = typeof import('./work-dll.service')
let dllMod: DllModule | null = null
let nativeReady = false
function ensureNativeEnv(): void {
if (nativeReady) return
setupNativeWorkingDir()
nativeReady = true
}
export async function loadDllModule(): Promise<DllModule> {
ensureNativeEnv()
if (!dllMod) {
dllMod = await import('./work-dll.service')
}
return dllMod
}
+6 -1
View File
@@ -88,5 +88,10 @@ export function setupNativeWorkingDir(): void {
if (!process.env.PATH?.toLowerCase().includes(nativeDir.toLowerCase())) { if (!process.env.PATH?.toLowerCase().includes(nativeDir.toLowerCase())) {
process.env.PATH = `${pathHead}${path.delimiter}${process.env.PATH || ''}` process.env.PATH = `${pathHead}${path.delimiter}${process.env.PATH || ''}`
} }
log.debug(`Native DLL search path: ${nativeDir}; cwd kept at ${process.cwd()}`) try {
process.chdir(execDir)
} catch (e) {
log.warn(`chdir to ${execDir} failed`, e)
}
log.debug(`Native DLL search path: ${nativeDir}; cwd=${process.cwd()}`)
} }
+31
View File
@@ -0,0 +1,31 @@
import { shell } from 'electron'
import fs from 'fs'
import path from 'path'
function validateDesignAppPath(exePath: string): { ok: true } | { ok: false; message: string } {
const p = exePath.trim()
if (!p) {
return { ok: false, message: '请在 cardsoon.config.json 中配置 designAppPath' }
}
const resolved = path.resolve(p)
if (!fs.existsSync(resolved)) {
return { ok: false, message: `设计软件不存在: ${resolved}` }
}
return { ok: true }
}
export async function openDesignApp(
exePath: string
): Promise<{ ok: true } | { ok: false; message: string }> {
const check = validateDesignAppPath(exePath)
if (!check.ok) return check
const target = path.resolve(exePath.trim())
const err = await shell.openPath(target)
if (err) {
// 用户在 UAC/系统弹窗中点击"否"或被系统拒绝时,shell.openPath 返回通用错误字符串
// 不把原始 "Failed to Open Path" 直接抛给用户,给出可读说明
return { ok: false, message: '打开设计软件失败,可能已被取消或需要管理员权限' }
}
return { ok: true }
}
+138 -52
View File
@@ -1,11 +1,21 @@
import { BrowserWindow } from 'electron' import { BrowserWindow } from 'electron'
import log from 'electron-log' import log from 'electron-log'
import { POLL_INTERVAL_MS } from '../constants' import { normalizeCardPositionQuery } from '@shared/card-position'
import { POLL_INTERVAL_MS, CS_OK } from '../constants'
import {
USB_TASK_COMPLETED,
USB_TASK_FAILED,
clampUsbCopyProgress,
usbTaskStatusHint
} from '@shared/usb-copy-state'
import { mainAppState } from './app-state' import { mainAppState } from './app-state'
import { dllGetJobStateById, dllGetUsbCopyState } from './work-dll.service' import { emitTrace } from '../utils/trace-bridge'
import { loadDllModule } from './dll-loader'
let jobTimer: ReturnType<typeof setInterval> | null = null let jobTimer: ReturnType<typeof setInterval> | null = null
let usbTimer: ReturnType<typeof setInterval> | null = null let usbTimer: ReturnType<typeof setInterval> | null = null
let cardTimer: ReturnType<typeof setInterval> | null = null
let usbPollGen = 0
let jobId = '' let jobId = ''
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
@@ -34,80 +44,156 @@ export function stopJobPoll(resetMode = false): void {
} }
} }
export function stopUsbPoll(): void { export function stopUsbPoll(resetMode = false): void {
usbPollGen += 1
if (usbTimer) { if (usbTimer) {
clearInterval(usbTimer) clearInterval(usbTimer)
usbTimer = null usbTimer = null
} }
if (resetMode && mainAppState.mode === 'usbCopying') {
mainAppState.mode = 'ready'
}
}
export function stopCardPositionPoll(): void {
if (cardTimer) {
clearInterval(cardTimer)
cardTimer = null
}
} }
export function stopAllPolls(): void { export function stopAllPolls(): void {
stopJobPoll(true) stopJobPoll(true)
stopUsbPoll() stopUsbPoll(true)
stopCardPositionPoll()
} }
export function startJobPoll(id: string): void { export function startJobPoll(id: string): void {
stopJobPoll(false) stopJobPoll(false)
jobId = id jobId = id
jobTimer = setInterval(() => { jobTimer = setInterval(() => {
try { void (async () => {
const r = dllGetJobStateById(jobId) try {
const failed = r.jobState === 4 const dll = await loadDllModule()
const cancelled = r.jobState === 6 const r = dll.dllGetJobStateById(jobId)
const finished = r.jobState === 100 const failed = r.jobState === 4
const terminal = failed || cancelled const cancelled = r.jobState === 6
send('job:poll-tick', { const finished = r.jobState === 100
jobId, const terminal = failed || cancelled
queryErrorCode: r.queryErrorCode, // 失败当刻立即取打印机错误串(与 USB 收集轮询对齐),避免渲染层事后取到通用/过期文案
jobState: r.jobState, let errorMessage = ''
progress: r.progress, if (failed) {
terminal, try {
failed, errorMessage = dll.dllGetPrinterErrorStr(-1) || ''
cancelled, } catch (e) {
finished log.warn('GetPrinterErrorStr on job failed failed', e)
}) }
if (r.queryErrorCode !== 0) { }
log.warn('GetJobStateById query failed', r.queryErrorCode) const tick = {
stopJobPoll(true) jobId,
return queryErrorCode: r.queryErrorCode,
jobState: r.jobState,
progress: r.progress,
terminal,
failed,
cancelled,
finished,
errorMessage
}
emitTrace('[poll] job:poll-tick', tick)
send('job:poll-tick', tick)
if (r.queryErrorCode !== 0) {
log.warn('GetJobStateById query failed', r.queryErrorCode)
return
}
if (failed || cancelled || finished) {
stopJobPoll(false)
}
} catch (e) {
log.error('job poll error', e)
stopJobPoll(false)
} }
if (failed || cancelled) { })()
stopJobPoll(true)
}
} catch (e) {
log.error('job poll error', e)
stopJobPoll(true)
}
}, POLL_INTERVAL_MS) }, POLL_INTERVAL_MS)
} }
export function startUsbPoll(): void { export function startUsbPoll(): void {
stopUsbPoll() stopUsbPoll(false)
const gen = usbPollGen
void pollUsbOnce(gen).catch((e) => log.error('usb poll error', e))
usbTimer = setInterval(() => { usbTimer = setInterval(() => {
try { void pollUsbOnce(gen).catch((e) => {
const r = dllGetUsbCopyState()
const failed = r.taskStatus === 3
const success = r.taskStatus === 2
const terminal = failed || success
send('usb:poll-tick', {
taskStatus: r.taskStatus,
progress: r.progress,
terminal,
failed,
success
})
if (terminal) {
stopUsbPoll()
mainAppState.mode = 'ready'
}
} catch (e) {
log.error('usb poll error', e) log.error('usb poll error', e)
stopUsbPoll() stopUsbPoll(false)
mainAppState.mode = 'ready' })
} }, POLL_INTERVAL_MS)
}
async function pollUsbOnce(gen: number): Promise<void> {
if (gen !== usbPollGen) return
const dll = await loadDllModule()
if (gen !== usbPollGen) return
const r = dll.dllGetUsbCopyState()
if (gen !== usbPollGen) return
const copyProgress = clampUsbCopyProgress(r.progress)
const failed = r.taskStatus === USB_TASK_FAILED
const success = r.taskStatus === USB_TASK_COMPLETED
const terminal = failed || success
let errorMessage = ''
if (failed) {
const errText = dll.dllGetPrinterErrorStr(-1)
errorMessage = errText || usbTaskStatusHint(USB_TASK_FAILED)
}
const tick = {
queryCode: r.queryCode,
taskStatus: r.taskStatus,
progress: copyProgress,
terminal,
failed,
success,
errorMessage
}
emitTrace('[poll] usb:poll-tick', tick)
if (gen !== usbPollGen) return
send('usb:poll-tick', tick)
if (r.queryCode !== CS_OK) {
log.warn('GetUsbCopyState query failed', r.queryCode)
return
}
if (terminal) {
stopUsbPoll(false)
}
}
async function pollCardPositionOnce(): Promise<void> {
try {
const dll = await loadDllModule()
if (!dll.isCardPositionApiAvailable()) return
const r = dll.dllGetPrinterCardPosition()
const tick = normalizeCardPositionQuery(r.queryCode, r.position)
emitTrace('[poll] card:position-tick', tick)
send('card:position-tick', tick)
} catch (e) {
log.error('card position poll error', e)
}
}
export function startCardPositionPoll(): void {
stopCardPositionPoll()
void pollCardPositionOnce()
cardTimer = setInterval(() => {
void pollCardPositionOnce()
}, POLL_INTERVAL_MS) }, POLL_INTERVAL_MS)
} }
export function getActiveJobId(): string { export function getActiveJobId(): string {
return jobId return jobId
} }
export function isJobPollActive(): boolean {
return jobTimer !== null
}
export function isUsbPollActive(): boolean {
return usbTimer !== null
}
+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()
}
+306 -58
View File
@@ -1,7 +1,9 @@
import path from 'path' import path from 'path'
import koffi from 'koffi' import koffi from 'koffi'
import log from 'electron-log' import log from 'electron-log'
import { normalizeCardPositionQuery } from '@shared/card-position'
import { CS_OK, JOB_ID_BUF_SIZE, LOG_FATAL_FLAG } from '../constants' import { CS_OK, JOB_ID_BUF_SIZE, LOG_FATAL_FLAG } from '../constants'
import { emitTrace, isTraceEnabled } from '../utils/trace-bridge'
import { getNativeDir } from './native-path' import { getNativeDir } from './native-path'
export interface InitParams { export interface InitParams {
@@ -22,6 +24,8 @@ let SAPI_Init: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterInfo: any = null let SAPI_GetPrinterInfo: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_FreePrinterInfo: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterErrorStr: any = null let SAPI_GetPrinterErrorStr: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_RestJobEx: any = null let SAPI_RestJobEx: any = null
@@ -37,11 +41,78 @@ let SAPI_GetUsbCopyState: any = null
let SAPI_PrinterResetprinter: any = null let SAPI_PrinterResetprinter: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterMovetoreject: any = null let SAPI_PrinterMovetoreject: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterMovetousbreader: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterMovetohopper: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterCardPosition: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterCheckstatus: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_UploadFile: any = null
let hasRejectApi = false let hasRejectApi = false
let hasCardPositionApi = false
let hasCheckstatusApi = false
let hasCancelApi = false let hasCancelApi = false
let hasUploadApi = false
let hasUsbReaderApi = false
let hasHopperApi = false
let loggedCancelMissing = false let loggedCancelMissing = false
let loggedRejectMissing = false let loggedRejectMissing = false
function tracePayload(r: unknown): Record<string, unknown> {
if (r === null || r === undefined) return {}
if (typeof r !== 'object') return { value: r }
return { ...(r as Record<string, unknown>) }
}
function traceCall<T>(name: string, args: Record<string, unknown> | undefined, fn: () => T): T {
if (!isTraceEnabled()) return fn()
const tag = `[dll] ${name}`
const start = Date.now()
emitTrace(`${tag}`, args)
try {
const r = fn()
emitTrace(`${tag}`, { ms: Date.now() - start, ...tracePayload(r) })
return r
} catch (e) {
emitTrace(`${tag}`, { ms: Date.now() - start, error: String(e) }, 'error')
throw e
}
}
function freePrinterInfoPtr(ptr: number): void {
if (!ptr) return
// DLL 堆分配的缓冲区必须用 DLL 导出的释放函数;koffi.free 会触发 STATUS_HEAP_CORRUPTION
if (SAPI_FreePrinterInfo) {
try {
SAPI_FreePrinterInfo(ptr)
} catch (e) {
log.warn('SAPI_FreePrinterInfo failed', e)
}
return
}
log.warn('SAPI_FreePrinterInfo unavailable; printer info buffer not freed')
}
function readPrinterJsonFromOutPtr(len: number, outPtr: Buffer): { code: number; json?: Record<string, unknown> } {
if (len <= 0) return { code: len }
const ptr = koffi.decode(outPtr, 0, 'void *') as number
if (!ptr) return { code: len }
try {
const jsonStr = koffi.decode(ptr, 'char', len) as string
if (!jsonStr?.trim()) return { code: len }
try {
return { code: len, json: JSON.parse(jsonStr) as Record<string, unknown> }
} catch {
return { code: len }
}
} finally {
freePrinterInfoPtr(ptr)
}
}
function loadLibrary(): void { function loadLibrary(): void {
if (lib) return if (lib) return
const dllPath = path.join(getNativeDir(), 'workDll.dll') const dllPath = path.join(getNativeDir(), 'workDll.dll')
@@ -58,6 +129,22 @@ function loadLibrary(): void {
SAPI_GetUsbCopyState = lib.func('int __stdcall SAPI_GetUsbCopyState(_Out_ int *, _Out_ int *)') SAPI_GetUsbCopyState = lib.func('int __stdcall SAPI_GetUsbCopyState(_Out_ int *, _Out_ int *)')
SAPI_PrinterResetprinter = lib.func('int __stdcall SAPI_PrinterResetprinter()') SAPI_PrinterResetprinter = lib.func('int __stdcall SAPI_PrinterResetprinter()')
try {
SAPI_FreePrinterInfo = lib.func('void __stdcall SAPI_FreePrinterInfo(void *)')
} catch {
SAPI_FreePrinterInfo = null
log.warn('SAPI_FreePrinterInfo not in workDll')
}
try {
SAPI_UploadFile = lib.func('int __stdcall SAPI_UploadFile(str, str, str)')
hasUploadApi = true
} catch {
SAPI_UploadFile = null
hasUploadApi = false
log.warn('SAPI_UploadFile not in workDll')
}
try { try {
SAPI_AdminJobCancel = lib.func('int __stdcall SAPI_AdminJobCancel(str)') SAPI_AdminJobCancel = lib.func('int __stdcall SAPI_AdminJobCancel(str)')
hasCancelApi = true hasCancelApi = true
@@ -66,7 +153,7 @@ function loadLibrary(): void {
hasCancelApi = false hasCancelApi = false
if (!loggedCancelMissing) { if (!loggedCancelMissing) {
loggedCancelMissing = true loggedCancelMissing = true
log.info('SAPI_AdminJobCancel not in workDll (optional); stop uses poll-stop only') emitTrace('[dll] SAPI_AdminJobCancel not in workDll (optional)')
} }
} }
@@ -77,9 +164,55 @@ function loadLibrary(): void {
hasRejectApi = false hasRejectApi = false
if (!loggedRejectMissing) { if (!loggedRejectMissing) {
loggedRejectMissing = true loggedRejectMissing = true
log.info('SAPI_PrinterMovetoreject not in workDll (optional); reject card disabled') emitTrace('[dll] SAPI_PrinterMovetoreject not in workDll (optional)')
} }
} }
try {
SAPI_PrinterMovetousbreader = lib.func('int __stdcall SAPI_PrinterMovetousbreader()')
hasUsbReaderApi = true
} catch {
SAPI_PrinterMovetousbreader = null
hasUsbReaderApi = false
emitTrace('[dll] SAPI_PrinterMovetousbreader not in workDll (optional)')
}
try {
SAPI_PrinterMovetohopper = lib.func('int __stdcall SAPI_PrinterMovetohopper()')
hasHopperApi = true
} catch {
SAPI_PrinterMovetohopper = null
hasHopperApi = false
emitTrace('[dll] SAPI_PrinterMovetohopper not in workDll (optional)')
}
try {
SAPI_GetPrinterCardPosition = lib.func('int __stdcall SAPI_GetPrinterCardPosition(_Out_ int *)')
hasCardPositionApi = true
} catch {
SAPI_GetPrinterCardPosition = null
hasCardPositionApi = false
emitTrace('[dll] SAPI_GetPrinterCardPosition not in workDll (optional)')
}
try {
SAPI_PrinterCheckstatus = lib.func('int __stdcall SAPI_PrinterCheckstatus()')
hasCheckstatusApi = true
} catch {
SAPI_PrinterCheckstatus = null
hasCheckstatusApi = false
emitTrace('[dll] SAPI_PrinterCheckstatus not in workDll (optional)')
}
log.info('workDll loaded', {
upload: hasUploadApi,
cancel: hasCancelApi,
reject: hasRejectApi,
usbReader: hasUsbReaderApi,
hopper: hasHopperApi,
cardPosition: hasCardPositionApi,
checkstatus: hasCheckstatusApi
})
} }
export function isRejectApiAvailable(): boolean { export function isRejectApiAvailable(): boolean {
@@ -92,47 +225,108 @@ export function isCancelApiAvailable(): boolean {
return hasCancelApi return hasCancelApi
} }
export function dllInit(params: InitParams): number { export function isUploadApiAvailable(): boolean {
loadLibrary() loadLibrary()
return SAPI_Init!( return hasUploadApi
params.sharedDir, }
params.keepCombinedImage ?? true,
params.stopOnFailure ?? false, export function isUsbReaderApiAvailable(): boolean {
params.cleanTaskFile ?? true, loadLibrary()
params.autoRetryTimes ?? 0, return hasUsbReaderApi
params.rejectConfig ?? false, }
params.logLevel ?? LOG_FATAL_FLAG,
params.outBack ?? false export function isHopperApiAvailable(): boolean {
) as number loadLibrary()
return hasHopperApi
}
export function isCardPositionApiAvailable(): boolean {
loadLibrary()
return hasCardPositionApi
}
export function isPrinterCheckstatusApiAvailable(): boolean {
loadLibrary()
return hasCheckstatusApi
}
export function dllInit(params: InitParams): number {
return traceCall(
'SAPI_Init',
{
sharedDir: params.sharedDir,
keepCombinedImage: params.keepCombinedImage ?? true,
stopOnFailure: params.stopOnFailure ?? false,
cleanTaskFile: params.cleanTaskFile ?? false,
autoRetryTimes: params.autoRetryTimes ?? 0,
rejectConfig: params.rejectConfig ?? false,
logLevel: params.logLevel ?? LOG_FATAL_FLAG,
outBack: params.outBack ?? false
},
() => {
loadLibrary()
return SAPI_Init!(
params.sharedDir,
params.keepCombinedImage ?? true,
params.stopOnFailure ?? false,
params.cleanTaskFile ?? false,
params.autoRetryTimes ?? 0,
params.rejectConfig ?? false,
params.logLevel ?? LOG_FATAL_FLAG,
params.outBack ?? false
) as number
}
)
}
function dllGetPrinterInfoInternal(
apiName: 'SAPI_GetPrinterInfo',
fn: (outPtr: Buffer) => number
): { code: number; json?: Record<string, unknown> } {
return traceCall(apiName, undefined, () => {
loadLibrary()
const outPtr = koffi.alloc('void *', 8)
try {
const len = fn(outPtr) as number
return readPrinterJsonFromOutPtr(len, outPtr)
} finally {
koffi.free(outPtr)
}
})
} }
export function dllGetPrinterInfo(): { code: number; json?: Record<string, unknown> } { export function dllGetPrinterInfo(): { code: number; json?: Record<string, unknown> } {
loadLibrary() return dllGetPrinterInfoInternal('SAPI_GetPrinterInfo', (p) => SAPI_GetPrinterInfo!(p))
const outPtr = koffi.alloc('void *', 8)
try {
const len = SAPI_GetPrinterInfo!(outPtr) as number
if (len <= 0) return { code: len }
const ptr = koffi.decode(outPtr, 0, 'void *') as number
const jsonStr = koffi.decode(ptr, 'char', len) as string
koffi.free(ptr)
return { code: len, json: JSON.parse(jsonStr) as Record<string, unknown> }
} finally {
koffi.free(outPtr)
}
} }
export function dllGetPrinterErrorStr(errorNo = -1): string { export function dllGetPrinterErrorStr(errorNo = -1): string {
loadLibrary() return traceCall('SAPI_GetPrinterErrorStr', { errorNo }, () => {
const s = SAPI_GetPrinterErrorStr!(errorNo) as string loadLibrary()
return s || '' const s = SAPI_GetPrinterErrorStr!(errorNo) as string
return s || ''
})
}
export function dllUploadFile(userDir: string, fileName: string, fileText: string): number {
return traceCall(
'SAPI_UploadFile',
{ userDir, fileName, bytes: Buffer.byteLength(fileText ?? '', 'utf8') },
() => {
loadLibrary()
if (!SAPI_UploadFile) throw new Error('UPLOAD_API_UNAVAILABLE')
return SAPI_UploadFile(userDir, fileName, fileText) as number
}
)
} }
export function dllRestJobEx(json: string): { code: number; jobId: string } { export function dllRestJobEx(json: string): { code: number; jobId: string } {
loadLibrary() return traceCall('SAPI_RestJobEx', { jsonBytes: Buffer.byteLength(json ?? '', 'utf8') }, () => {
const buf = Buffer.alloc(JOB_ID_BUF_SIZE) loadLibrary()
const code = SAPI_RestJobEx!(json, buf, JOB_ID_BUF_SIZE) as number const buf = Buffer.alloc(JOB_ID_BUF_SIZE)
const jobId = buf.toString('utf8').replace(/\0.*$/, '').trim() const code = SAPI_RestJobEx!(json, buf, JOB_ID_BUF_SIZE) as number
return { code, jobId } const jobId = buf.toString('utf8').replace(/\0.*$/, '').trim()
return { code, jobId }
})
} }
export function dllGetJobStateById(jobId: string): { export function dllGetJobStateById(jobId: string): {
@@ -140,43 +334,97 @@ export function dllGetJobStateById(jobId: string): {
jobState: number jobState: number
progress: number progress: number
} { } {
loadLibrary() return traceCall('SAPI_GetJobStateById', { jobId }, () => {
const jobState = [0] loadLibrary()
const copyScheduler = [0] const jobState = [0]
const queryErrorCode = SAPI_GetJobStateById!(jobId, jobState, copyScheduler) as number const copyScheduler = [0]
return { const queryErrorCode = SAPI_GetJobStateById!(jobId, jobState, copyScheduler) as number
queryErrorCode, return {
jobState: jobState[0], queryErrorCode,
progress: copyScheduler[0] jobState: jobState[0],
} progress: copyScheduler[0]
}
})
} }
export function dllAdminJobCancel(jobId: string): number { export function dllAdminJobCancel(jobId: string): number {
loadLibrary() return traceCall('SAPI_AdminJobCancel', { jobId }, () => {
if (!SAPI_AdminJobCancel) throw new Error('CANCEL_API_UNAVAILABLE') loadLibrary()
return SAPI_AdminJobCancel(jobId) as number if (!SAPI_AdminJobCancel) throw new Error('CANCEL_API_UNAVAILABLE')
return SAPI_AdminJobCancel(jobId) as number
})
} }
export function dllCopyFromUsb(destFolder: string, cardOutput: number): number { export function dllCopyFromUsb(destFolder: string, cardOutput: number): number {
loadLibrary() return traceCall('SAPI_CopyFromUsb', { destFolder, cardOutput }, () => {
return SAPI_CopyFromUsb!(destFolder, cardOutput) as number loadLibrary()
return SAPI_CopyFromUsb!(destFolder, cardOutput) as number
})
} }
export function dllGetUsbCopyState(): { taskStatus: number; progress: number } { export function dllGetUsbCopyState(): {
loadLibrary() queryCode: number
const taskStatus = [0] taskStatus: number
const progress = [0] progress: number
SAPI_GetUsbCopyState!(taskStatus, progress) } {
return { taskStatus: taskStatus[0], progress: progress[0] } return traceCall('SAPI_GetUsbCopyState', undefined, () => {
loadLibrary()
const taskStatus = [0]
const copyProgress = [0]
const queryCode = SAPI_GetUsbCopyState!(taskStatus, copyProgress) as number
return {
queryCode,
taskStatus: taskStatus[0],
progress: copyProgress[0]
}
})
} }
export function dllPrinterReset(): number { export function dllPrinterReset(): number {
loadLibrary() return traceCall('SAPI_PrinterResetprinter', undefined, () => {
return SAPI_PrinterResetprinter!() as number loadLibrary()
return SAPI_PrinterResetprinter!() as number
})
}
export function dllPrinterMoveToUsbReader(): number {
return traceCall('SAPI_PrinterMovetousbreader', undefined, () => {
loadLibrary()
if (!SAPI_PrinterMovetousbreader) throw new Error('USB_READER_API_UNAVAILABLE')
return SAPI_PrinterMovetousbreader() as number
})
}
export function dllPrinterMoveToHopper(): number {
return traceCall('SAPI_PrinterMovetohopper', undefined, () => {
loadLibrary()
if (!SAPI_PrinterMovetohopper) throw new Error('HOPPER_API_UNAVAILABLE')
return SAPI_PrinterMovetohopper() as number
})
} }
export function dllPrinterReject(): number { export function dllPrinterReject(): number {
loadLibrary() return traceCall('SAPI_PrinterMovetoreject', undefined, () => {
if (!SAPI_PrinterMovetoreject) throw new Error('REJECT_API_UNAVAILABLE') loadLibrary()
return SAPI_PrinterMovetoreject() as number if (!SAPI_PrinterMovetoreject) throw new Error('REJECT_API_UNAVAILABLE')
return SAPI_PrinterMovetoreject() as number
})
}
export function dllGetPrinterCardPosition(): { queryCode: number; position: number } {
return traceCall('SAPI_GetPrinterCardPosition', undefined, () => {
loadLibrary()
if (!SAPI_GetPrinterCardPosition) throw new Error('CARD_POSITION_API_UNAVAILABLE')
const out = [0]
const ret = SAPI_GetPrinterCardPosition!(out) as number
return normalizeCardPositionQuery(ret, out[0])
})
}
export function dllPrinterCheckstatus(): number {
return traceCall('SAPI_PrinterCheckstatus', undefined, () => {
loadLibrary()
if (!SAPI_PrinterCheckstatus) throw new Error('CHECKSTATUS_API_UNAVAILABLE')
return SAPI_PrinterCheckstatus!() as number
})
} }
+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 */
}
}
+21
View File
@@ -0,0 +1,21 @@
import fs from 'fs'
import path from 'path'
export interface JobCsvRow {
originName: string
value: string
}
export function buildSoonCsvText(rows: JobCsvRow[]): string {
const line1 = rows.map((r) => r.originName).join(',')
const line2 = rows.map((r) => r.value).join(',')
return `${line1}\n${line2}`
}
export function writeJobCsv(sharedDir: string, taskId: string, rows: JobCsvRow[]): string {
const dir = path.join(sharedDir, taskId)
fs.mkdirSync(dir, { recursive: true })
const csvPath = path.join(dir, 'temp.csv')
fs.writeFileSync(csvPath, buildSoonCsvText(rows), 'utf8')
return csvPath
}
+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
}
+77 -7
View File
@@ -4,12 +4,29 @@ import { pathToFileURL } from 'url'
export interface TemplateFieldRow { export interface TemplateFieldRow {
label: string label: string
value: string value: string
originName: string
/** soon frontData/backData 的 type1=图片,3/4/5=文本可编辑 */
fieldType: number
} }
export interface ParsedSoonTemplate { export interface ParsedSoonTemplate {
frontImageUrl: string frontImageUrl: string
backImageUrl: string backImageUrl: string
fields: TemplateFieldRow[] fields: TemplateFieldRow[]
/** soon 模板 flag1 双面 / 2 正面 / 3 背面 */
templateFlag: number
}
const SOON_FIELD_TYPES = new Set([1, 3, 4, 5])
/** soon 文件元数据 flag,用于与任务 print_flag 校验 */
export function readSoonTemplateFlag(raw: Record<string, unknown>): number {
const flag = Number(raw.flag)
if (flag === 1 || flag === 2 || flag === 3) return flag
const hasBack =
!!String(raw.backDisplayPic ?? '').trim() ||
(Array.isArray(raw.backData) && raw.backData.length > 0)
return hasBack ? 1 : 2
} }
function pickArray(obj: Record<string, unknown>, key: string): Record<string, unknown>[] { function pickArray(obj: Record<string, unknown>, key: string): Record<string, unknown>[] {
@@ -38,18 +55,54 @@ function sideLabel(side: 'front' | 'back'): string {
return side === 'front' ? '正面' : '背面' return side === 'front' ? '正面' : '背面'
} }
function resolveAssetPath(soonPath: string, ref: string): string { function toImageUrl(soonPath: string, ref: string): string {
if (!ref) return '' if (!ref) return ''
if (/^(data:|https?:|file:)/i.test(ref)) return ref
const clean = ref.replace(/^file:\/\//i, '') const clean = ref.replace(/^file:\/\//i, '')
const abs = path.isAbsolute(clean) ? clean : path.join(path.dirname(soonPath), clean) const abs = path.isAbsolute(clean) ? clean : path.join(path.dirname(soonPath), clean)
return pathToFileURL(abs).href return pathToFileURL(abs).href
} }
function toFieldLabel(name: string, side: 'front' | 'back'): string { function resolveAssetPath(soonPath: string, ref: string): string {
return `${name} [${sideLabel(side)}]` return toImageUrl(soonPath, ref)
} }
export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate { function toFieldLabel(name: string, side: 'front' | 'back'): string {
return `${name}[${sideLabel(side)}]`
}
function parseSoonWorkerDisk(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
const fields: TemplateFieldRow[] = []
const appendSide = (arr: unknown, side: '正面' | '背面') => {
if (!Array.isArray(arr)) return
for (const item of arr) {
if (!item || typeof item !== 'object') continue
const o = item as Record<string, unknown>
const type = Number(o.type)
if (!SOON_FIELD_TYPES.has(type)) continue
const name = String(o.name ?? '').trim()
if (!name) continue
const value = o.DefaultText == null ? '' : String(o.DefaultText)
fields.push({ label: `${name}[${side}]`, value, originName: name, fieldType: type })
}
}
appendSide(raw.frontData, '正面')
appendSide(raw.backData, '背面')
const frontPic = String(raw.frontDisplayPic ?? '').trim()
const backPic = String(raw.backDisplayPic ?? '').trim()
return {
frontImageUrl: toImageUrl(soonPath, frontPic),
backImageUrl: toImageUrl(soonPath, backPic),
fields,
templateFlag: readSoonTemplateFlag(raw)
}
}
function parseSoonLegacy(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
const imgs = pickArray(raw, 'Img') const imgs = pickArray(raw, 'Img')
const texts = pickArray(raw, 'Text') const texts = pickArray(raw, 'Text')
@@ -66,7 +119,7 @@ export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>
if (side === 'front') { if (side === 'front') {
if (!frontImageUrl) frontImageUrl = url if (!frontImageUrl) frontImageUrl = url
const name = pickStr(item, ['name', 'field', 'key']) || 'IMAGE' const name = pickStr(item, ['name', 'field', 'key']) || 'IMAGE'
fields.push({ label: toFieldLabel(name, 'front'), value: fileRef }) fields.push({ label: toFieldLabel(name, 'front'), value: fileRef, originName: name, fieldType: 1 })
} else if (!backImageUrl) { } else if (!backImageUrl) {
backImageUrl = url backImageUrl = url
} }
@@ -78,8 +131,25 @@ export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>
const value = pickStr(item, ['value', 'text', 'default', 'content', 'data']) const value = pickStr(item, ['value', 'text', 'default', 'content', 'data'])
let side = sideOf(item) let side = sideOf(item)
if (!side) side = /image|img|front/i.test(name) ? 'front' : 'back' if (!side) side = /image|img|front/i.test(name) ? 'front' : 'back'
fields.push({ label: toFieldLabel(name, side), value }) fields.push({ label: toFieldLabel(name, side), value, originName: name, fieldType: 5 })
}) })
return { frontImageUrl, backImageUrl, fields } return {
frontImageUrl,
backImageUrl,
fields,
templateFlag: readSoonTemplateFlag(raw)
}
}
export function parseSoonTemplate(soonPath: string, raw: Record<string, unknown>): ParsedSoonTemplate {
if (
Array.isArray(raw.frontData) ||
Array.isArray(raw.backData) ||
raw.frontDisplayPic != null ||
raw.backDisplayPic != null
) {
return parseSoonWorkerDisk(soonPath, raw)
}
return parseSoonLegacy(soonPath, raw)
} }
+160
View File
@@ -0,0 +1,160 @@
import fs from 'fs'
import path from 'path'
import log from 'electron-log'
import { CS_OK } from '../constants'
import { cleanPathPattern } from '@shared/path-pattern'
import { getDirectorySizeBytes } from './dir-size'
export type JobStageDll = {
dllUploadFile: (userDir: string, fileName: string, fileText: string) => number
isUploadApiAvailable: () => boolean
}
function copyIfExists(src: string, dest: string): void {
if (!fs.existsSync(src)) return
fs.mkdirSync(path.dirname(dest), { recursive: true })
fs.copyFileSync(src, dest)
}
function uploadText(
dll: JobStageDll,
userDir: string,
fileName: string,
text: string
): boolean {
if (!dll.isUploadApiAvailable()) return false
const code = dll.dllUploadFile(userDir, fileName, fileText)
if (code !== CS_OK) {
log.warn('SAPI_UploadFile failed', { userDir, fileName, code })
return false
}
return true
}
function stageSoonAssets(taskDir: string, soonSrc: string, soonDest: string): void {
copyIfExists(soonSrc, soonDest)
let raw: Record<string, unknown>
try {
raw = JSON.parse(fs.readFileSync(soonDest, 'utf8')) as Record<string, unknown>
} catch (e) {
log.warn('stageSoonAssets: parse soon failed', e)
return
}
const soonDir = path.dirname(soonSrc)
for (const key of ['frontDisplayPic', 'backDisplayPic']) {
const ref = raw[key]
if (typeof ref !== 'string' || !ref.trim()) continue
const clean = ref.replace(/^file:\/\//i, '').trim()
const assetSrc = path.isAbsolute(clean) ? clean : path.join(soonDir, clean)
const assetDest = path.join(taskDir, path.basename(clean))
copyIfExists(assetSrc, assetDest)
}
}
function normalizeCopyPaths(payload: Record<string, unknown>): void {
if (!Array.isArray(payload.path_file)) {
throw new Error('拷贝任务缺少 path_file')
}
const normalized: string[] = []
for (const entry of payload.path_file) {
if (typeof entry !== 'string' || !entry.trim()) continue
const target = cleanPathPattern(entry)
if (!target || !fs.existsSync(target)) {
throw new Error(`拷贝路径不存在: ${entry}`)
}
let st: fs.Stats
try {
st = fs.statSync(target)
} catch {
throw new Error(`拷贝路径不可访问: ${target}`)
}
if (st.isFile()) {
normalized.push(target)
continue
}
if (!st.isDirectory()) {
throw new Error(`拷贝路径无效: ${target}`)
}
if (getDirectorySizeBytes(target) <= 0) {
throw new Error(`拷贝路径下没有可拷贝的文件: ${target}`)
}
normalized.push(target)
}
if (normalized.length === 0) {
throw new Error('拷贝路径下没有可拷贝的文件')
}
payload.path_file = normalized
}
function finalizeRestJobPayload(payload: Record<string, unknown>): void {
const hasCopy = payload.has_copy_task === true
const hasPrint = payload.has_print_task === true
if (!hasCopy && !hasPrint) {
throw new Error('任务需包含拷贝或打印')
}
if (hasPrint) {
if (typeof payload.json_file !== 'string' || !payload.json_file.trim()) {
throw new Error('打印任务缺少 json_file')
}
if (!payload.udf_file) delete payload.udf_file
} else {
delete payload.json_file
delete payload.udf_file
}
if (hasCopy) {
const paths = payload.path_file
if (
!Array.isArray(paths) ||
paths.length === 0 ||
!paths.every((p) => typeof p === 'string' && p.trim())
) {
throw new Error('拷贝任务缺少 path_file')
}
} else {
delete payload.path_file
}
}
export function stageJobPayloadJson(
json: string,
sharedDir: string,
dll: JobStageDll
): { json: string; taskDir: string } {
const payload = JSON.parse(json) as Record<string, unknown>
const taskId = String(payload.task_id || '').trim()
if (!taskId) throw new Error('task_id 缺失')
const taskDir = path.join(sharedDir, taskId)
fs.mkdirSync(taskDir, { recursive: true })
const userDir = taskId
const udfFile = payload.udf_file
if (typeof udfFile === 'string' && udfFile.trim() && fs.existsSync(udfFile.trim())) {
const csvText = fs.readFileSync(udfFile.trim(), 'utf8')
if (!uploadText(dll, userDir, 'temp.csv', csvText)) {
copyIfExists(udfFile.trim(), path.join(taskDir, 'temp.csv'))
}
payload.udf_file = path.join(taskDir, 'temp.csv')
}
const jsonFile = payload.json_file
if (typeof jsonFile === 'string' && jsonFile.trim()) {
const src = jsonFile.trim()
const base = path.basename(src)
const dest = path.join(taskDir, base)
const soonText = fs.readFileSync(src, 'utf8')
uploadText(dll, userDir, base, soonText)
stageSoonAssets(taskDir, src, dest)
payload.json_file = dest
}
if (payload.has_copy_task === true) {
normalizeCopyPaths(payload)
}
finalizeRestJobPayload(payload)
return { json: JSON.stringify(payload), taskDir }
}
+38
View File
@@ -0,0 +1,38 @@
import type { WebContents } from 'electron'
import { configStore } from '../services/config-store'
export interface TracePayload {
level: 'info' | 'error'
message: string
data?: Record<string, unknown>
}
let target: WebContents | null = null
export function setTraceWebContents(wc: WebContents | null): void {
target = wc && !wc.isDestroyed() ? wc : null
}
export function isTraceEnabled(): boolean {
return configStore.get('traceEnabled', true)
}
export function emitTrace(
message: string,
data?: Record<string, unknown>,
level: 'info' | 'error' = 'info'
): void {
if (!isTraceEnabled()) return
if (!target || target.isDestroyed()) return
target.send('app:trace', { level, message, data } satisfies TracePayload)
}
/** 升级旧配置:曾写入 dllTraceEnabled:false 会导致一直无日志 */
export function migrateTraceConfig(): void {
if (configStore.get('_traceV2') === true) return
configStore.set('traceEnabled', true)
configStore.set('_traceV2', true)
if (configStore.has('dllTraceEnabled')) {
configStore.delete('dllTraceEnabled')
}
}
+11 -1
View File
@@ -4,8 +4,11 @@ const channels = {
invoke: [ invoke: [
'dll:init', 'dll:init',
'dll:printer-info', 'dll:printer-info',
'dll:printer-status',
'dll:printer-reset', 'dll:printer-reset',
'dll:printer-reject', 'dll:printer-reject',
'dll:printer-read-card',
'dll:printer-eject-card',
'dll:printer-error-str', 'dll:printer-error-str',
'dll:job-create', 'dll:job-create',
'dll:job-cancel', 'dll:job-cancel',
@@ -14,17 +17,24 @@ const channels = {
'poll:job-stop', 'poll:job-stop',
'poll:usb-start', 'poll:usb-start',
'poll:usb-stop', 'poll:usb-stop',
'poll:card-position-start',
'poll:card-position-stop',
'dialog:open-directory', 'dialog:open-directory',
'dialog:open-file', 'dialog:open-file',
'fs:path-exists', 'fs:path-exists',
'fs:dir-size', 'fs:dir-size',
'fs:parse-soon', 'fs:parse-soon',
'fs:resolve-network-hosts',
'fs:write-job-csv',
'secrets:get',
'secrets:set',
'config:get', 'config:get',
'config:set', 'config:set',
'shell:open-path', 'shell:open-path',
'design:open',
'dll:reject-available' 'dll:reject-available'
] as const, ] as const,
on: ['job:poll-tick', 'usb:poll-tick'] as const on: ['job:poll-tick', 'usb:poll-tick', 'card:position-tick', 'app:trace'] as const
} }
const cardsoonApi = { const cardsoonApi = {
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta <meta
http-equiv="Content-Security-Policy" http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: file: blob:"
/> />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>卡树数据卡打印系统</title> <title>卡树数据卡打印系统</title>
+2
View File
@@ -4,6 +4,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { useAppBootstrap } from '@/composables/useAppBootstrap' import { useAppBootstrap } from '@/composables/useAppBootstrap'
import { usePrinterStatusPoll } from '@/composables/usePrinterStatusPoll'
useAppBootstrap() useAppBootstrap()
usePrinterStatusPoll()
</script> </script>
+112 -25
View File
@@ -1,4 +1,5 @@
import type { InitParamsDTO, IpcResult, JobPollPayload, UsbPollPayload } from '@/types/ipc' import { parsePrinterInfoFromDll } from '@shared/printer-info'
import type { InitParamsDTO, IpcResult, JobPollPayload, UsbPollPayload, CardPositionPollPayload } from '@/types/ipc'
import type { PrinterStatusDisplay } from '@/types/printer' import type { PrinterStatusDisplay } from '@/types/printer'
function api() { function api() {
@@ -13,17 +14,16 @@ export async function dllPrinterInfo(): Promise<IpcResult<Record<string, unknown
return api().invoke('dll:printer-info') as Promise<IpcResult<Record<string, unknown>>> return api().invoke('dll:printer-info') as Promise<IpcResult<Record<string, unknown>>>
} }
export async function dllPrinterStatus(): Promise<
IpcResult<{ statusText: string; statusCode?: number; fromCache?: boolean; liveError?: string }>
> {
return api().invoke('dll:printer-status') as Promise<
IpcResult<{ statusText: string; statusCode?: number; fromCache?: boolean; liveError?: string }>
>
}
export function parsePrinterInfo(json: Record<string, unknown>): PrinterStatusDisplay { export function parsePrinterInfo(json: Record<string, unknown>): PrinterStatusDisplay {
const list = (json.printerList as Record<string, unknown>[]) || [] return parsePrinterInfoFromDll(json)
const p = list[0] || {}
const serial =
p.szPrinterSerial ?? p.PrinterSerial ?? p.SerialNo ?? p.PrinterName ?? '—'
return {
ribbonType: String(p.RibbonType ?? '—'),
statusText: String(p.PrinterType ?? '—'),
serialNo: String(serial),
printedCount: Number(p.PrintedCount ?? 0)
}
} }
export async function dllPrinterReset(): Promise<IpcResult> { export async function dllPrinterReset(): Promise<IpcResult> {
@@ -34,6 +34,16 @@ export async function dllPrinterReject(): Promise<IpcResult> {
return api().invoke('dll:printer-reject') as Promise<IpcResult> return api().invoke('dll:printer-reject') as Promise<IpcResult>
} }
/** 读卡:移动卡片到读取区(SAPI_PrinterMovetousbreader */
export async function dllPrinterReadCard(): Promise<IpcResult> {
return api().invoke('dll:printer-read-card') as Promise<IpcResult>
}
/** 退卡:移动卡片到出卡区(SAPI_PrinterMovetohopper */
export async function dllPrinterEjectCard(): Promise<IpcResult> {
return api().invoke('dll:printer-eject-card') as Promise<IpcResult>
}
export async function dllRejectAvailable(): Promise<IpcResult<{ available: boolean }>> { export async function dllRejectAvailable(): Promise<IpcResult<{ available: boolean }>> {
return api().invoke('dll:reject-available') as Promise<IpcResult<{ available: boolean }>> return api().invoke('dll:reject-available') as Promise<IpcResult<{ available: boolean }>>
} }
@@ -42,32 +52,45 @@ export async function dllPrinterErrorStr(errorNo = -1): Promise<IpcResult<{ text
return api().invoke('dll:printer-error-str', errorNo) as Promise<IpcResult<{ text: string }>> return api().invoke('dll:printer-error-str', errorNo) as Promise<IpcResult<{ text: string }>>
} }
export async function dllJobCreate(json: string): Promise<IpcResult<{ jobId: string }>> { export async function dllJobCreate(
return api().invoke('dll:job-create', json) as Promise<IpcResult<{ jobId: string }>> json: string,
opts?: { resubmit?: boolean }
): Promise<IpcResult<{ jobId: string }>> {
return api().invoke('dll:job-create', json, opts) as Promise<IpcResult<{ jobId: string }>>
} }
export async function dllJobCancel(jobId: string): Promise<IpcResult> { export async function dllJobCancel(jobId: string): Promise<IpcResult> {
return api().invoke('dll:job-cancel', jobId) as Promise<IpcResult> return api().invoke('dll:job-cancel', jobId) as Promise<IpcResult>
} }
export async function dllUsbCopy(destFolder: string, cardOutput: number): Promise<IpcResult> { export async function dllUsbCopy(
return api().invoke('dll:usb-copy', { destFolder, cardOutput }) as Promise<IpcResult> destFolder: string,
cardOutput: number,
opts?: { resubmit?: boolean }
): Promise<IpcResult> {
return api().invoke('dll:usb-copy', { destFolder, cardOutput, resubmit: opts?.resubmit }) as Promise<IpcResult>
} }
export async function pollJobStart(jobId: string): Promise<IpcResult> { export async function pollJobStart(jobId: string): Promise<IpcResult> {
return api().invoke('poll:job-start', jobId) as Promise<IpcResult> return api().invoke('poll:job-start', jobId) as Promise<IpcResult>
} }
export async function pollJobStop(): Promise<IpcResult> { export async function pollJobStop(opts?: { resetMode?: boolean }): Promise<IpcResult> {
return api().invoke('poll:job-stop') as Promise<IpcResult> return api().invoke('poll:job-stop', opts) as Promise<IpcResult>
} }
export async function pollUsbStart(): Promise<IpcResult> { export async function pollUsbStop(opts?: { resetMode?: boolean }): Promise<IpcResult> {
return api().invoke('poll:usb-start') as Promise<IpcResult> return api().invoke('poll:usb-stop', opts) as Promise<IpcResult>
} }
export async function pollUsbStop(): Promise<IpcResult> { export async function pollCardPositionStart(
return api().invoke('poll:usb-stop') as Promise<IpcResult> sessionMode?: 'distributing' | 'usbCopying'
): Promise<IpcResult> {
return api().invoke('poll:card-position-start', sessionMode) as Promise<IpcResult>
}
export async function pollCardPositionStop(): Promise<IpcResult> {
return api().invoke('poll:card-position-stop') as Promise<IpcResult>
} }
export function onJobPollTick(cb: (p: JobPollPayload) => void): () => void { export function onJobPollTick(cb: (p: JobPollPayload) => void): () => void {
@@ -78,6 +101,10 @@ export function onUsbPollTick(cb: (p: UsbPollPayload) => void): () => void {
return api().on('usb:poll-tick', cb as (...args: unknown[]) => void) return api().on('usb:poll-tick', cb as (...args: unknown[]) => void)
} }
export function onCardPositionTick(cb: (p: CardPositionPollPayload) => void): () => void {
return api().on('card:position-tick', cb as (...args: unknown[]) => void)
}
export async function dialogOpenDirectory(): Promise<IpcResult<{ paths: string[] }>> { export async function dialogOpenDirectory(): Promise<IpcResult<{ paths: string[] }>> {
return api().invoke('dialog:open-directory') as Promise<IpcResult<{ paths: string[] }>> return api().invoke('dialog:open-directory') as Promise<IpcResult<{ paths: string[] }>>
} }
@@ -88,6 +115,12 @@ export async function dialogOpenSoon(): Promise<IpcResult<{ path: string }>> {
> >
} }
export async function dialogOpenImage(): Promise<IpcResult<{ path: string }>> {
return api().invoke('dialog:open-file', [
{ name: 'Image', extensions: ['jpg', 'jpeg', 'png', 'bmp', 'gif', 'webp'] }
]) as Promise<IpcResult<{ path: string }>>
}
export async function fsPathExists(paths: string[]): Promise<IpcResult<{ missing: string[] }>> { export async function fsPathExists(paths: string[]): Promise<IpcResult<{ missing: string[] }>> {
return api().invoke('fs:path-exists', paths) as Promise<IpcResult<{ missing: string[] }>> return api().invoke('fs:path-exists', paths) as Promise<IpcResult<{ missing: string[] }>>
} }
@@ -100,22 +133,76 @@ export async function fsDirSize(
> >
} }
export async function fsWriteJobCsv(payload: {
taskId: string
rows: { originName: string; value: string }[]
}): Promise<IpcResult<{ path: string }>> {
return api().invoke('fs:write-job-csv', payload) as Promise<IpcResult<{ path: string }>>
}
export async function fsParseSoon(filePath: string): Promise< export async function fsParseSoon(filePath: string): Promise<
IpcResult<{ frontImageUrl: string; backImageUrl: string; fields: { label: string; value: string }[] }> IpcResult<{
frontImageUrl: string
backImageUrl: string
fields: { label: string; value: string; originName: string; fieldType: number }[]
templateFlag: number
}>
> { > {
return api().invoke('fs:parse-soon', filePath) as Promise< return api().invoke('fs:parse-soon', filePath) as Promise<
IpcResult<{ frontImageUrl: string; backImageUrl: string; fields: { label: string; value: string }[] }> IpcResult<{
frontImageUrl: string
backImageUrl: string
fields: { label: string; value: string; originName: string; fieldType: number }[]
templateFlag: number
}>
> >
} }
export async function fsResolveNetworkHosts(
paths: string[]
): Promise<IpcResult<{ hosts: string[]; driveHostMap: Record<string, string> }>> {
return api().invoke('fs:resolve-network-hosts', paths) as Promise<
IpcResult<{ hosts: string[]; driveHostMap: Record<string, string> }>
>
}
export interface SecretsPayloadDTO {
networkCredentials?: Record<string, { userName: string; password: string; lastUsed: string }>
dongleAuthCode?: string
}
export async function secretsGet(): Promise<IpcResult<SecretsPayloadDTO>> {
return api().invoke('secrets:get') as Promise<IpcResult<SecretsPayloadDTO>>
}
export async function secretsSet(patch: SecretsPayloadDTO): Promise<IpcResult<SecretsPayloadDTO>> {
return api().invoke('secrets:set', patch) as Promise<IpcResult<SecretsPayloadDTO>>
}
export async function configGet(): Promise< export async function configGet(): Promise<
IpcResult<{ sharedDir: string; templateDir: string; skipDllInit?: boolean }> IpcResult<{
sharedDir: string
templateDir: string
traceEnabled: boolean
lastPrinterStatus?: PrinterStatusDisplay
dllInitialized: boolean
}>
> { > {
return api().invoke('config:get') as Promise< return api().invoke('config:get') as Promise<
IpcResult<{ sharedDir: string; templateDir: string; skipDllInit?: boolean }> IpcResult<{
sharedDir: string
templateDir: string
traceEnabled: boolean
lastPrinterStatus?: PrinterStatusDisplay
dllInitialized: boolean
}>
> >
} }
export async function openDesignApp(): Promise<IpcResult> {
return api().invoke('design:open') as Promise<IpcResult>
}
export async function configSet(patch: Record<string, unknown>): Promise<IpcResult> { export async function configSet(patch: Record<string, unknown>): Promise<IpcResult> {
return api().invoke('config:set', patch) as Promise<IpcResult> return api().invoke('config:set', patch) as Promise<IpcResult>
} }
+10 -2
View File
@@ -13,7 +13,11 @@ export const ICON_NAMES = [
'plus', 'plus',
'exchange', 'exchange',
'stop', 'stop',
'warning' 'warning',
'id-card',
'eject',
'eye',
'eye-slash'
] as const ] as const
export type IconName = (typeof ICON_NAMES)[number] export type IconName = (typeof ICON_NAMES)[number]
@@ -34,5 +38,9 @@ export const ICON_FA_CLASS: Record<IconName, string> = {
plus: 'fas fa-plus', plus: 'fas fa-plus',
exchange: 'fas fa-exchange-alt', exchange: 'fas fa-exchange-alt',
stop: 'fas fa-stop', stop: 'fas fa-stop',
warning: 'fas fa-exclamation-triangle' warning: 'fas fa-exclamation-triangle',
'id-card': 'fas fa-id-card',
eject: 'fas fa-eject',
eye: 'fas fa-eye',
'eye-slash': 'fas fa-eye-slash'
} }
+11 -3
View File
@@ -1,7 +1,15 @@
<template> <template>
<footer class="c-footer"> <footer class="c-footer">
<div>版本V1.0</div> <div class="c-footer__version">{{ t('footer.version', { version: appVersion }) }}</div>
<div>www.cardsoon.com</div> <div>{{ t('footer.website') }}</div>
<div>版权所有 © 2026 卡树科技</div> <div>{{ t('footer.copyright') }}</div>
</footer> </footer>
</template> </template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// 构建时由 electron.vite.config.ts 从 package.json version 注入
const appVersion = __APP_VERSION__
</script>
+65 -9
View File
@@ -5,16 +5,24 @@
<div class="c-header__center"> <div class="c-header__center">
<div v-if="mode" class="c-mode-badge c-mode-badge--home">{{ mode }}</div> <div v-if="mode" class="c-mode-badge c-mode-badge--home">{{ mode }}</div>
<div class="c-status-capsule"> <div class="c-status-capsule">
<span>色带: <b>{{ status.ribbonType }}</b></span> <span>{{ t('header.ribbon') }}: <b>{{ status.ribbonType }}</b></span>
<span <span>{{ t('header.ribbonAmount') }}: <b>{{ status.ribbonAmount }}</b></span>
>状态: <b :class="statusTone">{{ status.statusText }}</b></span <span>{{ t('header.status') }}: <b :class="statusTone">{{ displayStatusText }}</b></span>
> <span>{{ t('header.serialNo') }}: <b>{{ status.serialNo }}</b></span>
<span>序列号: <b>{{ status.serialNo }}</b></span>
<span>已发行: <b>{{ status.printedCount }}</b></span>
</div> </div>
</div> </div>
<div class="c-header__actions-slot"> <div class="c-header__actions-slot">
<div class="c-header-actions"> <div class="c-header-actions">
<select
class="c-lang-select"
:value="currentLocale"
@change="onLocaleChange"
:title="t('common.select')"
>
<option v-for="opt in LOCALE_OPTIONS" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<slot /> <slot />
</div> </div>
</div> </div>
@@ -24,16 +32,64 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { LOCALE_OPTIONS, persistLocale, type AppLocale } from '@/i18n'
defineProps<{ mode?: string }>() defineProps<{ mode?: string }>()
const configStore = useConfigStore() const configStore = useConfigStore()
const status = computed(() => configStore.printer) const status = computed(() => configStore.printer)
const { t, locale } = useI18n()
const currentLocale = computed(() => locale.value)
function onLocaleChange(e: Event): void {
const val = (e.target as HTMLSelectElement).value as AppLocale
locale.value = val
persistLocale(val)
}
// 打印机状态文本来自主进程(中文),在前端按已知值做多语言映射
const PRINTER_STATUS_MAP: Record<string, string> = {
空闲: 'printerStatus.idle',
忙碌: 'printerStatus.busy',
正在打印: 'printerStatus.printing',
未连接打印机: 'printerStatus.notConnected',
未初始化: 'printerStatus.notInitialized',
初始化失败: 'printerStatus.initFailed',
就绪: 'printerStatus.ready'
}
const displayStatusText = computed(() => {
const raw = status.value.statusText
const key = PRINTER_STATUS_MAP[raw]
return key ? t(key) : raw
})
const statusTone = computed(() => { const statusTone = computed(() => {
const t = status.value.statusText const toneMap: Record<string, string> = {
if (t.includes('未初始化') || t.includes('未连接')) return 'c-status-warn' [t('printerStatus.notInitialized')]: 'c-status-warn',
return '' [t('printerStatus.notConnected')]: 'c-status-warn',
[t('printerStatus.initFailed')]: 'c-status-warn'
}
return toneMap[displayStatusText.value] || ''
}) })
</script> </script>
<style scoped>
.c-lang-select {
height: 28px;
font-size: 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
color: #303133;
cursor: pointer;
padding: 0 6px;
outline: none;
}
.c-lang-select:hover {
border-color: #409eff;
}
</style>
@@ -1,246 +0,0 @@
<template>
<div class="m-settings-modal" :class="{ 'is-open': modelValue }" :aria-hidden="!modelValue">
<div class="m-settings-modal__backdrop" />
<section
class="m-settings-modal__dialog"
role="dialog"
aria-modal="true"
aria-labelledby="settingsModalTitle"
@click.stop
>
<header class="m-settings-modal__header">
<h2 id="settingsModalTitle">设置</h2>
<button
type="button"
class="m-settings-modal__close"
aria-label="关闭"
@click="close"
>
<AppIcon name="times" size="sm" />
</button>
</header>
<div class="m-settings-modal__body">
<section class="m-settings-group">
<h3 class="m-settings-group__title">系统初始化</h3>
<div class="m-settings-group__panel m-settings-init">
<div class="m-settings-init__field">
<label for="settingSharedDir">任务目录 (shared_dir)</label>
<input
id="settingSharedDir"
v-model="sharedDir"
type="text"
class="c-input m-settings-shared-dir"
/>
</div>
<p v-if="appStore.initError" class="m-settings-init-error">{{ appStore.initError }}</p>
<button type="button" class="c-button-cs m-settings-init__btn" @click="onRetryInit">
重试 Init
</button>
<p class="m-settings-init-hint">每台进程仅可 Init 一次更换目录或重连打印机请重启应用</p>
</div>
</section>
<section class="m-settings-group">
<h3 class="m-settings-group__title">基础配置</h3>
<div class="m-settings-group__panel">
<div class="m-settings-row">
<label for="settingPriority">优先级</label>
<AppSelect
id="settingPriority"
v-model="form.priority"
block
:items="PRIORITY_OPTIONS"
/>
<label for="settingRibbonType">色带类型</label>
<AppSelect
id="settingRibbonType"
v-model="form.ribbonType"
block
:items="RIBBON_TYPE_OPTIONS"
/>
</div>
<div class="m-settings-row m-settings-row--format">
<label for="settingCopyFormat">拷贝前格式化类型</label>
<AppSelect
id="settingCopyFormat"
v-model="form.formatType"
block
:items="FORMAT_TYPE_OPTIONS"
/>
</div>
</div>
</section>
<section class="m-settings-group m-settings-group--advanced">
<h3 class="m-settings-group__title">高级选项</h3>
<div class="m-settings-group__panel">
<div class="m-settings-options">
<label class="m-settings-check">
<input v-model="form.generateIso" type="checkbox" />
<span>拷贝前生成 iso</span>
</label>
<label class="m-settings-check">
<input v-model="form.printCmdToHasi" type="checkbox" />
<span>打印 cmd hASI 字段</span>
</label>
<label class="m-settings-check">
<input v-model="form.generateZip" type="checkbox" />
<span>生成 zip</span>
</label>
<label class="m-settings-check">
<input v-model="form.presetCopy" type="checkbox" />
<span>预设内容拷贝</span>
</label>
<label class="m-settings-check">
<input v-model="form.generateHasi" type="checkbox" />
<span>生成 hASI 文件</span>
</label>
<label class="m-settings-check">
<input v-model="form.dongleCountCheck" type="checkbox" />
<span>加密狗计数</span>
</label>
<label class="m-settings-check">
<input v-model="form.failPrintLabel" type="checkbox" />
<span>失败打印标签</span>
</label>
</div>
</div>
</section>
</div>
<footer class="m-settings-modal__footer">
<button type="button" class="m-settings-modal__cancel" @click="close">
<AppIcon name="times" size="sm" />
<span>取消</span>
</button>
<button type="button" class="c-button-cs m-settings-modal__confirm" @click="onConfirm">
确定
</button>
</footer>
</section>
</div>
</template>
<script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import AppSelect from '@/components/AppSelect.vue'
import {
FORMAT_TYPE_OPTIONS,
PRIORITY_OPTIONS,
RIBBON_TYPE_OPTIONS
} from '@/constants/selectOptions'
import { notify } from '@/composables/useNotify'
import { useAppBootstrap } from '@/composables/useAppBootstrap'
import { configGet, configSet } from '@/api/cardsoon'
import { useAppStore } from '@/stores/app'
import { useConfigStore } from '@/stores/config'
import { useDistributeFormStore } from '@/stores/distributeForm'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
const form = useDistributeFormStore()
const appStore = useAppStore()
const configStore = useConfigStore()
const { retryInit } = useAppBootstrap()
const sharedDir = ref(configStore.sharedDir)
function close(): void {
emit('update:modelValue', false)
}
async function saveSettings(): Promise<void> {
const dir = sharedDir.value.trim()
if (!dir) return
configStore.setSharedDir(dir)
await configSet({ sharedDir: dir })
}
async function onConfirm(): Promise<void> {
await saveSettings()
close()
}
async function onRetryInit(): Promise<void> {
await saveSettings()
if (appStore.initialized) {
notify.info('系统已初始化,无需重复 Init')
return
}
await retryInit()
if (appStore.initialized) notify.success('Init 成功,系统已就绪')
else notify.error(appStore.initError || 'Init 失败')
}
function onKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') close()
}
watch(
() => props.modelValue,
async (open) => {
if (open) {
sharedDir.value = configStore.sharedDir
const cfg = await configGet()
if (cfg.data?.sharedDir) sharedDir.value = cfg.data.sharedDir
window.addEventListener('keydown', onKeydown)
} else {
window.removeEventListener('keydown', onKeydown)
}
}
)
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</script>
<style src="@/styles/pages/page4.css"></style>
<style scoped>
.m-settings-init {
display: flex;
flex-direction: column;
gap: 6px;
}
.m-settings-init__field {
display: flex;
flex-direction: column;
gap: 3px;
}
.m-settings-init__field label {
font-size: 10px;
color: #333;
}
.m-settings-shared-dir {
width: 100%;
min-width: 0;
height: 20px;
font-size: 10px;
}
.m-settings-init__btn {
align-self: flex-start;
height: 24px;
font-size: 12px;
padding: 0 10px;
}
.m-settings-row--format {
grid-template-columns: 120px 1fr;
}
.m-settings-init-error {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: #dc3545;
font-weight: 600;
}
.m-settings-init-hint {
margin: 0;
font-size: 10px;
color: #6c757d;
line-height: 1.3;
}
</style>
@@ -0,0 +1,285 @@
<template>
<Teleport to="body">
<transition name="npd-fade">
<div v-if="visible" class="npd-mask" @mousedown.self="onCancel">
<div class="npd-dialog" role="dialog" aria-modal="true" aria-labelledby="npd-title">
<div class="npd-header">
<span id="npd-title" class="npd-title">{{ t('networkDialog.title') }}</span>
<button type="button" class="npd-close" :aria-label="t('common.close')" @click="onCancel">×</button>
</div>
<div class="npd-body">
<p class="npd-hint">{{ t('networkDialog.hint') }}</p>
<label class="npd-field">
<span class="npd-label">{{ t('networkDialog.host') }}<span class="npd-req">*</span></span>
<input
v-model.trim="host"
type="text"
class="c-input"
:placeholder="t('networkDialog.hostPlaceholder')"
:class="{ 'is-invalid': touched && !hostValid }"
@blur="touched = true"
/>
</label>
<label class="npd-field">
<span class="npd-label">{{ t('networkDialog.share') }}</span>
<input
v-model.trim="share"
type="text"
class="c-input"
:placeholder="t('networkDialog.sharePlaceholder')"
/>
</label>
<label class="npd-field">
<span class="npd-label">{{ t('networkDialog.userName') }}<span class="npd-req">*</span></span>
<input
v-model.trim="userName"
type="text"
class="c-input"
:placeholder="t('networkDialog.userNamePlaceholder')"
:class="{ 'is-invalid': touched && !userNameValid }"
@blur="touched = true"
/>
</label>
<label class="npd-field">
<span class="npd-label">{{ t('networkDialog.password') }}<span class="npd-req">*</span></span>
<input
v-model="password"
type="password"
class="c-input"
:placeholder="t('networkDialog.errPassword')"
:class="{ 'is-invalid': touched && !passwordValid }"
@blur="touched = true"
/>
</label>
<div v-if="previewUrl" class="npd-preview">
<span class="npd-preview-label">{{ t('networkDialog.targetUNC') }}</span>
<code class="npd-preview-path">{{ previewUrl }}</code>
</div>
<p v-if="touched && errorText" class="npd-error">{{ errorText }}</p>
</div>
<div class="npd-footer">
<button type="button" class="c-button-cs" @click="onCancel">{{ t('common.cancel') }}</button>
<button type="button" class="c-button-cs npd-primary" :disabled="!canConfirm" @click="onConfirm">
{{ t('common.confirm') }}
</button>
</div>
</div>
</div>
</transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { buildNetworkUrl } from '@shared/network-host'
import { useNetworkAuthStore } from '@/stores/networkAuth'
const props = defineProps<{
visible: boolean
initialHost?: string
initialShare?: string
}>()
const emit = defineEmits<{
'update:visible': [boolean]
confirm: [{ path: string; hostName: string; userName: string; password: string }]
}>()
const netStore = useNetworkAuthStore()
const { t } = useI18n()
const host = ref('')
const share = ref('')
const userName = ref('')
const password = ref('')
const touched = ref(false)
const hostRe = /^[A-Za-z0-9_.-]+$/
const hostValid = computed(() => hostRe.test(host.value))
const userNameValid = computed(() => userName.value.length > 0)
const passwordValid = computed(() => password.value.length > 0)
const canConfirm = computed(() => hostValid.value && userNameValid.value && passwordValid.value)
const previewUrl = computed(() => (hostValid.value ? buildNetworkUrl(host.value, share.value) : ''))
const errorText = computed(() => {
if (!hostValid.value) return t('networkDialog.errHostInvalid')
if (!userNameValid.value) return t('networkDialog.errUserName')
if (!passwordValid.value) return t('networkDialog.errPassword')
return ''
})
function reset(): void {
host.value = ''
share.value = ''
userName.value = ''
password.value = ''
touched.value = false
}
function onCancel(): void {
emit('update:visible', false)
}
function onConfirm(): void {
touched.value = true
if (!canConfirm.value) return
const path = buildNetworkUrl(host.value, share.value)
emit('confirm', {
path,
hostName: host.value,
userName: userName.value,
password: password.value
})
reset()
emit('update:visible', false)
}
watch(
() => props.visible,
(v) => {
if (v) {
reset()
if (props.initialHost?.trim()) host.value = props.initialHost.trim()
if (props.initialShare?.trim()) share.value = props.initialShare.trim()
const storedHost = host.value
if (storedHost) {
const cred = netStore.getCredential(storedHost)
if (cred) {
userName.value = cred.userName
password.value = cred.password
}
}
}
}
)
</script>
<style scoped>
.npd-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 9000;
}
.npd-dialog {
width: 460px;
max-width: 92vw;
background: #fff;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
overflow: hidden;
}
.npd-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #ebeef5;
}
.npd-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.npd-close {
border: none;
background: transparent;
font-size: 20px;
line-height: 1;
color: #909399;
cursor: pointer;
padding: 0 4px;
}
.npd-close:hover {
color: #409eff;
}
.npd-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.npd-hint {
margin: 0;
font-size: 12px;
color: #909399;
}
.npd-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.npd-label {
font-size: 12px;
color: #606266;
}
.npd-req {
color: #f56c6c;
margin-left: 2px;
}
.is-invalid {
border-color: #f56c6c !important;
}
.npd-preview {
display: flex;
align-items: center;
gap: 6px;
background: #f0f9ff;
padding: 6px 10px;
border-radius: 4px;
}
.npd-preview-label {
font-size: 12px;
color: #606266;
}
.npd-preview-path {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
color: #409eff;
word-break: break-all;
}
.npd-error {
margin: 0;
font-size: 12px;
color: #f56c6c;
}
.npd-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid #ebeef5;
}
.npd-primary {
background: #409eff;
color: #fff;
border-color: #409eff;
}
.npd-primary[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
.npd-fade-enter-active,
.npd-fade-leave-active {
transition: opacity 0.15s ease;
}
.npd-fade-enter-from,
.npd-fade-leave-to {
opacity: 0;
}
</style>
@@ -12,6 +12,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -23,20 +24,22 @@ const props = withDefaults(
{ activeStep: 2, mode: 'running', variant: 'distribute' } { activeStep: 2, mode: 'running', variant: 'distribute' }
) )
const distributeSteps = [ const { t } = useI18n()
{ key: 'prep', label: '任务准备' },
{ key: 'copy', label: '拷贝数据' },
{ key: 'print', label: '打印卡片' },
{ key: 'done', label: '完成' }
] as const
const collectSteps = [ const distributeSteps = computed(() => [
{ key: 'prep', label: '任务准备' }, { key: 'prep', label: t('workflow.taskPrep') },
{ key: 'copy', label: '拷贝数据' }, { key: 'copy', label: t('workflow.copyData') },
{ key: 'done', label: '完成' } { key: 'print', label: t('workflow.printCard') },
] as const { key: 'done', label: t('workflow.complete') }
])
const steps = computed(() => (props.variant === 'collect' ? collectSteps : distributeSteps)) const collectSteps = computed(() => [
{ key: 'prep', label: t('workflow.taskPrep') },
{ key: 'copy', label: t('workflow.copyData') },
{ key: 'done', label: t('workflow.complete') }
])
const steps = computed(() => (props.variant === 'collect' ? collectSteps.value : distributeSteps.value))
const failedStep = computed(() => props.failedStep ?? (props.mode === 'failed' ? 3 : -1)) const failedStep = computed(() => props.failedStep ?? (props.mode === 'failed' ? 3 : -1))
@@ -1,55 +1,39 @@
import { onMounted } from 'vue' import { onMounted } from 'vue'
import i18n from '@/i18n'
import { notify } from '@/composables/useNotify' import { notify } from '@/composables/useNotify'
import { import { refreshPrinterAfterInit } from '@/composables/usePrinterStatus'
configGet, import { configGet, dllInit, dllRejectAvailable } from '@/api/cardsoon'
dllInit,
dllPrinterInfo,
dllRejectAvailable,
parsePrinterInfo
} from '@/api/cardsoon'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
function t(key: string): string {
return i18n.global.t(key)
}
let bootstrapped = false let bootstrapped = false
export function useAppBootstrap(): { function placeholderStatus(configStore: ReturnType<typeof useConfigStore>, text: string): void {
retryInit: () => Promise<void> if (configStore.printer.statusText === '—') {
refreshHeader: () => Promise<void> configStore.setPrinter({ ...configStore.printer, statusText: text })
} { }
}
export function useAppBootstrap(): void {
const appStore = useAppStore() const appStore = useAppStore()
const configStore = useConfigStore() const configStore = useConfigStore()
async function refreshHeader(): Promise<void> { async function hydrateFromConfig() {
const info = await dllPrinterInfo() const cfg = await configGet()
if (info.ok && info.data) { if (cfg.ok && cfg.data?.lastPrinterStatus) {
configStore.setPrinter(parsePrinterInfo(info.data)) configStore.setPrinter(cfg.data.lastPrinterStatus)
return
} }
configStore.setPrinter({ ...configStore.printer, statusText: '未连接打印机' }) if (cfg.ok && cfg.data?.sharedDir) {
configStore.setSharedDir(cfg.data.sharedDir)
}
return cfg
} }
async function doInit(): Promise<void> { async function syncRejectApi(): Promise<void> {
const cfg = await configGet()
const sharedDir = cfg.data?.sharedDir || ''
configStore.setSharedDir(sharedDir)
if (import.meta.env.DEV && cfg.data?.skipDllInit === true) {
appStore.setInitialized(false, '开发模式已跳过 DLL 初始化')
configStore.setPrinter({ ...configStore.printer, statusText: '未初始化(开发)' })
return
}
const init = await dllInit({ sharedDir })
if (!init.ok) {
appStore.setInitialized(false, init.message || 'Init 失败')
configStore.setPrinter({ ...configStore.printer, statusText: '未初始化' })
notify.error(init.message || '初始化失败,请检查任务目录权限')
return
}
appStore.setInitialized(true)
configStore.setPrinter({ ...configStore.printer, statusText: '系统已初始化' })
const warn = (init.data as { warning?: string } | undefined)?.warning
if (warn) notify.warning(warn)
try { try {
const rej = await dllRejectAvailable() const rej = await dllRejectAvailable()
if (rej.ok && rej.data) configStore.rejectApiAvailable = rej.data.available if (rej.ok && rej.data) configStore.rejectApiAvailable = rej.data.available
@@ -58,13 +42,39 @@ export function useAppBootstrap(): {
} }
} }
async function bootstrap(): Promise<void> {
const cfg = await hydrateFromConfig()
if (cfg.ok && cfg.data?.dllInitialized) {
appStore.setInitialized(true)
placeholderStatus(configStore, t('printerStatus.ready'))
await syncRejectApi()
window.setTimeout(() => void refreshPrinterAfterInit(), 1500)
return
}
const sharedDir = cfg.data?.sharedDir || ''
configStore.setSharedDir(sharedDir)
const init = await dllInit({ sharedDir })
if (!init.ok) {
appStore.setInitialized(false, init.message || t('printerStatus.initFailed'))
configStore.setPrinter({ ...configStore.printer, statusText: t('printerStatus.initFailed') })
notify.error(init.message || t('notify.initFailedHint'))
return
}
appStore.setInitialized(true)
const initMeta = init.data as { warning?: string } | undefined
if (initMeta?.warning) notify.warning(initMeta.warning)
placeholderStatus(configStore, initMeta?.warning ? t('printerStatus.notConnected') : t('printerStatus.ready'))
await syncRejectApi()
window.setTimeout(() => void refreshPrinterAfterInit(), 1500)
}
onMounted(() => { onMounted(() => {
if (bootstrapped) return if (bootstrapped) return
bootstrapped = true bootstrapped = true
window.setTimeout(() => { window.setTimeout(() => {
void doInit() void bootstrap()
}, 300) }, 100)
}) })
return { retryInit: doInit, refreshHeader }
} }
+10 -3
View File
@@ -1,5 +1,10 @@
import i18n from '@/i18n'
import { useToastStore, type ToastType } from '@/stores/toast' import { useToastStore, type ToastType } from '@/stores/toast'
function t(key: string, named?: Record<string, unknown>): string {
return i18n.global.t(key, named ?? {})
}
function push(type: ToastType, message: string, durationMs = 4500): void { function push(type: ToastType, message: string, durationMs = 4500): void {
useToastStore().push(type, message, durationMs) useToastStore().push(type, message, durationMs)
} }
@@ -11,9 +16,11 @@ export const notify = {
info: (message: string, durationMs?: number) => push('info', message, durationMs) info: (message: string, durationMs?: number) => push('info', message, durationMs)
} }
const INIT_HINT = '系统未初始化,请进入「数据分发 → 设置」重试 Init'
/** 未初始化等业务拦截时的统一提示 */ /** 未初始化等业务拦截时的统一提示 */
export function notifyRequireInit(action?: string): void { export function notifyRequireInit(action?: string): void {
notify.warning(action ? `系统未初始化,无法${action}` : INIT_HINT) if (action) {
notify.warning(t('notify.notReadyAction', { action }))
} else {
notify.warning(t('notify.notReady'))
}
} }
@@ -0,0 +1,57 @@
import { dllPrinterInfo, dllPrinterStatus, parsePrinterInfo } from '@/api/cardsoon'
import { useConfigStore } from '@/stores/config'
/** 状态是否表示打印机未连接/不可用 */
function isDisconnectedStatus(text: string): boolean {
return !text || text === '—' || text.includes('未连接') || text.includes('Not connected')
}
let prevStatusText = ''
export async function refreshLiveStatus(): Promise<void> {
const store = useConfigStore()
try {
const r = await dllPrinterStatus()
if (r.ok && r.data?.statusText) {
const newStatus = r.data.statusText
// 检测状态变化:从未连接 → 已连接时,立即刷新打印机信息(型号/isSingleSide等)
const wasDisconnected = isDisconnectedStatus(prevStatusText)
const nowConnected = !isDisconnectedStatus(newStatus)
store.setPrinter({ ...store.printer, statusText: newStatus })
if (wasDisconnected && nowConnected) {
// 打印机刚连上,立即拉取型号/色带等信息
void refreshPrinterInfo()
}
prevStatusText = newStatus
}
} catch {
/* ignore */
}
}
export async function refreshPrinterInfo(): Promise<void> {
const store = useConfigStore()
try {
const info = await dllPrinterInfo()
if (!info.ok || !info.data) return
const data = info.data as Record<string, unknown>
const parsed = data.snapshot
? (data.snapshot as typeof store.printer)
: parsePrinterInfo(data)
store.setPrinter({
...store.printer,
ribbonType: parsed.ribbonType,
ribbonAmount: parsed.ribbonAmount,
serialNo: parsed.serialNo,
printerName: parsed.printerName,
isSingleSide: parsed.isSingleSide
})
} catch {
/* ignore */
}
}
export async function refreshPrinterAfterInit(): Promise<void> {
await refreshPrinterInfo()
await refreshLiveStatus()
}
@@ -0,0 +1,86 @@
import { onMounted, onUnmounted, watch } from 'vue'
import { useAppStore } from '@/stores/app'
import { refreshLiveStatus, refreshPrinterInfo } from '@/composables/usePrinterStatus'
const STATUS_ACTIVE_MS = 1500
const STATUS_HIDDEN_MS = 8000
const INFO_MS = 30000
let statusTimer: ReturnType<typeof setInterval> | null = null
let infoTimer: ReturnType<typeof setInterval> | null = null
let statusInFlight = false
let infoInFlight = false
let mountedCount = 0
function statusIntervalMs(): number {
if (typeof document !== 'undefined' && document.hidden) return STATUS_HIDDEN_MS
return STATUS_ACTIVE_MS
}
async function tickStatus(): Promise<void> {
if (statusInFlight) return
statusInFlight = true
try {
await refreshLiveStatus()
} finally {
statusInFlight = false
}
}
async function tickInfo(): Promise<void> {
if (infoInFlight) return
infoInFlight = true
try {
await refreshPrinterInfo()
} finally {
infoInFlight = false
}
}
function clearTimers(): void {
if (statusTimer) {
clearInterval(statusTimer)
statusTimer = null
}
if (infoTimer) {
clearInterval(infoTimer)
infoTimer = null
}
}
function startTimers(): void {
clearTimers()
statusTimer = setInterval(() => void tickStatus(), statusIntervalMs())
infoTimer = setInterval(() => void tickInfo(), INFO_MS)
void tickStatus()
void tickInfo()
}
function onVisibilityChange(): void {
if (mountedCount <= 0) return
startTimers()
}
export function usePrinterStatusPoll(): void {
const appStore = useAppStore()
onMounted(() => {
mountedCount += 1
document.addEventListener('visibilitychange', onVisibilityChange)
if (appStore.initialized) startTimers()
})
onUnmounted(() => {
mountedCount = Math.max(0, mountedCount - 1)
document.removeEventListener('visibilitychange', onVisibilityChange)
if (mountedCount === 0) clearTimers()
})
watch(
() => appStore.initialized,
(ready) => {
if (ready && mountedCount > 0) startTimers()
else if (!ready) clearTimers()
}
)
}
+32 -19
View File
@@ -1,27 +1,40 @@
import type { ComposerTranslation } from 'vue-i18n'
export interface SelectOption { export interface SelectOption {
label: string label: string
value: string | number value: string | number
} }
export const COPY_TYPE_OPTIONS: SelectOption[] = [ type T = ComposerTranslation
{ label: '文件拷贝', value: 0 },
{ label: '镜像刻录', value: 1 }
]
export const FORMAT_TYPE_OPTIONS: SelectOption[] = [ export function copyTypeOptions(t: T): SelectOption[] {
{ label: '不格式化', value: 'none' }, return [
{ label: '快速格式化', value: 'fat' }, { label: t('copyType.fileCopy'), value: 0 },
{ label: '完全格式化', value: 'ntfs' } { label: t('copyType.imageBurn'), value: 1 }
] ]
}
export const PRIORITY_OPTIONS: SelectOption[] = [ export function formatTypeOptions(t: T): SelectOption[] {
{ label: '低', value: 'low' }, return [
{ label: '中', value: 'mid' }, { label: t('formatType.none'), value: 'none' },
{ label: '高', value: 'high' } { label: t('formatType.fat32'), value: 'fat32' },
] { label: t('formatType.exfat'), value: 'exfat' },
{ label: t('formatType.ntfs'), value: 'ntfs' }
]
}
export const RIBBON_TYPE_OPTIONS: SelectOption[] = [ export function priorityOptions(t: T): SelectOption[] {
{ label: '任何', value: 'any' }, return [
{ label: 'YMCKO', value: 'YMCKO' }, { label: t('common.low'), value: 'low' },
{ label: 'YMCK', value: 'YMCK' } { label: t('common.mid'), value: 'mid' },
] { label: t('common.high'), value: 'high' }
]
}
export function ribbonTypeOptions(t: T): SelectOption[] {
return [
{ label: t('common.any'), value: 'any' },
{ label: 'YMCKO', value: 'YMCKO' },
{ label: 'YMCK', value: 'YMCK' }
]
}
+2
View File
@@ -14,6 +14,8 @@ interface CardsoonApi {
} }
declare global { declare global {
/** 构建时由 electron.vite.config.ts 从 package.json version 注入 */
const __APP_VERSION__: string
interface Window { interface Window {
cardsoonApi: CardsoonApi cardsoonApi: CardsoonApi
} }
+45
View File
@@ -0,0 +1,45 @@
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN'
import enUS from './locales/en-US'
import zhTW from './locales/zh-TW'
export type AppLocale = 'zh-CN' | 'en-US' | 'zh-TW'
export const LOCALE_OPTIONS: { value: AppLocale; label: string }[] = [
{ value: 'zh-CN', label: '中文' },
{ value: 'en-US', label: 'English' },
{ value: 'zh-TW', label: '繁體中文' }
]
const STORAGE_KEY = 'app-locale'
function detectInitialLocale(): AppLocale {
try {
const saved = localStorage.getItem(STORAGE_KEY) as AppLocale | null
if (saved && LOCALE_OPTIONS.some((o) => o.value === saved)) return saved
} catch {
/* localStorage may be unavailable */
}
return 'zh-CN'
}
export function persistLocale(locale: AppLocale): void {
try {
localStorage.setItem(STORAGE_KEY, locale)
} catch {
/* ignore */
}
}
const i18n = createI18n({
legacy: false,
locale: detectInitialLocale(),
fallbackLocale: 'zh-CN',
messages: {
'zh-CN': zhCN,
'en-US': enUS,
'zh-TW': zhTW
}
})
export default i18n
+199
View File
@@ -0,0 +1,199 @@
export default {
common: {
cancel: 'Cancel',
confirm: 'OK',
close: 'Close',
addPath: 'Add Path',
clear: 'Clear',
submit: 'Submit',
home: 'Home',
stop: 'Stop',
back: 'Back',
reset: 'Reset',
select: 'Select',
resetPrinter: 'Reset Printer',
discardCard: 'Discard Card',
templateDesign: 'Template Design',
low: 'Low',
mid: 'Medium',
high: 'High',
any: 'Any'
},
header: {
modeHome: 'CardSoon Data Card Printer',
modeDistribute: 'Data Distribution Mode',
modeCollect: 'Data Collection Mode',
ribbon: 'Ribbon',
ribbonAmount: 'Remaining',
status: 'Status',
serialNo: 'Serial No.'
},
footer: {
version: 'Version v{version}',
website: 'www.cardsoon.com',
copyright: 'Copyright © 2026 CardSoon Technology'
},
home: {
toolsTitle: 'Tools',
tasksTitle: 'Tasks',
readCard: 'Read',
ejectCard: 'Eject',
dataDistribute: 'Data Distribution',
dataDistributeDesc: 'Distribute data onto cards',
dataCollect: 'Data Collection',
dataCollectDesc: 'Collect data from cards'
},
distributeConfig: {
pathConfig: 'Path Configuration',
addNetworkLocation: 'Add Network Location',
pathHint: 'The system will copy all items under this directory, excluding the folder itself',
netCredConfigured: 'Network credentials configured: {hosts}',
volumeLabel: 'Volume Label',
copyType: 'Copy Type',
formatType: 'Format Type',
dongle: 'Dongle',
dongleHint: '(101 = unlimited)',
dongleAuthLabel: 'Auth Code',
donglePassword: 'Enter authorization code',
showAuth: 'Show auth code',
hideAuth: 'Hide auth code',
templatePreview: 'Label Preview',
addLabel: 'Add Label',
removeLabel: 'Remove',
templateRemoved: 'Label removed',
doubleSide: 'Double-sided',
singleSideHint: 'Single-side printer detected, please select a side (front or back)',
frontSide: 'Front',
backSide: 'Back',
imageFieldNotSelected: 'No image selected',
templateLoaded: 'Label template loaded',
templateNoPreview: 'Template opened, but no previewable content found',
cleared: 'Cleared, restored to initial state',
netCredSaved: 'Network credentials saved',
loadProgress: 'Loaded: {loaded} GB / {total} GB ({percent}%)',
calculating: 'Calculating…',
sizeUnknown: 'Size unknown',
pathInvalid: 'Invalid path',
pathReady: '{size} · Pending copy',
pathPattern: '{dir}\\*.*'
},
copyType: {
fileCopy: 'File Copy',
imageBurn: 'Image Burn'
},
formatType: {
none: 'No Format',
fat32: 'Fat32',
exfat: 'exFat',
ntfs: 'NTFS'
},
dataCollect: {
importPath: 'Data Import Path',
cardOutput: 'Card Output Direction',
forwardOutput: 'Forward Output',
backwardOutput: 'Backward Output',
noDirSelected: 'No directory selected'
},
running: {
taskFailed: 'Task Failed',
taskCompleted: 'Task Completed',
collectCompleted: 'Collection Completed',
dataCollecting: 'Collecting Data',
taskRunning: 'Task Running',
waitingCard: 'Waiting for Card',
failedSubCollect: 'Please check the reader and card, insert a backup card to retry',
failedSubDistribute: 'Please check device issues, insert a backup card to retry',
completedSubCollect: 'Click Back, or insert a backup card to continue',
completedSubDistribute: 'Click Back, or insert a backup card to start next',
collectingHint: 'Reading from card and writing to import directory',
waitCardHint: 'Please insert a data card, the task will run automatically',
runningHint: 'Task in progress, please wait',
progressCounter: 'Task completed {success} times, with {fail} failures.'
},
networkDialog: {
title: 'Add Network Location',
hint: 'Credentials are used to access mapped drives or network shares. Please confirm the host is accessible and the account is valid.',
host: 'Host (IP or Hostname)',
share: 'Share Name (optional)',
userName: 'Username',
password: 'Password',
targetUNC: 'Target UNC',
hostPlaceholder: 'e.g. 192.168.1.100 or nas-server',
sharePlaceholder: 'e.g. share, leave empty to mount root only',
userNamePlaceholder: 'e.g. admin',
errHostInvalid: 'Invalid hostname/IP',
errUserName: 'Please enter username',
errPassword: 'Please enter password'
},
workflow: {
taskPrep: 'Task Preparation',
copyData: 'Copy Data',
printCard: 'Print Card',
complete: 'Complete'
},
notify: {
resetSent: 'Reset command sent',
resetFailed: 'Reset failed',
rejectUnavailable: 'Discard card API not supported in current environment',
cardRejected: 'Card discarded',
readCardSent: 'Card moved to reader',
readCardFailed: 'Read card failed',
ejectCardSent: 'Card ejected to outlet',
ejectCardFailed: 'Eject card failed',
operationFailed: 'Operation failed',
designStarted: 'Design app started',
designFailed: 'Failed to open design app, please check cardsoon.config.json',
designCancelled: 'Failed to open design app, may have been cancelled or requires admin privileges',
notReady: 'System not ready, please restart the app or check printer and task directory',
notReadyAction: 'System not ready, cannot {action}',
stopDistribute: 'Please stop the data distribution task first',
usbCollecting: 'USB collection in progress, please wait',
usbCollectInProgress: 'Data collection in progress, please stop it on the task page first',
usbCollectComplete: 'USB collection completed',
pathOpenFailed: 'Failed to open directory picker',
templateOpenFailed: 'Failed to open template picker',
templateParseFailed: 'Template parsing failed',
dirInvalid: 'Import directory invalid, cannot continue',
resubmitCollectFailed: 'Failed to resubmit collection task',
resubmitFailed: 'Resubmit failed',
pollStartFailed: 'Failed to start task polling',
pollMonitorFailed: 'Cannot monitor card position, please click Back or resubmit manually',
queryJobFailed: 'Query task failed: {code}',
queryUsbFailed: 'Query USB task failed: {code}',
cancelIssue: 'Issue occurred while cancelling task',
selectDirFirst: 'Please select a data import directory first',
selectDirFirstShort: 'Please select a data import directory first',
startUsbFailed: 'Failed to start USB collection',
usbWaitComplete: 'Please wait for USB collection to complete',
cannotEnterRunning: 'Cannot enter running page, task cancelled',
initFailedHint: 'Initialization failed, please check task directory permissions'
},
validation: {
noTask: 'Please configure a copy path or print template',
invalidTemplate: 'Please select a .soon template',
pathEmpty: 'Path cannot be empty',
pathNotExist: 'Path does not exist: {paths}',
noFilesToCopy: 'No files to copy under the path',
netCredMissing: 'Please configure network location credentials first: {host}',
templateNotExist: 'Template file does not exist',
printFlagMismatch: 'Print side mismatch, please reselect',
singleSideMustPick: 'Single-side printer, please select front or back',
pickSideRequired: 'Please select a print side (front or back) before submitting',
printerNotConnected: 'Printer not connected, please connect the printer first',
printerNotReady: 'Printer not connected or in fault (e.g. card jam). Please fix the device before submitting',
dongleAuthRequired: 'Please enter authorization code',
dongleCountInvalid: 'Dongle count must be 0 or 1-101',
createJobFailed: 'Failed to create task',
csvGenFailed: 'Failed to generate print variable CSV'
},
printerStatus: {
idle: 'Idle',
busy: 'Busy',
printing: 'Printing',
notConnected: 'Printer not connected',
notInitialized: 'Not initialized',
initFailed: 'Initialization failed',
ready: 'Ready',
unknown: '—'
}
}
+201
View File
@@ -0,0 +1,201 @@
export default {
common: {
cancel: '取消',
confirm: '确定',
close: '关闭',
addPath: '添加路径',
clear: '清空',
submit: '提交',
home: '首页',
stop: '停止',
back: '返回',
reset: '重置',
select: '选择',
resetPrinter: '重置打印机',
discardCard: '废弃卡片',
templateDesign: '模板设计',
low: '低',
mid: '中',
high: '高',
any: '任何'
},
header: {
modeHome: '卡树数据卡打印机软件',
modeDistribute: '数据分发模式',
modeCollect: '数据收集模式',
ribbon: '色带',
ribbonAmount: '余量',
status: '状态',
serialNo: '序列号'
},
footer: {
version: '版本 v{version}',
website: 'www.cardsoon.com',
copyright: '版权所有 © 2026 卡树科技'
},
home: {
toolsTitle: '工具',
tasksTitle: '任务',
readCard: '读卡',
ejectCard: '退卡',
dataDistribute: '数据分发',
dataDistributeDesc: '分发数据到打印卡片',
dataCollect: '数据收集',
dataCollectDesc: '从卡片收集导入数据'
},
distributeConfig: {
pathConfig: '路径配置',
addNetworkLocation: '添加网络位置',
pathHint: '系统将拷贝该目录下的所有子项,但不包含文件夹本身',
netCredConfigured: '已配置网络凭据:{hosts}',
volumeLabel: '卷标',
copyType: '拷贝类型',
formatType: '格式化类型',
dongle: '加密狗',
dongleHint: '101 为不限次数)',
dongleAuthLabel: '授权码',
donglePassword: '请输入授权码',
showAuth: '显示授权码',
hideAuth: '隐藏授权码',
templatePreview: '标签预览',
addLabel: '添加标签',
removeLabel: '移除标签',
templateRemoved: '已移除标签',
doubleSide: '双面',
singleSideHint: '当前为单面打印机,请选择打印面(正面或背面)',
frontSide: '正面',
backSide: '背面',
imageFieldNotSelected: '未选择图片',
templateLoaded: '已加载标签模板',
templateNoPreview: '模板已打开,但未解析到可预览内容',
cleared: '已清空,已恢复初始状态',
netCredSaved: '网络凭据已保存',
loadProgress: '已加载: {loaded} GB / {total} GB ({percent}%)',
calculating: '计算中…',
sizeUnknown: '大小未知',
pathInvalid: '路径无效',
pathReady: '{size} · 待拷贝',
pathPattern: '{dir}\\*.*'
},
copyType: {
fileCopy: '文件拷贝',
imageBurn: '镜像刻录'
},
formatType: {
none: '不格式化',
fat32: 'Fat32',
exfat: 'exFat',
ntfs: 'NTFS'
},
dataCollect: {
importPath: '数据导入地址',
cardOutput: '出卡方向',
forwardOutput: '向前出卡',
backwardOutput: '向后出卡',
noDirSelected: '未选择目录'
},
running: {
taskFailed: '任务失败',
taskCompleted: '任务已完成',
collectCompleted: '收集已完成',
dataCollecting: '数据收集中',
taskRunning: '任务执行中',
waitingCard: '等待插卡',
failedSubCollect: '请检查读卡器与卡片,插入备卡位可自动重试',
failedSubDistribute: '请检查设备故障,插入备卡位可自动重试',
completedSubCollect: '请点击返回,或插入备卡位继续下一张',
completedSubDistribute: '请点击返回,或插入备卡位自动开始下一张',
collectingHint: '正在从卡片读取并写入导入目录',
waitCardHint: '请插入数据卡,任务将自动执行',
runningHint: '任务执行中,请稍候',
progressCounter: '任务已经完成{success}次,其中失败次数是{fail}。'
},
networkDialog: {
title: '添加网络位置',
hint: '凭据用于访问已添加的映射盘或网络共享路径,请确认主机可访问且账号有效。',
host: '主机(IP 或主机名)',
share: '共享名(可选)',
userName: '用户名',
password: '密码',
targetUNC: '目标 UNC',
hostPlaceholder: '例如 192.168.1.100 或 nas-server',
sharePlaceholder: '例如 share,留空表示只挂载到根',
userNamePlaceholder: '例如 admin',
errHostInvalid: '主机名/IP 不合法',
errUserName: '请输入用户名',
errPassword: '请输入密码'
},
workflow: {
taskPrep: '任务准备',
copyData: '拷贝数据',
printCard: '打印卡片',
complete: '完成'
},
notify: {
resetSent: '已发送重置指令',
resetFailed: '重置失败',
rejectUnavailable: '当前环境不支持废卡接口',
cardRejected: '已废弃卡片',
readCardSent: '已送卡到读卡区',
readCardFailed: '读卡失败',
ejectCardSent: '已退卡到出卡区',
ejectCardFailed: '退卡失败',
operationFailed: '操作失败',
designStarted: '已启动设计软件',
designFailed: '打开设计软件失败,请检查 cardsoon.config.json',
designCancelled: '打开设计软件失败,可能已被取消或需要管理员权限',
notReady: '系统未就绪,请重启应用或检查打印机与任务目录',
notReadyAction: '系统未就绪,无法{action}',
stopDistribute: '请先停止数据分发任务',
usbCollecting: 'USB 收集进行中,请等待完成',
usbCollectInProgress: '数据收集进行中,请先在任务页停止',
usbCollectComplete: 'USB 收集完成',
pathOpenFailed: '打开目录选择失败',
templateOpenFailed: '打开模板选择失败',
templateParseFailed: '模板解析失败',
dirInvalid: '导入目录无效,无法继续',
resubmitCollectFailed: '重新提交收集任务失败',
resubmitFailed: '重新提交失败',
pollStartFailed: '启动任务轮询失败',
pollMonitorFailed: '无法监控卡位,请手动点击返回或重新提交',
queryJobFailed: '查询任务失败: {code}',
queryUsbFailed: '查询 USB 任务失败: {code}',
cancelIssue: '取消任务时出现问题',
selectDirFirst: '请先选择数据导入目录',
selectDirFirstShort: '请先选择数据导入目录',
startUsbFailed: '启动 USB 收集失败',
usbWaitComplete: '请先等待 USB 收集完成',
cannotEnterRunning: '无法进入运行页,已取消任务',
initFailedHint: '初始化失败,请检查任务目录权限',
waitTimeoutCollect: '等待时间较长:请检查 U 盘数据线是否连接、卡片是否插好(可把卡插反后重试)',
waitTimeoutDistribute: '等待进卡时间较长:请检查卡片是否到位、打印机是否夹卡或报错'
},
validation: {
noTask: '请配置拷贝路径或打印模板',
invalidTemplate: '请选择 .soon 模板',
pathEmpty: '路径不能为空',
pathNotExist: '路径不存在: {paths}',
noFilesToCopy: '拷贝路径下没有可拷贝的文件',
netCredMissing: '请先配置网络位置凭据: {host}',
templateNotExist: '模板文件不存在',
printFlagMismatch: '打印面数不匹配,请重新选择',
singleSideMustPick: '当前为单面打印机,请选择正面或背面',
pickSideRequired: '请选择打印面(正面或背面)后再提交',
printerNotConnected: '打印机未连接,请先连接打印机后再提交',
printerNotReady: '打印机未连接或设备异常(如夹卡),请排除故障后再提交',
dongleAuthRequired: '请输入授权码',
dongleCountInvalid: '加密狗次数须为 0 或 1-101',
createJobFailed: '创建任务失败',
csvGenFailed: '生成打印变量 CSV 失败'
},
printerStatus: {
idle: '空闲',
busy: '忙碌',
printing: '正在打印',
notConnected: '未连接打印机',
notInitialized: '未初始化',
initFailed: '初始化失败',
ready: '就绪',
unknown: '—'
}
}
+199
View File
@@ -0,0 +1,199 @@
export default {
common: {
cancel: '取消',
confirm: '確定',
close: '關閉',
addPath: '新增路徑',
clear: '清空',
submit: '提交',
home: '首頁',
stop: '停止',
back: '返回',
reset: '重設',
select: '選擇',
resetPrinter: '重設印表機',
discardCard: '廢棄卡片',
templateDesign: '模板設計',
low: '低',
mid: '中',
high: '高',
any: '任何'
},
header: {
modeHome: '卡樹數據卡印表機軟體',
modeDistribute: '數據分發模式',
modeCollect: '數據收集模式',
ribbon: '色帶',
ribbonAmount: '餘量',
status: '狀態',
serialNo: '序號'
},
footer: {
version: '版本 v{version}',
website: 'www.cardsoon.com',
copyright: '版權所有 © 2026 卡樹科技'
},
home: {
toolsTitle: '工具',
tasksTitle: '任務',
readCard: '讀卡',
ejectCard: '退卡',
dataDistribute: '數據分發',
dataDistributeDesc: '分發數據到列印卡片',
dataCollect: '數據收集',
dataCollectDesc: '從卡片收集匯入數據'
},
distributeConfig: {
pathConfig: '路徑配置',
addNetworkLocation: '新增網路位置',
pathHint: '系統將拷貝該目錄下的所有子項,但不包含資料夾本身',
netCredConfigured: '已配置網路憑證:{hosts}',
volumeLabel: '卷標',
copyType: '拷貝類型',
formatType: '格式化類型',
dongle: '加密狗',
dongleHint: '101 為不限次數)',
dongleAuthLabel: '授權碼',
donglePassword: '請輸入授權碼',
showAuth: '顯示授權碼',
hideAuth: '隱藏授權碼',
templatePreview: '標籤預覽',
addLabel: '新增標籤',
removeLabel: '移除標籤',
templateRemoved: '已移除標籤',
doubleSide: '雙面',
singleSideHint: '當前為單面印表機,請選擇列印面(正面或背面)',
frontSide: '正面',
backSide: '背面',
imageFieldNotSelected: '未選擇圖片',
templateLoaded: '已載入標籤模板',
templateNoPreview: '模板已開啟,但未解析到可預覽內容',
cleared: '已清空,已恢復初始狀態',
netCredSaved: '網路憑證已儲存',
loadProgress: '已載入: {loaded} GB / {total} GB ({percent}%)',
calculating: '計算中…',
sizeUnknown: '大小未知',
pathInvalid: '路徑無效',
pathReady: '{size} · 待拷貝',
pathPattern: '{dir}\\*.*'
},
copyType: {
fileCopy: '檔案拷貝',
imageBurn: '鏡像燒錄'
},
formatType: {
none: '不格式化',
fat32: 'Fat32',
exfat: 'exFat',
ntfs: 'NTFS'
},
dataCollect: {
importPath: '數據匯入地址',
cardOutput: '出卡方向',
forwardOutput: '向前出卡',
backwardOutput: '向後出卡',
noDirSelected: '未選擇目錄'
},
running: {
taskFailed: '任務失敗',
taskCompleted: '任務已完成',
collectCompleted: '收集已完成',
dataCollecting: '數據收集中',
taskRunning: '任務執行中',
waitingCard: '等待插卡',
failedSubCollect: '請檢查讀卡器與卡片,插入備卡位可自動重試',
failedSubDistribute: '請檢查設備故障,插入備卡位可自動重試',
completedSubCollect: '請點擊返回,或插入備卡位繼續下一張',
completedSubDistribute: '請點擊返回,或插入備卡位自動開始下一張',
collectingHint: '正在從卡片讀取並寫入匯入目錄',
waitCardHint: '請插入數據卡,任務將自動執行',
runningHint: '任務執行中,請稍候',
progressCounter: '任務已經完成{success}次,其中失敗次數是{fail}。'
},
networkDialog: {
title: '新增網路位置',
hint: '憑證用於存取已新增的映射盤或網路共享路徑,請確認主機可存取且帳號有效。',
host: '主機(IP 或主機名)',
share: '共享名(可選)',
userName: '使用者名稱',
password: '密碼',
targetUNC: '目標 UNC',
hostPlaceholder: '例如 192.168.1.100 或 nas-server',
sharePlaceholder: '例如 share,留空表示只掛載到根',
userNamePlaceholder: '例如 admin',
errHostInvalid: '主機名/IP 不合法',
errUserName: '請輸入使用者名稱',
errPassword: '請輸入密碼'
},
workflow: {
taskPrep: '任務準備',
copyData: '拷貝數據',
printCard: '列印卡片',
complete: '完成'
},
notify: {
resetSent: '已發送重設指令',
resetFailed: '重設失敗',
rejectUnavailable: '當前環境不支援廢卡介面',
cardRejected: '已廢棄卡片',
readCardSent: '已送卡到讀卡區',
readCardFailed: '讀卡失敗',
ejectCardSent: '已退卡到出卡區',
ejectCardFailed: '退卡失敗',
operationFailed: '操作失敗',
designStarted: '已啟動設計軟體',
designFailed: '開啟設計軟體失敗,請檢查 cardsoon.config.json',
designCancelled: '開啟設計軟體失敗,可能已被取消或需要管理員權限',
notReady: '系統未就緒,請重啟應用或檢查印表機與任務目錄',
notReadyAction: '系統未就緒,無法{action}',
stopDistribute: '請先停止數據分發任務',
usbCollecting: 'USB 收集進行中,請等待完成',
usbCollectInProgress: '數據收集進行中,請先在任務頁停止',
usbCollectComplete: 'USB 收集完成',
pathOpenFailed: '開啟目錄選擇失敗',
templateOpenFailed: '開啟模板選擇失敗',
templateParseFailed: '模板解析失敗',
dirInvalid: '匯入目錄無效,無法繼續',
resubmitCollectFailed: '重新提交收集任務失敗',
resubmitFailed: '重新提交失敗',
pollStartFailed: '啟動任務輪詢失敗',
pollMonitorFailed: '無法監控卡位,請手動點擊返回或重新提交',
queryJobFailed: '查詢任務失敗: {code}',
queryUsbFailed: '查詢 USB 任務失敗: {code}',
cancelIssue: '取消任務時出現問題',
selectDirFirst: '請先選擇數據匯入目錄',
selectDirFirstShort: '請先選擇數據匯入目錄',
startUsbFailed: '啟動 USB 收集失敗',
usbWaitComplete: '請先等待 USB 收集完成',
cannotEnterRunning: '無法進入運行頁,已取消任務',
initFailedHint: '初始化失敗,請檢查任務目錄權限'
},
validation: {
noTask: '請配置拷貝路徑或列印模板',
invalidTemplate: '請選擇 .soon 模板',
pathEmpty: '路徑不能為空',
pathNotExist: '路徑不存在: {paths}',
noFilesToCopy: '拷貝路徑下沒有可拷貝的檔案',
netCredMissing: '請先配置網路位置憑證: {host}',
templateNotExist: '模板檔案不存在',
printFlagMismatch: '列印面數不匹配,請重新選擇',
singleSideMustPick: '當前為單面印表機,請選擇正面或背面',
pickSideRequired: '請選擇列印面(正面或背面)後再提交',
printerNotConnected: '印表機未連接,請先連接印表機後再提交',
printerNotReady: '印表機未連接或設備異常(如夾卡),請排除故障後再提交',
dongleAuthRequired: '請輸入授權碼',
dongleCountInvalid: '加密狗次數須為 0 或 1-101',
createJobFailed: '建立任務失敗',
csvGenFailed: '生成列印變數 CSV 失敗'
},
printerStatus: {
idle: '空閒',
busy: '忙碌',
printing: '正在列印',
notConnected: '未連接印表機',
notInitialized: '未初始化',
initFailed: '初始化失敗',
ready: '就緒',
unknown: '—'
}
}
+107 -4
View File
@@ -1,12 +1,115 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import i18n from '@/i18n'
import { configGet } from '@/api/cardsoon'
import { useNetworkAuthStore } from '@/stores/networkAuth'
import { useDongleAuthStore } from '@/stores/dongleAuth'
import { useDistributeFormStore } from '@/stores/distributeForm'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import './styles/design-base.css' import './styles/design-base.css'
import './styles/icons-font.css' import './styles/icons-font.css'
import './styles/shell.css' import './styles/shell.css'
const app = createApp(App)
app.use(createPinia())
app.use(router) // preload 未注入时(如普通浏览器调试/ preload 加载失败)不再白屏,直接跳过桌面侧能力
app.mount('#app') const hasApi = typeof window.cardsoonApi !== 'undefined'
if (hasApi) {
window.cardsoonApi.on('app:trace', (payload) => {
const p = payload as { level: string; message: string; data?: Record<string, unknown> }
if (p.level === 'error') console.error(p.message, p.data ?? '')
else console.log(p.message, p.data ?? '')
})
}
async function setTrace(on: boolean): Promise<void> {
await window.cardsoonApi.invoke('config:set', { traceEnabled: on })
console.info(`[trace] 控制台日志已${on ? '开启' : '关闭'}`)
}
const w = window as Window & { trace?: (on?: boolean) => Promise<void>; dllTrace?: (on?: boolean) => Promise<void> }
if (hasApi) {
w.trace = async (on = true) => setTrace(on)
w.dllTrace = w.trace
void (async () => {
const cfg = await configGet()
const on = cfg.ok && cfg.data?.traceEnabled === true
console.info(`[trace] 控制台日志: ${on ? '已开启' : '已关闭'},执行 trace(false) 关闭`)
})()
}
/**
* 渲染进程启动崩溃兜底:把白屏变成可见错误面板,
* 便于在无法打开 DevTools 的现场截图定位问题。
* 仅在 Vue 挂载前/挂载瞬间触发的错误会覆盖界面;
* 挂载完成后的运行时错误只进控制台,不破坏已有界面。
*/
let appMounted = false
function showFatalError(stage: string, err: unknown): void {
if (appMounted) {
console.error(`[renderer:${stage}]`, err)
return
}
const el = document.getElementById('app')
if (!el) return
const detail = err instanceof Error ? `${err.message}\n\n${err.stack ?? ''}` : String(err)
el.innerHTML = `<pre style="margin:16px;padding:16px;background:#fff5f5;border:1px solid #feb2b2;border-radius:8px;color:#c53030;font-size:13px;white-space:pre-wrap;word-break:break-all;">[renderer:${stage}]\n${detail}</pre>`
}
window.addEventListener('error', (e) => {
showFatalError('window.onerror', e.error ?? e.message)
})
window.addEventListener('unhandledrejection', (e) => {
showFatalError('unhandledrejection', e.reason)
})
try {
const app = createApp(App)
app.config.errorHandler = (err, _instance, info) => {
console.error('[vue errorHandler]', err, info)
if (!appMounted) showFatalError(`vue:${info}`, err)
}
const pinia = createPinia()
app.use(pinia)
app.use(i18n)
app.use(router)
void (async () => {
await useNetworkAuthStore().loadFromStorage()
const dongleStore = useDongleAuthStore()
await dongleStore.loadFromSecrets()
useDistributeFormStore().dongleAuthCode = dongleStore.authCode
})()
app.mount('#app')
appMounted = true
} catch (err) {
showFatalError('bootstrap', err)
}
-32
View File
@@ -1,32 +0,0 @@
import { onMounted, onUnmounted, ref } from 'vue'
import { useJobStore } from '@/stores/job'
const CIRCLE_LEN = 283
export function useMockJobPoll() {
const progress = ref(0)
const jobStore = useJobStore()
let timer: ReturnType<typeof setInterval> | null = null
const strokeOffset = ref(CIRCLE_LEN)
function tick() {
progress.value = Math.min(100, progress.value + 8)
strokeOffset.value = CIRCLE_LEN - (CIRCLE_LEN * progress.value) / 100
if (progress.value >= 100) {
jobStore.successCount += 1
progress.value = 0
strokeOffset.value = CIRCLE_LEN
}
}
onMounted(() => {
timer = setInterval(tick, 1000)
})
onUnmounted(() => {
if (timer) clearInterval(timer)
})
return { progress, strokeOffset }
}
-9
View File
@@ -1,9 +0,0 @@
import type { PrinterStatusDisplay } from '@/types/printer'
/** 阶段一 Header 展示;阶段二由 GetPrinterInfo 替换 */
export const mockPrinterStatus: PrinterStatusDisplay = {
ribbonType: 'YMCKO',
statusText: '50/300',
serialNo: 'S103B29035',
printedCount: 190
}
+13 -17
View File
@@ -7,15 +7,11 @@ export function setupRouterGuards(router: Router): void {
const job = useJobStore() const job = useJobStore()
const app = useAppStore() const app = useAppStore()
if (to.path === '/distribute/running' && !job.jobId) { if (to.path === '/distribute/running' && !job.jobId && app.mode !== 'distributing') {
return { path: '/distribute/config' } return { path: '/distribute/config' }
} }
if (to.path === '/distribute/failed' && job.failCount === 0) { if (to.path === '/collect/running' && app.mode !== 'usbCopying' && app.mode !== 'ready') {
return { path: '/distribute/config' }
}
if (to.path === '/collect/running' && app.mode !== 'usbCopying') {
return { path: '/collect' } return { path: '/collect' }
} }
@@ -23,21 +19,21 @@ export function setupRouterGuards(router: Router): void {
return { path: '/home' } return { path: '/home' }
} }
if (app.mode === 'usbCopying') { if (app.mode === 'usbCopying' && to.path.startsWith('/distribute')) {
if (to.path.startsWith('/distribute')) return { path: '/collect/running' } return { path: '/collect/running' }
if (from.path === '/collect/running') {
const allowed = ['/collect/running', '/collect', '/home']
if (!allowed.includes(to.path)) return false
}
} }
if (from.path === '/distribute/running' && to.path !== '/distribute/failed') { if (from.path === '/distribute/running' && app.mode === 'distributing') {
if (to.path !== '/distribute/config') { if (to.path === '/distribute/config') return true
app.setMode('ready') return { path: '/distribute/config' }
return { path: '/distribute/config' } }
}
if (from.path === '/collect/running' && app.mode === 'usbCopying') {
const allowed = ['/collect/running', '/collect', '/home']
if (!allowed.includes(to.path)) return { path: '/collect/running' }
} }
return true return true
}) })
} }
-5
View File
@@ -16,11 +16,6 @@ const router = createRouter({
name: 'distribute-running', name: 'distribute-running',
component: () => import('@/views/DistributeRunningView.vue') component: () => import('@/views/DistributeRunningView.vue')
}, },
{
path: '/distribute/failed',
name: 'distribute-failed',
component: () => import('@/views/DistributeFailedView.vue')
},
{ {
path: '/collect/running', path: '/collect/running',
name: 'collect-running', name: 'collect-running',
+98 -5
View File
@@ -1,69 +1,162 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
export interface TemplateFieldRow { export interface TemplateFieldRow {
label: string label: string
value: string value: string
originName: string
fieldType: number
} }
export interface TemplatePreview { export interface TemplatePreview {
frontImageUrl: string frontImageUrl: string
backImageUrl: string backImageUrl: string
fields: TemplateFieldRow[] fields: TemplateFieldRow[]
/** soon 模板 flag1 双面 / 2 正面 / 3 背面 */
templateFlag: number
} }
export interface PathListItem { export interface PathListItem {
path: string path: string
meta: string meta: string
sizeBytes: number sizeBytes: number
} }
export interface DistributeFormState { export interface DistributeFormState {
pathList: PathListItem[] pathList: PathListItem[]
volumeLabel: string volumeLabel: string
templateFile: string templateFile: string
templatePreview: TemplatePreview | null templatePreview: TemplatePreview | null
/** 任务 print_flag1 双面 / 2 仅正面 / 3 仅背面 */
printFlag: number
copyType: 0 | 1 copyType: 0 | 1
formatType: 'none' | 'fat' | 'ntfs'
formatType: 'none' | 'fat32' | 'exfat' | 'ntfs'
dongleEnabled: boolean dongleEnabled: boolean
dongleMode: -1 | 0 | 255
/** 勾选加密狗时有效:0 默认,1-101 为次数(101=不限次数) */
dongleInstallCount: number
dongleAuthCode: string
priority: 'low' | 'mid' | 'high' priority: 'low' | 'mid' | 'high'
ribbonType: 'any' | 'YMCKO' | 'YMCK' ribbonType: 'any' | 'YMCKO' | 'YMCK'
generateIso: boolean generateIso: boolean
generateZip: boolean generateZip: boolean
printCmdToHasi: boolean printCmdToHasi: boolean
presetCopy: boolean presetCopy: boolean
generateHasi: boolean generateHasi: boolean
dongleCountCheck: boolean dongleCountCheck: boolean
failPrintLabel: boolean failPrintLabel: boolean
} }
function createDefaultForm(): DistributeFormState { function createDefaultForm(): DistributeFormState {
return { return {
pathList: [], pathList: [],
volumeLabel: 'DATA_CARD', volumeLabel: 'DATA_CARD',
templateFile: '', templateFile: '',
templatePreview: null, templatePreview: null,
printFlag: 1,
copyType: 0, copyType: 0,
formatType: 'none',
dongleEnabled: true, formatType: 'fat32',
dongleMode: -1,
dongleEnabled: false,
dongleInstallCount: 0,
dongleAuthCode: '',
priority: 'low', priority: 'low',
ribbonType: 'any', ribbonType: 'any',
generateIso: false, generateIso: false,
generateZip: false, generateZip: false,
printCmdToHasi: false, printCmdToHasi: false,
presetCopy: false, presetCopy: false,
generateHasi: false, generateHasi: false,
dongleCountCheck: false, dongleCountCheck: false,
failPrintLabel: false failPrintLabel: false
} }
} }
export const useDistributeFormStore = defineStore('distributeForm', { export const useDistributeFormStore = defineStore('distributeForm', {
state: (): DistributeFormState => createDefaultForm(), state: (): DistributeFormState => createDefaultForm(),
actions: { actions: {
reset() { reset() {
const preservedAuth = this.dongleAuthCode
Object.assign(this, createDefaultForm()) Object.assign(this, createDefaultForm())
this.dongleAuthCode = preservedAuth
} }
} }
}) })
+30
View File
@@ -0,0 +1,30 @@
import { defineStore } from 'pinia'
import { secretsGet, secretsSet } from '@/api/cardsoon'
export const useDongleAuthStore = defineStore('dongleAuth', {
state: () => ({
authCode: '' as string,
loaded: false
}),
actions: {
async loadFromSecrets(): Promise<void> {
try {
const r = await secretsGet()
if (r.ok && r.data) {
this.authCode = r.data.dongleAuthCode || ''
}
} catch (e) {
console.warn('[dongleAuth] load failed', e)
}
this.loaded = true
},
async persist(authCode: string): Promise<void> {
this.authCode = authCode
try {
await secretsSet({ dongleAuthCode: authCode })
} catch (e) {
console.warn('[dongleAuth] persist failed', e)
}
}
}
})
+10
View File
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
export const useJobStore = defineStore('job', { export const useJobStore = defineStore('job', {
state: () => ({ state: () => ({
jobId: '', jobId: '',
lastJobJson: '',
submitting: false, submitting: false,
successCount: 0, successCount: 0,
failCount: 0 failCount: 0
@@ -11,11 +12,20 @@ export const useJobStore = defineStore('job', {
setActiveJob(jobId: string) { setActiveJob(jobId: string) {
this.jobId = jobId this.jobId = jobId
}, },
setLastJobJson(json: string) {
this.lastJobJson = json
},
clearActiveJob() { clearActiveJob() {
this.jobId = '' this.jobId = ''
}, },
/** 重置次数计数器(不影响当前 jobId/lastJobJson */
resetCounts() {
this.successCount = 0
this.failCount = 0
},
reset() { reset() {
this.jobId = '' this.jobId = ''
this.lastJobJson = ''
this.submitting = false this.submitting = false
this.successCount = 0 this.successCount = 0
this.failCount = 0 this.failCount = 0
@@ -0,0 +1,89 @@
import { defineStore } from 'pinia'
import type { StoredCredential } from '@/types/network'
import { secretsGet, secretsSet } from '@/api/cardsoon'
const LEGACY_KEY = 'networkCredentials'
export const useNetworkAuthStore = defineStore('networkAuth', {
state: () => ({
credentials: {} as Record<string, StoredCredential>,
driveHosts: {} as Record<string, string>
}),
getters: {
hasCredentials: (state) => Object.keys(state.credentials).length > 0,
configuredHosts: (state) => Object.keys(state.credentials)
},
actions: {
async loadFromStorage(): Promise<void> {
try {
const r = await secretsGet()
if (r.ok && r.data?.networkCredentials) {
this.credentials = { ...r.data.networkCredentials }
return
}
} catch (e) {
console.warn('[networkAuth] secrets load failed', e)
}
try {
const raw = localStorage.getItem(LEGACY_KEY)
if (!raw) return
const parsed = JSON.parse(raw) as Record<string, StoredCredential>
if (parsed && typeof parsed === 'object') {
this.credentials = { ...parsed }
await this.persist()
localStorage.removeItem(LEGACY_KEY)
}
} catch (e) {
console.warn('[networkAuth] legacy load failed', e)
try {
localStorage.removeItem(LEGACY_KEY)
} catch {
/* ignore */
}
}
},
setCredential(hostName: string, userName: string, password: string): void {
const host = (hostName || '').trim()
if (!host) return
this.credentials[host] = {
userName: userName || '',
password: password || '',
lastUsed: new Date().toISOString()
}
void this.persist()
},
setDriveHost(letter: string, hostName: string): void {
const L = (letter || '').trim().toUpperCase()
const host = (hostName || '').trim()
if (!L || L.length !== 1 || !host) return
this.driveHosts[L] = host
},
getDriveHostMap(): Record<string, string> {
return { ...this.driveHosts }
},
getCredential(hostName: string): StoredCredential | null {
const host = (hostName || '').trim()
if (!host) return null
return this.credentials[host] || null
},
removeCredential(hostName: string): void {
const host = (hostName || '').trim()
if (!host) return
if (delete this.credentials[host]) {
void this.persist()
}
},
clearAll(): void {
this.credentials = {}
this.driveHosts = {}
void this.persist()
},
async persist(): Promise<void> {
try {
await secretsSet({ networkCredentials: this.credentials })
} catch (e) {
console.warn('[networkAuth] persist failed', e)
}
}
}
})
@@ -65,3 +65,15 @@
.fa-exclamation-triangle::before { .fa-exclamation-triangle::before {
content: '\f071'; content: '\f071';
} }
.fa-id-card::before {
content: '\f2c2';
}
.fa-eject::before {
content: '\f052';
}
.fa-eye::before {
content: '\f06e';
}
.fa-eye-slash::before {
content: '\f070';
}
+14 -1
View File
@@ -22,7 +22,8 @@
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 12px;
padding: 20px 30px; padding: 20px 30px;
min-width: 220px; min-width: 0;
max-width: 50%;
} }
/* 面板标题 */ /* 面板标题 */
@@ -51,6 +52,8 @@
/* ========== 路径选择 - 大按钮设计 ========== */ /* ========== 路径选择 - 大按钮设计 ========== */
.m-path-box { .m-path-box {
width: 100%; width: 100%;
min-width: 0;
box-sizing: border-box;
padding: 12px 16px; padding: 12px 16px;
background: #f8f9fa; background: #f8f9fa;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
@@ -59,6 +62,16 @@
font-weight: 600; font-weight: 600;
color: #495057; color: #495057;
font-family: monospace; font-family: monospace;
overflow: hidden;
}
.m-path-text {
display: block;
width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.m-path-btn { .m-path-btn {
@@ -1,128 +0,0 @@
/*
Page 4 - 数据导入模式
手机横屏优化:左右分栏,大触摸区域,紧凑布局
*/
/* ========== 手机横屏核心布局 ========== */
.l-mobile-landscape {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
padding: 0 60px;
height: 100%;
flex: 1;
}
/* 配置面板 */
.m-config-panel {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
padding: 20px 30px;
min-width: 220px;
}
/* 面板标题 */
.m-panel-title {
font-size: 14px;
font-weight: 700;
color: #495057;
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.m-panel-title i {
color: var(--cs-primary);
font-size: 16px;
}
/* 垂直分隔线 */
.m-divider-v {
width: 1px;
height: 100px;
background: #e9ecef;
}
/* ========== 路径选择 - 大按钮设计 ========== */
.m-path-box {
width: 100%;
padding: 12px 16px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
color: #495057;
font-family: monospace;
}
.m-path-btn {
width: 100%;
height: 44px;
background: #fff;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
color: #495057;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s ease;
}
.m-path-btn:hover {
border-color: var(--cs-primary);
color: var(--cs-primary);
}
.m-path-btn i {
color: var(--cs-primary);
}
/* ========== 单选按钮 - 大触摸区域 ========== */
.m-radio-group {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.m-radio-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
color: #495057;
transition: all 0.2s ease;
}
.m-radio-item input {
width: 18px;
height: 18px;
margin: 0;
cursor: pointer;
accent-color: var(--cs-primary);
}
.m-radio-item:hover {
border-color: var(--cs-primary);
}
.m-radio-item:has(input:checked) {
border-color: var(--cs-primary);
background: rgba(0, 128, 0, 0.05);
}
@@ -73,6 +73,101 @@
color: var(--cs-primary); color: var(--cs-primary);
} }
/* 不支持的工具按钮(如当前 DLL 无废卡接口):置灰禁用 */
.m-tool-btn:disabled {
background: #f1f3f5;
border-color: #e9ecef;
color: #adb5bd;
cursor: not-allowed;
box-shadow: none;
}
.m-tool-btn:disabled:hover {
border-color: #e9ecef;
box-shadow: none;
}
.m-tool-btn:disabled i,
.m-tool-btn:disabled:hover i {
color: #ced4da;
}
/* ========== 读卡 / 退卡 PK 式斜切分隔按钮 ========== */
.m-tool-versus {
position: relative;
width: 100%;
height: 48px;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 6px;
overflow: hidden;
display: flex;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.m-tool-versus:hover {
border-color: var(--cs-primary);
box-shadow: 0 4px 12px rgba(0, 128, 0, 0.10);
}
.m-versus-half {
position: relative;
z-index: 1;
width: 50%;
height: 100%;
border: none;
background: transparent;
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
color: #495057;
cursor: pointer;
white-space: nowrap;
transition: background 0.18s ease, color 0.18s ease;
}
.m-versus-half span {
white-space: nowrap;
overflow: hidden;
text-overflow: clip;
}
.m-versus-half i {
font-size: 14px;
flex-shrink: 0;
color: #6c757d;
transition: color 0.18s ease;
}
/* 左半:内容朝中间(右对齐),右边缘斜切为 \ 形 */
.m-versus-half--read {
justify-content: flex-end;
padding-right: 12px;
clip-path: polygon(0 0, calc(100% - 9px) 0, calc(100% + 9px) 100%, 0 100%);
}
/* 右半:内容朝中间(左对齐),左边缘斜切为 \ 形 */
.m-versus-half--eject {
justify-content: flex-start;
padding-left: 12px;
clip-path: polygon(calc(0% - 9px) 0, 100% 0, 100% 100%, calc(0% + 9px) 100%);
}
.m-versus-half:hover {
background: rgba(0, 128, 0, 0.08);
color: var(--cs-primary);
}
.m-versus-half:hover i {
color: var(--cs-primary);
}
.m-versus-half:active {
background: rgba(0, 128, 0, 0.16);
}
/* ========== 垂直分隔线 ========== */ /* ========== 垂直分隔线 ========== */
.m-divider { .m-divider {
width: 1px; width: 1px;
@@ -1,360 +0,0 @@
/*
Page 8 - 循环任务执行中样式
左右分布布局:左 = 状态+工作流,右 = 大圆环
Index 首页也复用此样式
*/
/* ========== Index 首页仪表板样式 ========== */
.l-dashboard {
display: flex;
align-items: center;
justify-content: center;
gap: 60px;
padding: 20px 80px;
height: 100%;
flex: 1;
}
/* 区域标题 */
.m-section-title {
font-size: 11px;
font-weight: 700;
color: #adb5bd;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 1px;
padding-left: 4px;
}
/* ========== 工具区域(左) ========== */
.m-tool-section {
width: 180px;
flex-shrink: 0;
}
.m-tool-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.m-tool-btn {
width: 100%;
height: 48px;
background: #fff;
border: 1px solid #dee2e6;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0 16px;
gap: 10px;
font-size: 13px;
font-weight: 600;
color: #495057;
cursor: pointer;
transition: all 0.2s ease;
}
.m-tool-btn i {
font-size: 16px;
color: #6c757d;
width: 20px;
text-align: center;
transition: color 0.2s ease;
}
.m-tool-btn:hover {
border-color: var(--cs-primary);
box-shadow: 0 4px 12px rgba(0, 128, 0, 0.1);
}
.m-tool-btn:hover i {
color: var(--cs-primary);
}
/* ========== 垂直分隔线 ========== */
.m-divider {
width: 1px;
height: 140px;
background: linear-gradient(to bottom, transparent, #dee2e6, transparent);
}
/* ========== 任务区域(右) ========== */
.m-task-section {
flex: 1;
max-width: 400px;
}
.m-task-grid {
display: flex;
gap: 12px;
}
.m-task-card {
flex: 1;
min-height: 100px;
background: #fff;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
cursor: pointer;
transition: all 0.25s ease;
}
.m-task-icon {
width: 42px;
height: 42px;
border-radius: 8px;
background: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s ease;
}
.m-task-icon i {
font-size: 18px;
color: #6c757d;
transition: color 0.25s ease;
}
.m-task-info {
text-align: center;
}
.m-task-info h4 {
font-size: 14px;
font-weight: 800;
color: #495057;
margin: 0 0 4px 0;
transition: color 0.25s ease;
}
.m-task-info p {
font-size: 11px;
color: #adb5bd;
margin: 0;
font-weight: 500;
}
/* 悬停效果 - 绿色主题 */
.m-task-card:hover {
border-color: var(--cs-primary);
box-shadow: 0 6px 16px rgba(0, 128, 0, 0.12);
transform: translateY(-2px);
}
.m-task-card:hover .m-task-icon {
background: var(--cs-primary);
}
.m-task-card:hover .m-task-icon i {
color: #fff;
}
.m-task-card:hover h4 {
color: var(--cs-primary);
}
/* 停止按钮样式 - 醒目红色 */
.c-nav-btn--stop {
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%) !important;
box-shadow: 0 3px 10px rgba(220, 53, 69, 0.35) !important;
width: 48px !important;
height: 48px !important;
}
.c-nav-btn--stop i {
color: #fff !important;
font-size: 18px !important;
}
.c-nav-btn--stop span {
color: #fff !important;
font-weight: 700 !important;
}
.c-nav-btn--stop:hover {
background: linear-gradient(135deg, #c82333 0%, #a71d2a 100%) !important;
box-shadow: 0 4px 14px rgba(220, 53, 69, 0.45) !important;
}
/* ========== 核心布局:左右分布 ========== */
.l-hero-container {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 80px !important;
padding: 0 120px !important;
}
/* 左侧面板:状态 + 工作流 */
.m-left-panel {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 30px;
flex: 1;
max-width: 380px;
}
/* 右侧面板:圆环进度 */
.m-right-panel {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
/* ========== 状态消息 - 左对齐 ========== */
.c-status-panel {
text-align: left !important;
width: 100% !important;
}
.c-status-title.is-looping {
color: var(--cs-primary);
font-size: 24px;
font-weight: 800;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
justify-content: flex-start;
}
.c-status-title.is-looping::before {
content: '';
width: 12px;
height: 12px;
background: var(--cs-primary);
border-radius: 50%;
animation: blink 1.5s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.c-status-sub {
text-align: left !important;
font-size: 13px !important;
}
/* 统计计数文字 */
.c-status-counter {
font-size: 12px;
color: #6c757d;
font-weight: 600;
margin: 8px 0 0 0;
}
.c-status-counter .ok {
color: var(--cs-primary);
font-weight: 800;
}
.c-status-counter .err {
color: #dc3545;
font-weight: 800;
}
/* ========== 4步工作流 - 直接渲染 ========== */
.m-steps-flow {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0;
width: 100%;
padding-top: 10px;
}
.m-steps-flow .step-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 70px;
}
.m-steps-flow .step-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #dee2e6;
}
.m-steps-flow .step-item.is-active .step-dot {
background: var(--cs-primary);
box-shadow: 0 0 0 4px rgba(0, 128, 0, 0.15);
}
.m-steps-flow .step-label {
font-size: 11px;
font-weight: 600;
color: #adb5bd;
white-space: nowrap;
}
.m-steps-flow .step-item.is-active .step-label {
color: var(--cs-primary);
font-weight: 700;
}
.m-steps-flow .step-line {
width: 50px;
height: 3px;
background: #dee2e6;
margin-bottom: 18px;
}
.m-steps-flow .step-line.is-active {
background: var(--cs-primary);
}
/* ========== 圆形进度条 ========== */
.m-progress-circle {
position: relative;
width: 160px;
height: 160px;
}
.m-progress-circle svg {
transform: rotate(-90deg);
width: 100%;
height: 100%;
}
.m-progress-circle circle {
fill: none;
stroke-width: 10;
stroke-linecap: round;
}
.m-progress-circle .bg {
stroke: #ecf0f1;
}
.m-progress-circle .fill {
stroke: var(--cs-primary);
stroke-dasharray: 283;
transition: stroke-dashoffset 0.5s ease;
}
.m-progress-value {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 34px;
font-weight: 900;
color: var(--cs-primary);
}
@@ -1,172 +0,0 @@
/*
Page 3 - 任务失败界面
风格与 page8 统一:左右分布,红色错误主题
*/
/* ========== 核心布局:左右分布 ========== */
.l-hero-container {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
gap: 80px !important;
padding: 0 120px !important;
}
/* 左侧面板:状态 + 工作流 */
.m-left-panel {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 30px;
flex: 1;
max-width: 380px;
}
/* 右侧面板:错误图标 */
.m-right-panel {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
/* ========== 状态消息 - 红色错误主题 ========== */
.c-status-panel {
text-align: left !important;
width: 100% !important;
}
.c-status-title.is-error {
color: #dc3545;
font-size: 24px;
font-weight: 800;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 6px;
justify-content: flex-start;
}
.c-status-title.is-error::before {
content: '';
width: 12px;
height: 12px;
background: #dc3545;
border-radius: 50%;
animation: blink-red 1.5s infinite;
}
@keyframes blink-red {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.c-status-sub {
text-align: left !important;
font-size: 13px !important;
color: #6c757d;
}
/* 统计计数文字 */
.c-status-counter {
font-size: 12px;
color: #6c757d;
font-weight: 600;
margin: 8px 0 0 0;
}
.c-status-counter .ok {
color: #28a745;
font-weight: 800;
}
.c-status-counter .err {
color: #dc3545;
font-weight: 800;
}
/* ========== 4步工作流 - 红色错误主题 ========== */
.m-steps-flow {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0;
width: 100%;
padding-top: 10px;
}
.m-steps-flow .step-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-width: 70px;
}
.m-steps-flow .step-dot {
width: 14px;
height: 14px;
border-radius: 50%;
background: #dee2e6;
}
/* 完成状态 - 绿色 */
.m-steps-flow .step-item.is-completed .step-dot {
background: #28a745;
}
.m-steps-flow .step-item.is-completed .step-label {
color: #28a745;
font-weight: 700;
}
/* 错误状态 - 红色 */
.m-steps-flow .step-item.is-error .step-dot {
background: #dc3545;
box-shadow: 0 0 0 4px rgba(220, 53, 69, 0.15);
}
.m-steps-flow .step-item.is-error .step-label {
color: #dc3545;
font-weight: 700;
}
.m-steps-flow .step-label {
font-size: 11px;
font-weight: 600;
color: #adb5bd;
white-space: nowrap;
}
.m-steps-flow .step-line {
width: 50px;
height: 3px;
background: #dee2e6;
margin-bottom: 18px;
}
.m-steps-flow .step-line.is-completed {
background: #28a745;
}
.m-steps-flow .step-line.is-error {
background: linear-gradient(to right, #28a745 50%, #dc3545 50%);
}
/* ========== 错误图标 - 红色大三角 ========== */
.m-error-icon {
width: 160px;
height: 160px;
border-radius: 50%;
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(220, 53, 69, 0.3);
}
.m-error-icon i {
font-size: 70px;
color: #fff;
}
+97 -224
View File
@@ -1,8 +1,3 @@
/*
Page 7 业务样式 - 打印系统核心界面
基于 base.css 构建
*/
/* 左右栏固定 1:1,内容变化不挤占宽度 */ /* 左右栏固定 1:1,内容变化不挤占宽度 */
.app-shell__main.l-main-flex { .app-shell__main.l-main-flex {
display: grid; display: grid;
@@ -53,6 +48,11 @@
flex-wrap: nowrap; flex-wrap: nowrap;
} }
.m-panel-toolbar .toolbar-row + .toolbar-row {
flex-wrap: wrap;
row-gap: 4px;
}
.toolbar-item { .toolbar-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -81,6 +81,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
flex: 0 1 auto;
min-width: 0;
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
color: #6c757d; color: #6c757d;
@@ -108,22 +110,64 @@
margin-left: 3px; margin-left: 3px;
} }
/* 提示文字 */ .m-panel-toolbar .c-checkbox-item .c-input.dog-auth {
.m-panel-toolbar .c-checkbox-item .dog-hint { width: 140px;
font-size: 8px; height: 20px;
padding: 0 4px;
font-size: 9px;
border-radius: 3px;
border: 1px solid #ced4da;
margin-left: 3px;
}
/* 授权码输入框容器 + 小眼睛 */
.m-panel-toolbar .c-checkbox-item .dog-auth-wrap {
position: relative;
display: inline-flex;
align-items: center;
margin-left: 3px;
}
.m-panel-toolbar .c-checkbox-item .dog-auth-toggle {
position: absolute;
right: 2px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: #adb5bd; color: #adb5bd;
font-size: 10px;
}
.m-panel-toolbar .c-checkbox-item .dog-auth-toggle:hover {
color: #495057;
}
/* 授权码标签 */
.m-panel-toolbar .c-checkbox-item .dog-auth-label {
font-size: 9px;
color: #495057;
font-weight: 500; font-weight: 500;
margin-left: 2px; margin-left: 6px;
flex-shrink: 1; flex-shrink: 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.m-panel-toolbar .c-checkbox-item { /* 提示文字 */
flex: 1; .m-panel-toolbar .c-checkbox-item .dog-hint {
min-width: 0; font-size: 9px;
color: #adb5bd;
font-weight: 500;
margin-left: 2px;
flex-shrink: 0;
white-space: nowrap;
} }
/* 列表业务项 (File Items) */ /* 列表业务项 (File Items) */
@@ -410,214 +454,6 @@
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
} }
/* 10. 设置弹窗 (Page 8) */
.m-settings-modal {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
z-index: 50;
}
.m-settings-modal.is-open {
display: flex;
}
.m-settings-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.24);
}
.m-settings-modal__dialog {
position: relative;
width: min(540px, calc(100% - 20px));
max-height: calc(100% - 12px);
min-height: 0;
background: #f3f3f3;
border: 1px solid #cfcfcf;
border-radius: 6px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
display: flex;
flex-direction: column;
overflow: hidden;
}
.m-settings-modal__header {
flex-shrink: 0;
height: 28px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 0 12px;
border-bottom: 1px solid #dddddd;
background: linear-gradient(to bottom, #fbfbfb, #efefef);
}
.m-settings-modal__header h2 {
font-size: 11px;
color: #5a5a5a;
font-weight: 700;
}
.m-settings-modal__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 3px;
background: transparent;
color: #666;
cursor: pointer;
}
.m-settings-modal__close:hover {
background: rgba(0, 0, 0, 0.06);
color: #333;
}
.m-settings-modal__body {
flex: 1;
min-height: 0;
padding: 6px 10px 4px;
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: #bbb transparent;
}
.m-settings-modal__body::-webkit-scrollbar {
width: 5px;
}
.m-settings-modal__body::-webkit-scrollbar-thumb {
background: #bbb;
border-radius: 3px;
}
.m-settings-group {
flex-shrink: 0;
border: 1px solid #d8d8d8;
background: #f5f5f5;
padding: 6px;
}
.m-settings-group--advanced {
min-height: 0;
}
.m-settings-group__title {
font-size: 11px;
color: #444;
margin-bottom: 4px;
font-weight: 700;
}
.m-settings-group__panel {
background: #efefef;
border: 1px solid #d9d9d9;
padding: 6px;
overflow: visible;
}
.m-settings-row {
display: grid;
grid-template-columns: 58px 155px 58px 1fr;
align-items: center;
column-gap: 8px;
margin-bottom: 6px;
}
.m-settings-row:last-child {
margin-bottom: 0;
grid-template-columns: 86px 155px 1fr;
}
.m-settings-row label {
font-size: 10px;
color: #333;
white-space: nowrap;
}
.m-settings-row .c-app-select {
min-width: 0;
}
.m-settings-options {
display: grid;
grid-template-columns: 1fr 1fr;
row-gap: 6px;
column-gap: 14px;
align-content: start;
min-height: 0;
}
.m-settings-check {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: #333;
white-space: nowrap;
}
.m-settings-check input[type='checkbox'] {
width: 12px;
height: 12px;
margin: 0;
}
.m-settings-modal__footer {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 4px 12px 8px;
border-top: 1px solid #ddd;
background: #f3f3f3;
}
.m-settings-modal__cancel {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 68px;
height: 24px;
padding: 0 10px;
border: 1px solid #ced4da;
border-radius: 3px;
background: #fff;
font-size: 12px;
color: #495057;
cursor: pointer;
}
.m-settings-modal__cancel:hover {
border-color: #adb5bd;
background: #f8f9fa;
}
.m-settings-modal__cancel .fas {
font-size: 11px;
color: #6c757d;
}
.m-settings-modal__confirm {
min-width: 68px;
height: 24px;
font-size: 12px;
border-radius: 3px;
padding: 0 12px;
}
/* 路径提示 */ /* 路径提示 */
.m-path-hint { .m-path-hint {
display: flex; display: flex;
@@ -780,6 +616,43 @@
min-width: 0; min-width: 0;
} }
.c-data-table-mini .c-field-input {
width: 100%;
height: 16px;
border: none;
background: transparent;
font-size: inherit;
font-weight: inherit;
color: inherit;
outline: none;
min-width: 0;
padding: 0;
}
.c-field-pick {
flex-shrink: 0;
border: 1px solid #dcdfe6;
border-radius: 3px;
background: #fff;
font-size: 8px;
font-weight: 700;
color: #495057;
padding: 0 6px;
height: 18px;
cursor: pointer;
}
.c-field-pick:hover {
background: #f1f3f5;
}
.c-field-value {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.c-path-cell__text { .c-path-cell__text {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -1,660 +0,0 @@
/*
Page 7 业务样式 - 打印系统核心界面
基于 base.css 构建
*/
/* 布局微调:增加左侧面板宽度给新控件 */
.m-panel--left {
flex: 5;
}
.m-panel--right {
flex: 5;
}
/* ========== 工具栏 - 紧凑两行布局 ========== */
.m-panel-toolbar {
padding: 10px 14px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
display: flex;
flex-direction: column;
gap: 8px;
}
.toolbar-row {
display: flex;
align-items: center;
gap: 16px;
}
.toolbar-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: 600;
color: #6c757d;
white-space: nowrap;
}
.toolbar-item .c-input {
width: 90px;
height: 24px;
padding: 0 6px;
font-size: 9px;
border-radius: 3px;
border: 1px solid #ced4da;
}
.toolbar-item .c-select {
width: 90px;
height: 24px;
padding: 0 6px;
font-size: 9px;
border-radius: 3px;
border: 1px solid #ced4da;
}
/* 复选框样式 */
.m-panel-toolbar .c-checkbox-item {
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
font-weight: 600;
color: #6c757d;
white-space: nowrap;
cursor: pointer;
padding: 2px 0;
}
.m-panel-toolbar .c-checkbox-item input[type="checkbox"] {
width: 14px;
height: 14px;
margin: 0;
cursor: pointer;
}
/* 加密狗数量输入框 */
.m-panel-toolbar .c-checkbox-item .c-input.dog-count {
width: 40px;
height: 20px;
padding: 0 4px;
font-size: 9px;
text-align: center;
border-radius: 3px;
border: 1px solid #ced4da;
margin-left: 3px;
}
/* 提示文字 */
.m-panel-toolbar .c-checkbox-item .dog-hint {
font-size: 8px;
color: #adb5bd;
font-weight: 500;
margin-left: 2px;
}
/* 列表业务项 (File Items) */
.m-file-item {
display: flex;
align-items: center;
padding: 6px 10px;
border-bottom: 1px solid #f8f8f8;
}
.m-file-item__icon {
width: 32px;
font-size: 22px; /* 图标大幅放大,对齐 case.png */
color: var(--cs-dark-blue);
margin-right: 12px;
text-align: center;
}
.m-file-item__info {
flex: 1;
}
.m-file-item__name {
font-size: 11px;
font-weight: 600;
}
.m-file-item__meta {
font-size: 9px;
color: #999;
}
.m-file-item__delete {
border: none;
background: none;
color: var(--cs-primary);
font-size: 14px;
cursor: pointer;
}
/* 卡片预览区 (Preview Area) */
.m-preview-area {
background: #333;
margin: 8px;
height: 100px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.m-card-small {
width: 130px;
height: 84px;
background: white;
border-radius: 3px;
padding: 5px;
font-size: 7px;
}
.m-card-small__row {
margin-bottom: 2px;
}
/* 面板头部专用布局 */
.c-panel__header .c-nav-group {
gap: 4px;
align-items: center;
}
/* 优化按钮文字展示,确保不换行 */
.c-button--mini,
.c-button--primary {
background: var(--cs-primary) !important;
color: #fff !important;
border: none !important;
border-radius: 4px;
padding: 1px 8px; /* 进一步收紧边距 */
font-weight: 800;
font-size: 10px;
white-space: nowrap;
letter-spacing: -0.2px; /* 微调字间距 */
}
.c-button--mini:hover {
background: var(--cs-primary-hover) !important;
}
/* 数据详情表格 (Data Table) */
.m-data-section {
flex: 1;
padding: 0 10px;
overflow-y: auto;
}
.m-data-table {
width: 100%;
border-collapse: collapse;
font-size: 10px;
}
.m-data-table td {
padding: 3px 0;
border-bottom: 1px solid #f5f5f5;
}
.m-data-table td:first-child {
color: #888;
width: 40%;
}
/* 动态表单字段 (Dynamic Fields) */
.m-dynamic-fields {
padding: 0 10px 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.m-dynamic-row {
display: flex;
align-items: center; /* 垂直居中 */
gap: 8px;
min-height: 24px; /* 增加最小高度确保对齐空间 */
}
.m-dynamic-row select,
.m-dynamic-row input[type='text'] {
border: 1px solid var(--cs-border);
border-radius: 3px;
font-size: 10px;
}
.m-dynamic-row select {
flex: 1.2;
background: #f9f9f9;
}
.m-dynamic-row input[type='text'] {
flex: 1.8;
}
/* 单选框组对齐优化 */
.m-dynamic-row .radio-group {
display: flex;
align-items: center;
gap: 10px;
flex: 1.8; /* 与输入框占据同样的宽度比例,保持视觉对称 */
}
.m-dynamic-row label {
display: flex;
align-items: center; /* 关键:Label 内部 Flex 居中 */
gap: 4px;
font-size: 10px;
color: #444;
cursor: pointer;
line-height: 1; /* 防止行高干扰 */
}
.m-dynamic-row input[type='radio'] {
margin: 0;
cursor: pointer;
width: 12px;
height: 12px;
position: relative;
top: 1px; /* 视觉补偿:单选框通常在浏览器中偏上 1px */
}
.c-list-item {
padding: 6px 12px;
border-bottom: 1px solid #f5f6f7;
}
.c-list-item__icon {
color: #3498db;
font-size: 14px;
width: 20px;
}
.c-list-item__name {
font-size: 11px;
font-weight: 800;
color: #333;
}
.c-list-item__meta {
font-size: 9px;
color: #999;
}
.c-list-item__action {
color: #e74c3c;
font-size: 14px;
}
/* 4. 预览区:高保真拟物化 */
.m-preview-area {
background: #2d3436;
margin: 8px;
height: 105px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.5);
}
.m-card-small {
width: 135px;
height: 88px;
background: #fff;
border-radius: 4px;
padding: 6px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
}
.m-card-small__row {
font-size: 8px;
color: #333;
margin-bottom: 2px;
line-height: 1.2;
}
.m-card-small__label {
font-weight: 800;
}
/* 5. 业务数据表格:极致紧凑化 (2px 间距) */
.m-data-section { padding: 2px 10px; }
.m-data-table { width: 100%; border-collapse: separate; border-spacing: 0 2px; font-size: 10px !important; }
.m-data-table td { padding: 0; border: none; vertical-align: middle; }
.m-data-table td:first-child {
color: #666; width: 35%; font-weight: 700; padding-right: 8px;
}
.m-data-table td:last-child {
color: #333; font-weight: 800; text-align: left;
background: #f9fafb; border: 1px solid #dcdfe6; border-radius: 3px;
padding: 1px 8px; height: 20px; /* 进一步压低高度 */
}
/* 6. 动态表单项:极致压缩 (解决挤压问题) */
.m-dynamic-fields { padding: 0 10px 6px; display: flex; flex-direction: column; gap: 2px; }
.m-dynamic-row { display: flex; align-items: center; gap: 6px; min-height: 20px; }
.m-dynamic-row .c-select {
width: 35%; /* 强制与上方 Label 宽度一致,实现对齐 */
height: 20px; font-size: 10px; border: 1px solid #dcdfe6; border-radius: 3px;
}
.m-dynamic-row .c-input {
flex: 1; height: 20px; font-size: 10px; border: 1px solid #dcdfe6; border-radius: 3px; padding: 0 8px;
}
.m-dynamic-row .m-file-item__delete {
font-size: 12px; color: #999; padding: 0 4px;
}
.m-dynamic-row label { display: flex; align-items: center; gap: 4px; cursor: pointer; color: #444; font-size: 10px; }
/* 7. 主色调回归:Page 3 式工业绿 */
.c-button--primary {
background: var(--cs-primary) !important;
border-color: #3b633a !important;
}
/* 8. 进度条深度美化 (极致窄版 - 12px 极简设计) */
.c-progress {
height: 12px; /* 极致窄版,节省空间 */
background: #dee2e6;
border-radius: 6px;
position: relative;
overflow: hidden;
margin: 2px 10px; /* 减小外边距 */
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.15);
border: 1px solid #adb5bd;
}
.c-progress-fill {
height: 100%;
background: linear-gradient(to bottom, #40c057, #2f9e44);
border-radius: 5px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
.c-progress-text {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 8.5px; /* 极致字号 */
font-weight: 800;
color: #fff;
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.4);
z-index: 2;
line-height: 12px;
}
/* 9. Footer 区域收缩 */
.c-panel__footer {
padding: 4px 0 !important; /* 彻底压缩 Footer 高度 */
min-height: auto !important;
border-top: 1px solid #f0f0f0;
}
/* 10. 设置弹窗 (Page 8) */
.m-settings-modal {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
z-index: 50;
}
.m-settings-modal.is-open {
display: flex;
}
.m-settings-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.24);
}
.m-settings-modal__dialog {
position: relative;
width: 560px;
min-height: 265px;
background: #f3f3f3;
border: 1px solid #cfcfcf;
border-radius: 6px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
display: flex;
flex-direction: column;
}
.m-settings-modal__header {
height: 28px;
display: flex;
align-items: center;
padding: 0 12px;
border-bottom: 1px solid #dddddd;
background: linear-gradient(to bottom, #fbfbfb, #efefef);
}
.m-settings-modal__header h2 {
font-size: 11px;
color: #5a5a5a;
font-weight: 700;
}
.m-settings-modal__body {
flex: 1;
padding: 8px 12px 6px;
display: flex;
flex-direction: column;
gap: 8px;
}
.m-settings-group {
border: 1px solid #d8d8d8;
background: #f5f5f5;
padding: 8px;
}
.m-settings-group--advanced {
min-height: 132px;
}
.m-settings-group__title {
font-size: 11px;
color: #444;
margin-bottom: 7px;
font-weight: 700;
}
.m-settings-group__panel {
background: #efefef;
border: 1px solid #d9d9d9;
padding: 8px;
}
.m-settings-row {
display: grid;
grid-template-columns: 58px 155px 58px 1fr;
align-items: center;
column-gap: 8px;
margin-bottom: 6px;
}
.m-settings-row:last-child {
margin-bottom: 0;
grid-template-columns: 86px 155px 1fr;
}
.m-settings-row label {
font-size: 10px;
color: #333;
white-space: nowrap;
}
.m-settings-row .c-select {
height: 20px;
font-size: 10px;
border-radius: 2px;
border-color: #c9c9c9;
background: #fff;
}
.m-settings-options {
display: grid;
grid-template-columns: 1fr 1fr;
row-gap: 12px;
column-gap: 22px;
align-content: start;
min-height: 92px;
}
.m-settings-check {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: #333;
white-space: nowrap;
}
.m-settings-check input[type='checkbox'] {
width: 12px;
height: 12px;
margin: 0;
}
.m-settings-modal__footer {
display: flex;
justify-content: flex-end;
padding: 0 12px 10px;
}
.m-settings-modal__confirm {
min-width: 68px;
height: 22px;
font-size: 10px;
border-radius: 3px;
padding: 0 12px;
}
/* 路径提示 */
.m-path-hint {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
font-size: 10px;
color: #6c757d;
}
.m-path-hint i {
color: var(--cs-dark-grey);
font-size: 12px;
}
/* 路径列表项 */
.c-path-item {
display: flex;
align-items: center;
padding: 6px 12px;
border-bottom: 1px solid #f5f6f7;
}
.c-path-item__info {
flex: 1;
}
.c-path-item__name {
font-size: 11px;
font-weight: 700;
color: #333;
}
.c-path-item__meta {
font-size: 9px;
color: #999;
margin-top: 2px;
}
.c-path-item__delete {
border: none;
background: none;
color: #e74c3c;
font-size: 16px;
cursor: pointer;
padding: 2px 6px;
font-weight: 800;
}
/* 标签预览区 */
.c-preview-area {
background: #2d3436;
margin: 8px;
height: 105px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.5);
}
.c-card-small {
width: 135px;
height: 88px;
background: #fff;
border-radius: 4px;
padding: 6px;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
}
.c-card-small__row {
font-size: 8px;
color: #333;
margin-bottom: 2px;
line-height: 1.2;
}
.c-card-small__label {
font-weight: 800;
}
/* 紧凑数据表格 */
.c-data-table-mini {
width: 100%;
border-collapse: separate;
border-spacing: 0 2px;
font-size: 9px;
}
.c-data-table-mini td {
padding: 0;
border: none;
vertical-align: middle;
}
.c-data-table-mini td:first-child {
color: #666;
width: 35%;
font-weight: 700;
padding-right: 8px;
font-size: 8px;
}
.c-data-table-mini td:last-child {
color: #333;
font-weight: 800;
text-align: left;
background: #f9fafb;
border: 1px solid #dcdfe6;
border-radius: 3px;
padding: 1px 6px;
height: 18px;
font-size: 8px;
}
.c-data-table-mini td:last-child.c-path-cell {
background: transparent;
border: none;
padding: 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.c-path-update {
color: var(--cs-primary);
font-weight: 800;
cursor: pointer;
padding: 0 4px;
font-size: 10px;
}
-8
View File
@@ -1,4 +1,3 @@
/* Electron 壳层:覆盖 design 原型用的深色信箱背景 */
html, html,
body, body,
#app { #app {
@@ -13,7 +12,6 @@ body,
position: relative; position: relative;
} }
/* 720×360 逻辑画布;内容区同比例;useScale 按 innerWidth/720 顶对齐 */
.app-shell { .app-shell {
position: absolute; position: absolute;
left: 0; left: 0;
@@ -25,11 +23,6 @@ body,
overflow: hidden; overflow: hidden;
} }
/*
* 首页两侧「空白」主要来自 page2.css 的 .l-dashboard
* padding 左右 + justify-content:center + 左栏固定 180px / 右栏 max-width:400px
* 在 DevTools 里选中 main.app-shell__main.l-dashboard 可看到盒模型
*/
.app-shell__main.l-dashboard { .app-shell__main.l-dashboard {
padding: 20px 40px; padding: 20px 40px;
gap: 48px; gap: 48px;
@@ -75,7 +68,6 @@ body,
margin-bottom: 2px; margin-bottom: 2px;
} }
/* 与 page2.css 中 .m-tool-btn i / .m-task-icon i 对齐 */
.m-tool-btn .fas { .m-tool-btn .fas {
font-size: 16px; font-size: 16px;
color: #6c757d; color: #6c757d;
+12
View File
@@ -25,12 +25,24 @@ export interface JobPollPayload {
failed: boolean failed: boolean
cancelled: boolean cancelled: boolean
finished: boolean finished: boolean
/** 任务失败当刻由主进程取到的打印机错误串 */
errorMessage?: string
} }
export interface UsbPollPayload { export interface UsbPollPayload {
/** SAPI_GetUsbCopyState 返回值,0 表示成功 */
queryCode: number
/** task_status: 0 preparing, 1 copying, 2 completed, 3 failed */
taskStatus: number taskStatus: number
/** copy_progress 0-100 */
progress: number progress: number
terminal: boolean terminal: boolean
failed: boolean failed: boolean
success: boolean success: boolean
errorMessage?: string
}
export interface CardPositionPollPayload {
queryCode: number
position: number
} }
+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 -3
View File
@@ -1,14 +1,19 @@
export interface PrinterStatusDisplay { export interface PrinterStatusDisplay {
ribbonType: string ribbonType: string
ribbonAmount: string
statusText: string statusText: string
serialNo: string serialNo: string
printedCount: number /** 打印机型号/名称(如 TH80),用于判断单/双面能力 */
printerName: string
/** 是否为单面打印机(如 TH80),单面打印机不能选"双面"打印 */
isSingleSide: boolean
} }
/** Init 前 Header 占位;阶段二由 GetPrinterInfo 覆盖 */
export const defaultPrinterStatus: PrinterStatusDisplay = { export const defaultPrinterStatus: PrinterStatusDisplay = {
ribbonType: '—', ribbonType: '—',
ribbonAmount: '—',
statusText: '—', statusText: '—',
serialNo: '—', serialNo: '—',
printedCount: 0 printerName: '—',
isSingleSide: false
} }
+46 -9
View File
@@ -1,32 +1,69 @@
import type { DistributeFormState } from '@/stores/distributeForm' import type { DistributeFormState } from '@/stores/distributeForm'
import type { NetInfoCredential } from '@shared/network-host'
import { cleanPathPattern } from '@shared/path-pattern' import { cleanPathPattern } from '@shared/path-pattern'
import { resolveJobTasks } from '@/utils/validateJobConfig'
export interface BuildJobOptions {
taskId: string
udfFile?: string
netInfo?: NetInfoCredential[]
}
function formatFileForApi(formatType: Exclude<DistributeFormState['formatType'], 'none'>): string {
switch (formatType) {
case 'exfat':
return 'exFAT'
case 'ntfs':
return 'NTFS'
default:
return 'FAT32'
}
}
export function buildJobConfig(
form: DistributeFormState,
opts: BuildJobOptions
): Record<string, unknown> {
const { hasCopy, hasPrint } = resolveJobTasks(form)
const needFormat = form.formatType !== 'none'
export function buildJobConfig(form: DistributeFormState): Record<string, unknown> {
const taskId = `T${Date.now()}`
const hasCopy = form.pathList.length > 0
const hasPrint = !!form.templateFile.trim()
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
task_id: taskId, task_id: opts.taskId,
print_copys: 1, print_copys: 1,
has_print_task: hasPrint, has_print_task: hasPrint,
has_copy_task: hasCopy, has_copy_task: hasCopy,
label: form.volumeLabel || 'DATA_CARD', label: form.volumeLabel || 'DATA_CARD',
file_type: String(form.copyType), file_type: String(form.copyType),
zone_type: form.copyType === 1 ? '1' : '0', zone_type: form.copyType === 1 ? '1' : '0',
need_format: form.formatType !== 'none', need_format: needFormat,
format_file: form.formatType === 'ntfs' ? 'NTFS' : 'FAT',
disk_size: '16GB', disk_size: '16GB',
dongle_install_count: form.dongleEnabled ? form.dongleMode : -1 dongle_install_count: form.dongleEnabled ? form.dongleInstallCount : -1
} }
if (form.dongleEnabled) {
body.auth_code = form.dongleAuthCode.trim()
}
if (needFormat) {
body.format_file = formatFileForApi(form.formatType as Exclude<DistributeFormState['formatType'], 'none'>)
}
if (hasCopy) { if (hasCopy) {
body.path_file = form.pathList.map((x) => cleanPathPattern(x.path)) body.path_file = form.pathList.map((x) => cleanPathPattern(x.path))
} }
if (hasPrint) { if (hasPrint) {
body.json_file = form.templateFile.trim() body.json_file = form.templateFile.trim()
body.print_flag = 1 body.print_flag = form.printFlag
const udf = opts.udfFile?.trim()
if (udf) body.udf_file = udf
} }
if (form.generateIso) body.is_generate_iso = true if (form.generateIso) body.is_generate_iso = true
if (form.generateZip) body.is_generate_zip = true if (form.generateZip) body.is_generate_zip = true
if (form.failPrintLabel) body.is_printer_record_logo = true if (form.failPrintLabel) body.is_printer_record_logo = true
if (opts.netInfo?.length) body.net_info = opts.netInfo
return body return body
} }
@@ -0,0 +1,31 @@
import { cleanPathPattern } from '@shared/path-pattern'
import { buildNetInfo, collectHostsForCopyPaths, type NetInfoCredential } from '@shared/network-host'
import type { DistributeFormState } from '@/stores/distributeForm'
import { fsResolveNetworkHosts } from '@/api/cardsoon'
import { useNetworkAuthStore } from '@/stores/networkAuth'
async function mergedDriveHostMap(form: DistributeFormState): Promise<Record<string, string>> {
const paths = form.pathList.map((x) => cleanPathPattern(x.path))
const netStore = useNetworkAuthStore()
if (!paths.length) return { ...netStore.getDriveHostMap() }
const r = await fsResolveNetworkHosts(paths)
const fromNetUse = r.ok && r.data?.driveHostMap ? r.data.driveHostMap : {}
return { ...fromNetUse, ...netStore.getDriveHostMap() }
}
export async function resolveCopyNetworkHosts(form: DistributeFormState): Promise<string[]> {
const paths = form.pathList.map((x) => cleanPathPattern(x.path))
if (!paths.length) return []
const driveHostMap = await mergedDriveHostMap(form)
return collectHostsForCopyPaths(paths, driveHostMap)
}
export async function buildNetInfoForForm(form: DistributeFormState): Promise<NetInfoCredential[]> {
const hosts = await resolveCopyNetworkHosts(form)
if (!hosts.length) return []
const netStore = useNetworkAuthStore()
return buildNetInfo(
hosts.map((h) => ({ hostName: h })),
(host) => netStore.getCredential(host)
)
}
@@ -0,0 +1,82 @@
import i18n from '@/i18n'
import { genTaskId } from '@shared/gen-task-id'
import { buildJobConfig } from '@/utils/buildJobConfig'
import { resolveJobTasks } from '@/utils/validateJobConfig'
import { buildNetInfoForForm } from '@/utils/copyNetworkHosts'
import { dllJobCreate, fsWriteJobCsv } from '@/api/cardsoon'
import { useJobStore } from '@/stores/job'
import type { DistributeFormState } from '@/stores/distributeForm'
function t(key: string, named?: Record<string, unknown>): string {
return i18n.global.t(key, named ?? {})
}
function printFieldRows(form: DistributeFormState) {
return (form.templatePreview?.fields ?? []).map((f) => ({
originName: f.originName || f.label.replace(/\[.*\]$/, ''),
value: f.value ?? ''
}))
}
async function buildJobJson(
form: DistributeFormState
): Promise<{ ok: true; json: string } | { ok: false; message: string }> {
const { hasCopy, hasPrint } = resolveJobTasks(form)
if (!hasCopy && !hasPrint) {
return { ok: false, message: t('validation.noTask') }
}
const taskId = genTaskId()
let udfFile = ''
if (hasPrint) {
const rows = printFieldRows(form)
if (rows.length > 0) {
const csv = await fsWriteJobCsv({ taskId, rows })
if (!csv.ok || !csv.data?.path) {
return { ok: false, message: csv.message || t('validation.csvGenFailed') }
}
udfFile = csv.data.path
}
}
let netInfo: Awaited<ReturnType<typeof buildNetInfoForForm>> = []
if (hasCopy) {
try {
netInfo = await buildNetInfoForForm(form)
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : String(e) }
}
}
try {
return {
ok: true,
json: JSON.stringify(buildJobConfig(form, { taskId, udfFile, netInfo }))
}
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : String(e) }
}
}
export async function createDistributeJob(
form: DistributeFormState,
opts?: { resubmit?: boolean }
): Promise<{ ok: true; jobId: string } | { ok: false; message: string }> {
const jobStore = useJobStore()
let json: string
if (opts?.resubmit && jobStore.lastJobJson) {
json = jobStore.lastJobJson
} else {
const built = await buildJobJson(form)
if (!built.ok) return built
json = built.json
jobStore.setLastJobJson(json)
}
const created = await dllJobCreate(json, opts)
if (!created.ok || !created.data?.jobId) {
return { ok: false, message: created.message || t('validation.createJobFailed') }
}
return { ok: true, jobId: created.data.jobId }
}
-4
View File
@@ -58,7 +58,3 @@ export function mapJobStateToUi(jobState: number) {
} }
} }
} }
export function shouldUseProgress(jobState: number): boolean {
return jobState === 2 || jobState === 3
}
@@ -1,10 +1,20 @@
import type { DistributeFormState } from '@/stores/distributeForm' import type { DistributeFormState } from '@/stores/distributeForm'
import i18n from '@/i18n'
function t(key: string, named?: Record<string, unknown>): string {
return i18n.global.t(key, named ?? {})
}
export function resolveJobTasks(f: DistributeFormState): { hasCopy: boolean; hasPrint: boolean } {
const hasCopy = f.pathList.some((p) => !!p.path.trim())
const hasPrint = !!f.templateFile.trim()
return { hasCopy, hasPrint }
}
export function validateJobConfig(f: DistributeFormState): string | null { export function validateJobConfig(f: DistributeFormState): string | null {
const hasCopy = f.pathList.length > 0 const { hasCopy, hasPrint } = resolveJobTasks(f)
const hasPrint = !!f.templateFile.trim() if (!hasCopy && !hasPrint) return t('validation.noTask')
if (!hasCopy && !hasPrint) return '请配置拷贝路径或打印模板' if (hasPrint && !/\.soon$/i.test(f.templateFile.trim())) return t('validation.invalidTemplate')
if (hasPrint && !/\.soon$/i.test(f.templateFile.trim())) return '请选择 .soon 模板' if (hasCopy && f.pathList.some((p) => !p.path.trim())) return t('validation.pathEmpty')
if (hasCopy && f.pathList.some((p) => !p.path.trim())) return '路径不能为空'
return null return null
} }
@@ -0,0 +1,126 @@
import type { DistributeFormState } from '@/stores/distributeForm'
import i18n from '@/i18n'
import { fsPathExists, dllPrinterStatus } from '@/api/cardsoon'
import { resolveJobTasks, validateJobConfig } from '@/utils/validateJobConfig'
import { resolveCopyNetworkHosts } from '@/utils/copyNetworkHosts'
import { useNetworkAuthStore } from '@/stores/networkAuth'
import { useConfigStore } from '@/stores/config'
function t(key: string, named?: Record<string, unknown>): string {
return i18n.global.t(key, named ?? {})
}
function totalCopyBytes(form: DistributeFormState): number {
return form.pathList.reduce((sum, item) => sum + (item.sizeBytes || 0), 0)
}
export function printFlagMismatch(printFlag: number, templateFlag: number): boolean {
return (
(printFlag === 1 && templateFlag !== 1) ||
(printFlag === 2 && templateFlag === 3) ||
(printFlag === 3 && templateFlag === 2)
)
}
/**
* 提交前打印机硬件状态预检(分发/收集共用)。
* - 返回非 null 字符串:拦截提交并提示(未连接 / 设备故障码,如夹卡)
* - 返回 null:放行(含 DLL 不支持状态查询、查询异常等无法判断的情况,交给后续流程)
* 注:夹卡等具体故障码表待 DLL 侧提供后,可在此按 statusCode 精确映射文案。
*/
export async function preflightPrinterStatus(): Promise<string | null> {
try {
const r = await dllPrinterStatus()
if (!r.ok) {
const msg = r.message || ''
// DLL 不支持状态查询时无法判断,放行交给后续流程
if (msg.includes('不支持') || msg.toLowerCase().includes('not supported')) {
return null
}
return t('validation.printerNotReady')
}
const data = r.data
if (!data) return t('validation.printerNotReady')
// 健康:主进程返回了明确的非负状态码(0 空闲 / 忙碌 / 打印中)
if (typeof data.statusCode === 'number' && data.statusCode >= 0) return null
// 缓存兜底 + liveError:实时查询失败(断连 / 故障码)
if (data.fromCache && data.liveError) return t('validation.printerNotReady')
// 无状态码(未初始化 / 未知异常):保守拦截
return t('validation.printerNotReady')
} catch {
// 查询异常时不阻塞,让后续流程处理
return null
}
}
export async function validateJobPreflight(form: DistributeFormState): Promise<string | null> {
const err = validateJobConfig(form)
if (err) return err
// 打印机硬件预检:打印/拷贝任务都需要设备,未连接或故障码(如夹卡)时拦截提交
const { hasCopy, hasPrint } = resolveJobTasks(form)
if (hasPrint || hasCopy) {
const fault = await preflightPrinterStatus()
if (fault) return fault
}
if (hasCopy) {
const paths = form.pathList.map((x) => x.path)
if (paths.length > 0) {
const ex = await fsPathExists(paths)
if (ex.ok && ex.data?.missing.length) {
return t('validation.pathNotExist', { paths: ex.data.missing.join(', ') })
}
if (totalCopyBytes(form) <= 0) {
return t('validation.noFilesToCopy')
}
}
const hosts = await resolveCopyNetworkHosts(form)
if (hosts.length > 0) {
const netStore = useNetworkAuthStore()
for (const host of hosts) {
const cred = netStore.getCredential(host)
if (!cred?.userName?.trim() || !cred.password) {
return t('validation.netCredMissing', { host })
}
}
}
}
if (hasPrint) {
const soon = form.templateFile.trim()
const ex = await fsPathExists([soon])
if (ex.ok && ex.data?.missing.length) {
return t('validation.templateNotExist')
}
const preview = form.templatePreview
if (preview && printFlagMismatch(form.printFlag, preview.templateFlag)) {
return t('validation.printFlagMismatch')
}
// 单面打印机:双面模板必须显式选择正面或背面,且不能选双面
const configStore = useConfigStore()
if (configStore.printer.isSingleSide) {
if (preview && preview.templateFlag === 1 && form.printFlag !== 2 && form.printFlag !== 3) {
return t('validation.pickSideRequired')
}
if (form.printFlag === 1) {
return t('validation.singleSideMustPick')
}
}
}
if (form.dongleEnabled) {
if (!form.dongleAuthCode.trim()) {
return t('validation.dongleAuthRequired')
}
const n = form.dongleInstallCount
if (!Number.isInteger(n) || n < 0 || n > 101) {
return t('validation.dongleCountInvalid')
}
}
return null
}
+28 -18
View File
@@ -1,12 +1,12 @@
<template> <template>
<AppShell> <AppShell>
<AppHeader mode="数据收集模式"> <AppHeader :mode="t('header.modeCollect')">
<div class="c-nav-group"> <div class="c-nav-group">
<NavButton icon="home" label="首页" @click="goHome" /> <NavButton icon="home" :label="t('common.home')" @click="goHome" />
<NavButton icon="trash" label="清空" @click="collectStore.reset()" /> <NavButton icon="trash" :label="t('common.clear')" @click="collectStore.reset()" />
<NavButton <NavButton
icon="check-circle" icon="check-circle"
label="提交" :label="t('common.submit')"
variant="primary" variant="primary"
:active="true" :active="true"
:disabled="!canSubmit" :disabled="!canSubmit"
@@ -18,32 +18,34 @@
<section class="m-config-panel"> <section class="m-config-panel">
<h3 class="m-panel-title"> <h3 class="m-panel-title">
<AppIcon name="folder-open" size="sm" /> <AppIcon name="folder-open" size="sm" />
数据导入地址 {{ t('dataCollect.importPath') }}
</h3> </h3>
<div class="m-path-box"> <div class="m-path-box">
<span class="m-path-text">{{ collectStore.destPath || '未选择目录' }}</span> <span class="m-path-text" :title="collectStore.destPath || undefined">{{
collectStore.destPath || t('dataCollect.noDirSelected')
}}</span>
</div> </div>
<button type="button" class="m-path-btn" @click="addPath"> <button type="button" class="m-path-btn" @click="addPath">
<AppIcon name="plus" size="sm" /> <AppIcon name="plus" size="sm" />
添加路径 {{ t('common.addPath') }}
</button> </button>
</section> </section>
<div class="m-divider-v" /> <div class="m-divider-v" />
<section class="m-config-panel"> <section class="m-config-panel">
<h3 class="m-panel-title"> <h3 class="m-panel-title">
<AppIcon name="exchange" size="sm" /> <AppIcon name="exchange" size="sm" />
出卡方向 {{ t('dataCollect.cardOutput') }}
</h3> </h3>
<div class="m-radio-group"> <div class="m-radio-group">
<label class="m-radio-item"> <label class="m-radio-item">
<input v-model="collectStore.cardOutput" type="radio" :value="1" /> <input v-model="collectStore.cardOutput" type="radio" :value="1" />
<span class="radio-custom" /> <span class="radio-custom" />
<span>向前出卡</span> <span>{{ t('dataCollect.forwardOutput') }}</span>
</label> </label>
<label class="m-radio-item"> <label class="m-radio-item">
<input v-model="collectStore.cardOutput" type="radio" :value="2" /> <input v-model="collectStore.cardOutput" type="radio" :value="2" />
<span class="radio-custom" /> <span class="radio-custom" />
<span>向后出卡</span> <span>{{ t('dataCollect.backwardOutput') }}</span>
</label> </label>
</div> </div>
</section> </section>
@@ -55,6 +57,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { notify, notifyRequireInit } from '@/composables/useNotify' import { notify, notifyRequireInit } from '@/composables/useNotify'
import AppShell from '@/layouts/AppShell.vue' import AppShell from '@/layouts/AppShell.vue'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
@@ -63,9 +66,11 @@ import NavButton from '@/components/NavButton.vue'
import AppIcon from '@/components/AppIcon.vue' import AppIcon from '@/components/AppIcon.vue'
import { useCollectStore } from '@/stores/collect' import { useCollectStore } from '@/stores/collect'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { dialogOpenDirectory, dllUsbCopy, pollUsbStart } from '@/api/cardsoon' import { dialogOpenDirectory, dllUsbCopy } from '@/api/cardsoon'
import { preflightPrinterStatus } from '@/utils/validateJobPreflight'
const router = useRouter() const router = useRouter()
const { t } = useI18n()
const collectStore = useCollectStore() const collectStore = useCollectStore()
const appStore = useAppStore() const appStore = useAppStore()
@@ -77,7 +82,7 @@ const canSubmit = computed(
async function addPath(): Promise<void> { async function addPath(): Promise<void> {
const r = await dialogOpenDirectory() const r = await dialogOpenDirectory()
if (!r.ok) { if (!r.ok) {
notify.error(r.message || '打开目录选择失败') notify.error(r.message || t('notify.pathOpenFailed'))
return return
} }
const picked = r.data?.paths[0] const picked = r.data?.paths[0]
@@ -86,31 +91,36 @@ async function addPath(): Promise<void> {
async function onSubmit(): Promise<void> { async function onSubmit(): Promise<void> {
if (!canUse.value) { if (!canUse.value) {
notifyRequireInit('开始 USB 收集') notifyRequireInit(t('notify.startUsbFailed'))
return return
} }
if (appStore.mode === 'distributing') { if (appStore.mode === 'distributing') {
notify.warning('请先停止数据分发任务') notify.warning(t('notify.stopDistribute'))
return return
} }
const dest = collectStore.destPath.trim() const dest = collectStore.destPath.trim()
if (!dest) { if (!dest) {
notify.warning('请先选择数据导入目录') notify.warning(t('notify.selectDirFirst'))
return
}
// 打印机硬件预检:未连接或故障码(如夹卡)时拦截提交
const fault = await preflightPrinterStatus()
if (fault) {
notify.warning(fault)
return return
} }
const r = await dllUsbCopy(dest, collectStore.cardOutput) const r = await dllUsbCopy(dest, collectStore.cardOutput)
if (!r.ok) { if (!r.ok) {
notify.error(r.message || '可能已有任务在执行') notify.error(r.message || t('notify.startUsbFailed'))
return return
} }
appStore.setMode('usbCopying') appStore.setMode('usbCopying')
await pollUsbStart()
await router.push('/collect/running') await router.push('/collect/running')
} }
function goHome(): void { function goHome(): void {
if (appStore.mode === 'usbCopying') { if (appStore.mode === 'usbCopying') {
notify.warning('数据收集进行中,请先在任务页停止') notify.warning(t('notify.usbCollectInProgress'))
return return
} }
router.push('/home') router.push('/home')
@@ -1,12 +1,12 @@
<template> <template>
<AppShell> <AppShell>
<AppHeader mode="数据分发模式"> <AppHeader :mode="t('header.modeDistribute')">
<div class="c-nav-group"> <div class="c-nav-group">
<NavButton icon="home" label="首页" @click="router.push('/home')" /> <NavButton icon="home" :label="t('common.home')" @click="router.push('/home')" />
<NavButton icon="trash" label="清空" @click="onClear" /> <NavButton icon="trash" :label="t('common.clear')" @click="onClear" />
<NavButton <NavButton
icon="check-circle" icon="check-circle"
label="提交" :label="t('common.submit')"
variant="primary" variant="primary"
:active="true" :active="true"
:disabled="!canSubmit" :disabled="!canSubmit"
@@ -17,43 +17,77 @@
<main class="app-shell__main l-main-flex"> <main class="app-shell__main l-main-flex">
<section class="c-panel m-panel--left"> <section class="c-panel m-panel--left">
<div class="c-panel__header"> <div class="c-panel__header">
<span class="c-panel__title">路径配置</span> <span class="c-panel__title">{{ t('distributeConfig.pathConfig') }}</span>
<div class="c-nav-group"> <div class="c-nav-group">
<button type="button" class="c-button-cs" @click="addPath"> <button type="button" class="c-button-cs" @click="addPath">
添加路径 {{ t('common.addPath') }}
</button>
<button type="button" class="c-button-cs" @click="openNetworkDialog">
{{ t('distributeConfig.addNetworkLocation') }}
</button> </button>
</div> </div>
</div> </div>
<div class="m-path-hint"> <div class="m-path-hint">
<AppIcon name="info-circle" size="sm" /> <AppIcon name="info-circle" size="sm" />
<span>系统将拷贝该目录下的所有子项但不包含文件夹本身</span> <span>{{ t('distributeConfig.pathHint') }}</span>
</div>
<div v-if="configuredHosts.length" class="m-net-cred-hint">
{{ t('distributeConfig.netCredConfigured', { hosts: configuredHosts.join('') }) }}
</div> </div>
<div class="m-panel-toolbar"> <div class="m-panel-toolbar">
<div class="toolbar-row"> <div class="toolbar-row">
<div class="toolbar-item"> <div class="toolbar-item">
<span>卷标</span> <span>{{ t('distributeConfig.volumeLabel') }}</span>
<input v-model="formStore.volumeLabel" type="text" class="c-input" /> <input v-model="formStore.volumeLabel" type="text" class="c-input" />
</div> </div>
<div class="toolbar-item"> <div class="toolbar-item">
<span>拷贝类型</span> <span>{{ t('distributeConfig.copyType') }}</span>
<AppSelect v-model="formStore.copyType" :items="COPY_TYPE_OPTIONS" /> <AppSelect v-model="formStore.copyType" :items="copyTypeItems" />
</div> </div>
</div> </div>
<div class="toolbar-row"> <div class="toolbar-row">
<div class="toolbar-item"> <div class="toolbar-item">
<span>格式化类型</span> <span>{{ t('distributeConfig.formatType') }}</span>
<AppSelect v-model="formStore.formatType" :items="FORMAT_TYPE_OPTIONS" /> <AppSelect v-model="formStore.formatType" :items="formatTypeItems" />
</div> </div>
<label class="c-checkbox-item"> <label class="c-checkbox-item">
<input v-model="formStore.dongleEnabled" type="checkbox" />
<span>加密狗</span>
<input <input
v-model.number="formStore.dongleMode" v-model="formStore.dongleEnabled"
type="checkbox"
@change="onDongleToggle"
/>
<span>{{ t('distributeConfig.dongle') }}</span>
<input
:value="dongleInputValue"
type="number" type="number"
class="c-input dog-count" class="c-input dog-count"
min="0"
max="101"
step="1"
:disabled="!formStore.dongleEnabled" :disabled="!formStore.dongleEnabled"
@input="onDongleCountInput"
/> />
<span class="dog-hint">{{ dongleHint }}</span> <span class="dog-hint">{{ dongleHint }}</span>
<template v-if="formStore.dongleEnabled">
<span class="dog-auth-label">{{ t('distributeConfig.dongleAuthLabel') }}</span>
<div class="dog-auth-wrap">
<input
v-model="formStore.dongleAuthCode"
:type="showAuthCode ? 'text' : 'password'"
class="c-input dog-auth"
:placeholder="t('distributeConfig.donglePassword')"
@input="onDongleAuthInput"
/>
<button
type="button"
class="dog-auth-toggle"
:title="showAuthCode ? t('distributeConfig.hideAuth') : t('distributeConfig.showAuth')"
@click="showAuthCode = !showAuthCode"
>
<AppIcon :name="showAuthCode ? 'eye-slash' : 'eye'" size="sm" />
</button>
</div>
</template>
</label> </label>
</div> </div>
</div> </div>
@@ -77,15 +111,51 @@
</section> </section>
<section class="c-panel m-panel--right"> <section class="c-panel m-panel--right">
<div class="c-panel__header"> <div class="c-panel__header">
<span class="c-panel__title">标签预览</span> <span class="c-panel__title">{{ t('distributeConfig.templatePreview') }}</span>
<div class="c-nav-group"> <div class="c-nav-group">
<button type="button" class="c-button-cs" :disabled="!canUse" @click="pickTemplate"> <button type="button" class="c-button-cs" @click="pickTemplate">
添加标签 {{ t('distributeConfig.addLabel') }}
</button>
<button
v-if="formStore.templateFile"
type="button"
class="c-button-cs"
@click="removeTemplate"
>
{{ t('distributeConfig.removeLabel') }}
</button> </button>
</div> </div>
</div> </div>
<div v-if="templatePreviewLoaded && hasDoubleSide" class="m-print-side-picker">
<template v-if="isSingleSidePrinter">
<label class="m-print-side-option">
<input v-model="formStore.printFlag" type="radio" :value="2" />
<span>{{ t('distributeConfig.frontSide') }}</span>
</label>
<label class="m-print-side-option">
<input v-model="formStore.printFlag" type="radio" :value="3" />
<span>{{ t('distributeConfig.backSide') }}</span>
</label>
<span v-if="!sidePicked" class="m-print-side-hint">
{{ t('distributeConfig.singleSideHint') }}
</span>
</template>
<template v-else>
<label class="m-print-side-option">
<input v-model="formStore.printFlag" type="radio" :value="1" />
<span>{{ t('distributeConfig.doubleSide') }}</span>
</label>
</template>
</div>
<div class="c-preview-area"> <div class="c-preview-area">
<div class="c-card-small c-card-small--slot"> <div
class="c-card-small c-card-small--slot c-card-side-pick"
:class="{
'c-card-side--dim': formStore.printFlag === 3,
'c-card-side-pick--off': !canPickFront
}"
@click="selectPrintSide(2)"
>
<img <img
v-if="formStore.templatePreview?.frontImageUrl" v-if="formStore.templatePreview?.frontImageUrl"
class="c-card-small__img" class="c-card-small__img"
@@ -94,7 +164,14 @@
/> />
<span v-else class="c-card-side-label">FRONT</span> <span v-else class="c-card-side-label">FRONT</span>
</div> </div>
<div class="c-card-small c-card-small--slot c-card-small--back"> <div
class="c-card-small c-card-small--slot c-card-small--back c-card-side-pick"
:class="{
'c-card-side--dim': formStore.printFlag === 2,
'c-card-side-pick--off': !canPickBack
}"
@click="selectPrintSide(3)"
>
<img <img
v-if="formStore.templatePreview?.backImageUrl" v-if="formStore.templatePreview?.backImageUrl"
class="c-card-small__img" class="c-card-small__img"
@@ -109,7 +186,21 @@
<table class="c-data-table-mini"> <table class="c-data-table-mini">
<tr v-for="(row, idx) in formStore.templatePreview!.fields" :key="idx"> <tr v-for="(row, idx) in formStore.templatePreview!.fields" :key="idx">
<td>{{ row.label }}</td> <td>{{ row.label }}</td>
<td> <td v-if="isImageField(row)" class="c-path-cell">
<span class="c-path-cell__text" :title="row.value">{{
imageFieldLabel(row.value)
}}</span>
<button type="button" class="c-field-pick" @click="pickFieldImage(idx)">{{ t('common.select') }}</button>
</td>
<td v-else-if="isTextField(row)">
<input
v-model="row.value"
type="text"
class="c-field-input"
:placeholder="row.label"
/>
</td>
<td v-else>
<span class="c-field-value" :title="row.value">{{ row.value || '—' }}</span> <span class="c-field-value" :title="row.value">{{ row.value || '—' }}</span>
</td> </td>
</tr> </tr>
@@ -119,12 +210,21 @@
</section> </section>
</main> </main>
<AppFooter /> <AppFooter />
<NetworkPathDialog
v-model:visible="networkDialogVisible"
:initial-host="networkDialogHost"
:initial-share="networkDialogShare"
@confirm="onNetworkConfirm"
/>
</AppShell> </AppShell>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { cleanPathPattern } from '@shared/path-pattern'
import { extractDriveLetter } from '@shared/network-host'
import { notify, notifyRequireInit } from '@/composables/useNotify' import { notify, notifyRequireInit } from '@/composables/useNotify'
import AppShell from '@/layouts/AppShell.vue' import AppShell from '@/layouts/AppShell.vue'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
@@ -132,61 +232,194 @@ import AppFooter from '@/components/AppFooter.vue'
import NavButton from '@/components/NavButton.vue' import NavButton from '@/components/NavButton.vue'
import AppIcon from '@/components/AppIcon.vue' import AppIcon from '@/components/AppIcon.vue'
import AppSelect from '@/components/AppSelect.vue' import AppSelect from '@/components/AppSelect.vue'
import { COPY_TYPE_OPTIONS, FORMAT_TYPE_OPTIONS } from '@/constants/selectOptions' import NetworkPathDialog from '@/components/NetworkPathDialog.vue'
import { copyTypeOptions, formatTypeOptions } from '@/constants/selectOptions'
import { CARD_CAPACITY_BYTES, CARD_CAPACITY_GB } from '@/constants/cardCapacity' import { CARD_CAPACITY_BYTES, CARD_CAPACITY_GB } from '@/constants/cardCapacity'
import { useDistributeFormStore } from '@/stores/distributeForm' import { useDistributeFormStore } from '@/stores/distributeForm'
import { useJobStore } from '@/stores/job' import { useJobStore } from '@/stores/job'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { validateJobConfig } from '@/utils/validateJobConfig' import { useConfigStore } from '@/stores/config'
import { buildJobConfig } from '@/utils/buildJobConfig' import { useNetworkAuthStore } from '@/stores/networkAuth'
import { useDongleAuthStore } from '@/stores/dongleAuth'
import { validateJobPreflight } from '@/utils/validateJobPreflight'
import { createDistributeJob } from '@/utils/createDistributeJob'
import { formatBytesAsGb, formatBytesCompact } from '@/utils/formatBytes' import { formatBytesAsGb, formatBytesCompact } from '@/utils/formatBytes'
import { dialogOpenDirectory, dialogOpenSoon, dllJobCreate, fsDirSize, fsParseSoon, fsPathExists } from '@/api/cardsoon' import type { TemplateFieldRow } from '@/stores/distributeForm'
import {
dialogOpenDirectory,
dialogOpenImage,
dialogOpenSoon,
dllJobCancel,
fsDirSize,
fsParseSoon,
fsResolveNetworkHosts
} from '@/api/cardsoon'
const router = useRouter() const router = useRouter()
const { t } = useI18n()
const formStore = useDistributeFormStore() const formStore = useDistributeFormStore()
const jobStore = useJobStore() const jobStore = useJobStore()
const appStore = useAppStore() const appStore = useAppStore()
const configStore = useConfigStore()
const netStore = useNetworkAuthStore()
const dongleStore = useDongleAuthStore()
const networkDialogVisible = ref(false)
const networkDialogHost = ref('')
const networkDialogShare = ref('')
const showAuthCode = ref(false)
// 单面打印机检测到后,重置 printFlag(避免默认双面)
watch(
() => configStore.printer.isSingleSide,
(isSingle) => {
if (isSingle && formStore.printFlag === 1) {
const flag = formStore.templatePreview?.templateFlag ?? 1
formStore.printFlag = defaultPrintFlagForTemplate(flag, true)
}
}
)
let dongleAuthPersistTimer: ReturnType<typeof setTimeout> | null = null
const canUse = computed(() => appStore.initialized) const canUse = computed(() => appStore.initialized)
const canSubmit = computed( const canSubmit = computed(
() => canUse.value && !jobStore.submitting && appStore.mode !== 'usbCopying' () => canUse.value && !jobStore.submitting && appStore.mode !== 'usbCopying'
) )
const configuredHosts = computed(() => netStore.configuredHosts)
const totalLoadedBytes = computed(() => const totalLoadedBytes = computed(() =>
formStore.pathList.reduce((sum, item) => sum + (item.sizeBytes || 0), 0) formStore.pathList.reduce((sum, item) => sum + (item.sizeBytes || 0), 0)
) )
const loadPercent = computed(() => { const loadPercent = computed(() => {
if (!formStore.pathList.length) return 0 if (!formStore.pathList.length) return 0
return Math.min(100, Math.round((totalLoadedBytes.value / CARD_CAPACITY_BYTES) * 100)) const pct = Math.min(100, Math.round((totalLoadedBytes.value / CARD_CAPACITY_BYTES) * 100))
return pct > 0 ? pct : 1
}) })
const hasTemplatePreview = computed( const hasTemplatePreview = computed(
() => !!formStore.templatePreview && formStore.templatePreview.fields.length > 0 () => !!formStore.templatePreview && formStore.templatePreview.fields.length > 0
) )
const templateFlag = computed(() => formStore.templatePreview?.templateFlag ?? 0)
const hasDoubleSide = computed(() => templateFlag.value === 1)
const isSingleSidePrinter = computed(() => configStore.printer.isSingleSide)
/** 模板已加载(无论有无可编辑字段),用于选面区显示 */
const templatePreviewLoaded = computed(() => !!formStore.templatePreview)
/** 单面打印机 + 双面模板时,必须显式选择正面或背面 */
const sidePicked = computed(
() => formStore.printFlag === 2 || formStore.printFlag === 3
)
const copyTypeItems = computed(() => copyTypeOptions(t))
const formatTypeItems = computed(() => formatTypeOptions(t))
const canPickFront = computed(() => templateFlag.value !== 3)
const canPickBack = computed(() => templateFlag.value !== 2)
const loadProgressText = computed(() => { const loadProgressText = computed(() => {
const loadedGb = formatBytesAsGb(totalLoadedBytes.value) const loadedGb = formatBytesAsGb(totalLoadedBytes.value)
return `已加载: ${loadedGb} GB / ${CARD_CAPACITY_GB} GB (${loadPercent.value}%)` return t('distributeConfig.loadProgress', { loaded: loadedGb, total: CARD_CAPACITY_GB, percent: loadPercent.value })
}) })
function isTextField(row: TemplateFieldRow): boolean {
return row.fieldType === 3 || row.fieldType === 4 || row.fieldType === 5
}
function isImageField(row: TemplateFieldRow): boolean {
return row.fieldType === 1
}
function imageFieldLabel(value: string): string {
const v = value.trim()
if (!v) return '未选择图片'
const parts = v.replace(/\\/g, '/').split('/')
return parts[parts.length - 1] || v
}
function defaultPrintFlagForTemplate(flag: number, isSingleSide: boolean): number {
// 单面打印机 + 双面模板:不默认,强制用户选择打印面(0=未选择)
if (isSingleSide && flag === 1) return 0
if (flag === 1 || flag === 2 || flag === 3) return flag
return 2
}
function selectPrintSide(flag: 2 | 3): void {
if (flag === 2 && !canPickFront.value) return
if (flag === 3 && !canPickBack.value) return
formStore.printFlag = flag
}
async function pickFieldImage(idx: number): Promise<void> {
const preview = formStore.templatePreview
if (!preview) return
const row = preview.fields[idx]
if (!row || !isImageField(row)) return
const r = await dialogOpenImage()
if (!r.ok) {
notify.error(r.message || '打开图片选择失败')
return
}
const picked = r.data?.path?.trim()
if (!picked) return
row.value = picked
}
function onClear(): void { function onClear(): void {
formStore.reset() formStore.reset()
notify.info('已清空,已恢复初始状态') notify.info(t('distributeConfig.cleared'))
} }
const dongleHint = computed(() => { function removeTemplate(): void {
if (!formStore.dongleEnabled) return '(未启用)' formStore.templateFile = ''
if (formStore.dongleMode === -1) return '-1 无加密狗)' formStore.templatePreview = null
if (formStore.dongleMode === 0) return '0 一次性)' formStore.printFlag = 1
if (formStore.dongleMode === 255) return '255 无限次)' notify.info(t('distributeConfig.templateRemoved'))
return '0=一次 255=无限)' }
})
const dongleInputValue = computed(() =>
formStore.dongleEnabled ? formStore.dongleInstallCount : -1
)
const dongleHint = computed(() =>
formStore.dongleEnabled ? '101 为不限次数)' : ''
)
function clampDongleCount(n: number): number {
return Math.min(101, Math.max(0, Math.round(n)))
}
function onDongleToggle(): void {
if (formStore.dongleEnabled) {
formStore.dongleInstallCount = 0
}
}
function onDongleCountInput(e: Event): void {
if (!formStore.dongleEnabled) return
const raw = Number((e.target as HTMLInputElement).value)
if (!Number.isFinite(raw)) return
formStore.dongleInstallCount = clampDongleCount(raw)
}
function onDongleAuthInput(): void {
if (dongleAuthPersistTimer) clearTimeout(dongleAuthPersistTimer)
dongleAuthPersistTimer = setTimeout(() => {
void dongleStore.persist(formStore.dongleAuthCode)
}, 300)
}
async function addPath(): Promise<void> { async function addPath(): Promise<void> {
const r = await dialogOpenDirectory() const r = await dialogOpenDirectory()
if (!r.ok) { if (!r.ok) {
notify.error(r.message || '打开目录选择失败') notify.error(r.message || t('notify.pathOpenFailed'))
return return
} }
if (!r.data?.paths.length) return if (!r.data?.paths.length) return
@@ -194,7 +427,7 @@ async function addPath(): Promise<void> {
const idx = formStore.pathList.length const idx = formStore.pathList.length
formStore.pathList.push({ formStore.pathList.push({
path: `${dir}\\*.*`, path: `${dir}\\*.*`,
meta: '计算中…', meta: t('distributeConfig.calculating'),
sizeBytes: 0 sizeBytes: 0
}) })
await refreshPathSize(idx, dir) await refreshPathSize(idx, dir)
@@ -218,11 +451,42 @@ function removePath(idx: number): void {
formStore.pathList.splice(idx, 1) formStore.pathList.splice(idx, 1)
} }
async function pickTemplate(): Promise<void> { async function openNetworkDialog(): Promise<void> {
if (!canUse.value) { networkDialogHost.value = ''
notifyRequireInit('选择打印模板') networkDialogShare.value = ''
return for (const item of formStore.pathList) {
const dir = cleanPathPattern(item.path)
const letter = extractDriveLetter(dir)
if (!letter) continue
const r = await fsResolveNetworkHosts([dir])
if (r.ok && r.data?.driveHostMap?.[letter]) {
networkDialogHost.value = r.data.driveHostMap[letter]
break
}
if (r.ok && r.data?.hosts?.length) {
networkDialogHost.value = r.data.hosts[0]
break
}
} }
networkDialogVisible.value = true
}
function onNetworkConfirm(payload: {
path: string
hostName: string
userName: string
password: string
}): void {
netStore.setCredential(payload.hostName, payload.userName, payload.password)
for (const item of formStore.pathList) {
const letter = extractDriveLetter(cleanPathPattern(item.path))
if (letter) netStore.setDriveHost(letter, payload.hostName)
}
networkDialogVisible.value = false
notify.success('网络凭据已保存')
}
async function pickTemplate(): Promise<void> {
const r = await dialogOpenSoon() const r = await dialogOpenSoon()
if (!r.ok) { if (!r.ok) {
notify.error(r.message || '打开模板选择失败') notify.error(r.message || '打开模板选择失败')
@@ -237,12 +501,16 @@ async function pickTemplate(): Promise<void> {
} }
formStore.templateFile = soonPath formStore.templateFile = soonPath
formStore.templatePreview = parsed.data formStore.templatePreview = parsed.data
formStore.printFlag = defaultPrintFlagForTemplate(
parsed.data.templateFlag,
isSingleSidePrinter.value
)
const { fields, frontImageUrl, backImageUrl } = parsed.data const { fields, frontImageUrl, backImageUrl } = parsed.data
if (!fields.length && !frontImageUrl && !backImageUrl) { if (!fields.length && !frontImageUrl && !backImageUrl) {
notify.warning('模板已打开,但未解析到可预览内容') notify.warning(t('distributeConfig.templateNoPreview'))
return return
} }
notify.success('已加载标签模板') notify.success(t('distributeConfig.templateLoaded'))
} }
async function onSubmit(): Promise<void> { async function onSubmit(): Promise<void> {
@@ -255,37 +523,31 @@ async function onSubmit(): Promise<void> {
return return
} }
if (jobStore.submitting) return if (jobStore.submitting) return
const err = validateJobConfig(formStore) const err = await validateJobPreflight(formStore)
if (err) { if (err) {
notify.warning(err) notify.warning(err)
return return
} }
const paths = formStore.pathList.map((x) => x.path)
if (paths.length) {
const ex = await fsPathExists(paths)
if (ex.ok && ex.data?.missing.length) {
notify.error(`路径不存在: ${ex.data.missing.join(', ')}`)
return
}
}
if (formStore.templateFile.trim()) {
const ex = await fsPathExists([formStore.templateFile.trim()])
if (ex.ok && ex.data?.missing.length) {
notify.error('模板文件不存在')
return
}
}
jobStore.submitting = true jobStore.submitting = true
// 新任务开始前重置次数计数器(不重置 jobId/lastJobJson,避免影响并发逻辑)
jobStore.resetCounts()
try { try {
const json = JSON.stringify(buildJobConfig(formStore)) const created = await createDistributeJob(formStore)
const created = await dllJobCreate(json) if (!created.ok) {
if (!created.ok || !created.data?.jobId) { notify.error(created.message)
notify.error(created.message || '创建任务失败')
return return
} }
jobStore.setActiveJob(created.data.jobId) const newJobId = created.jobId
jobStore.setActiveJob(newJobId)
appStore.setMode('distributing') appStore.setMode('distributing')
await router.push('/distribute/running') try {
await router.push('/distribute/running')
} catch {
await dllJobCancel(newJobId)
jobStore.clearActiveJob()
appStore.setMode('ready')
notify.error(t('notify.cannotEnterRunning'))
}
} finally { } finally {
jobStore.submitting = false jobStore.submitting = false
} }
@@ -295,6 +557,32 @@ async function onSubmit(): Promise<void> {
<style src="@/styles/pages/page4.css"></style> <style src="@/styles/pages/page4.css"></style>
<style scoped> <style scoped>
.m-net-cred-hint {
margin: 0 8px 6px;
font-size: 12px;
color: #606266;
}
.m-print-side-picker {
display: flex;
gap: 16px;
padding: 6px 12px 0;
font-size: 12px;
color: #606266;
}
.m-print-side-option {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.m-print-side-hint {
color: #e6a23c;
font-weight: 500;
}
.c-card-small--slot { .c-card-small--slot {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -322,10 +610,15 @@ async function onSubmit(): Promise<void> {
background: #f8f9fa; background: #f8f9fa;
} }
.c-field-value { .c-card-side-pick {
display: block; cursor: pointer;
overflow: hidden; }
text-overflow: ellipsis;
white-space: nowrap; .c-card-side-pick--off {
cursor: not-allowed;
}
.c-card-side--dim {
opacity: 0.35;
} }
</style> </style>
@@ -1,84 +0,0 @@
<template>
<AppShell>
<AppHeader mode="数据分发模式">
<div class="c-nav-group">
<NavButton icon="arrow-left" label="返回" @click="onBack" />
<NavButton icon="redo" label="重置" variant="primary" @click="onReset" />
</div>
</AppHeader>
<main class="app-shell__main l-main-full">
<section class="l-hero-container">
<div class="m-left-panel">
<div class="c-status-panel">
<h2 class="c-status-title is-error">任务失败</h2>
<p class="c-status-sub">请检查设备故障后重新插入数据卡</p>
<p class="c-status-counter">
任务已经完成<span class="ok">{{ jobStore.successCount }}</span
>其中失败次数是<span class="err">{{ jobStore.failCount }}</span>
</p>
<p v-if="errorText" class="m-error-detail">{{ errorText }}</p>
</div>
<WorkflowSteps mode="failed" :failed-step="3" />
</div>
<div class="m-right-panel">
<div class="m-error-icon">
<AppIcon name="warning" size="xl" />
</div>
</div>
</section>
</main>
<AppFooter />
</AppShell>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
import NavButton from '@/components/NavButton.vue'
import AppIcon from '@/components/AppIcon.vue'
import WorkflowSteps from '@/components/WorkflowSteps.vue'
import { useDistributeFormStore } from '@/stores/distributeForm'
import { useJobStore } from '@/stores/job'
import { useAppStore } from '@/stores/app'
import { dllPrinterErrorStr, pollJobStop } from '@/api/cardsoon'
const router = useRouter()
const formStore = useDistributeFormStore()
const jobStore = useJobStore()
const appStore = useAppStore()
const errorText = ref('')
onMounted(async () => {
appStore.setMode('ready')
await pollJobStop()
const r = await dllPrinterErrorStr(-1)
errorText.value = r.ok && r.data?.text ? r.data.text : ''
})
function onBack(): void {
jobStore.clearActiveJob()
router.push('/distribute/config')
}
function onReset(): void {
formStore.reset()
jobStore.reset()
router.push('/home')
}
</script>
<style src="@/styles/pages/page3.css"></style>
<style scoped>
.m-error-detail {
margin-top: 6px;
font-size: 10px;
color: #dc3545;
font-weight: 600;
max-width: 320px;
word-break: break-all;
}
</style>
@@ -1,29 +1,58 @@
<template> <template>
<AppShell> <AppShell>
<AppHeader :mode="headerMode"> <AppHeader :mode="headerMode">
<NavButton icon="stop" label="停止" variant="stop" @click="onStop" /> <div v-if="phase === 'failed'" class="c-nav-group">
<NavButton icon="arrow-left" :label="t('common.back')" @click="onFailedBack" />
<NavButton icon="redo" :label="t('common.reset')" variant="primary" @click="onFailedReset" />
</div>
<NavButton
v-else-if="phase === 'completed'"
icon="home"
:label="t('common.back')"
@click="onReturn"
/>
<NavButton v-else icon="stop" :label="t('common.stop')" variant="stop" @click="onStop" />
</AppHeader> </AppHeader>
<main class="app-shell__main l-main-full"> <main class="app-shell__main l-main-full">
<section class="l-hero-container"> <section class="l-hero-container">
<div class="m-left-panel"> <div class="m-left-panel">
<div class="c-status-panel"> <div class="c-status-panel">
<h2 class="c-status-title is-looping">{{ statusTitle }}</h2> <template v-if="phase === 'failed'">
<p class="c-status-sub">{{ statusSub }}</p> <h2 class="c-status-title is-error">{{ t('running.taskFailed') }}</h2>
<p class="c-status-counter"> <p class="c-status-sub">{{ failedSub }}</p>
任务已经完成<span class="ok">{{ successCount }}</span>其中失败次数是<span <p class="c-status-counter">
class="err" {{ t('running.progressCounter', { success: successCount, fail: failCount }) }}
>{{ failCount }}</span </p>
> <p v-if="failureErrorText" class="m-error-detail">{{ failureErrorText }}</p>
</p> </template>
<template v-else>
<h2 class="c-status-title" :class="{ 'is-looping': phase === 'running' }">
{{ statusTitle }}
</h2>
<p class="c-status-sub">{{ statusSub }}</p>
<p class="c-status-counter">
{{ t('running.progressCounter', { success: successCount, fail: failCount }) }}
</p>
</template>
</div> </div>
<WorkflowSteps <WorkflowSteps
v-if="phase === 'failed'"
mode="failed"
:variant="isCollect ? 'collect' : 'distribute'"
:failed-step="failureStep"
/>
<WorkflowSteps
v-else
:active-step="workflowStep" :active-step="workflowStep"
:variant="isCollect ? 'collect' : 'distribute'" :variant="isCollect ? 'collect' : 'distribute'"
mode="running" mode="running"
/> />
</div> </div>
<div class="m-right-panel"> <div class="m-right-panel">
<div class="m-progress-circle"> <div v-if="phase === 'failed'" class="m-error-icon">
<AppIcon name="warning" size="xl" />
</div>
<div v-else class="m-progress-circle">
<svg viewBox="0 0 100 100"> <svg viewBox="0 0 100 100">
<circle class="bg" cx="50" cy="50" r="45" /> <circle class="bg" cx="50" cy="50" r="45" />
<circle <circle
@@ -46,135 +75,432 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { notify } from '@/composables/useNotify' import { notify } from '@/composables/useNotify'
import { refreshLiveStatus } from '@/composables/usePrinterStatus'
import AppShell from '@/layouts/AppShell.vue' import AppShell from '@/layouts/AppShell.vue'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue' import AppFooter from '@/components/AppFooter.vue'
import NavButton from '@/components/NavButton.vue' import NavButton from '@/components/NavButton.vue'
import AppIcon from '@/components/AppIcon.vue'
import WorkflowSteps from '@/components/WorkflowSteps.vue' import WorkflowSteps from '@/components/WorkflowSteps.vue'
import { useJobStore } from '@/stores/job' import { useJobStore } from '@/stores/job'
import { useCollectStore } from '@/stores/collect' import { useCollectStore } from '@/stores/collect'
import { useDistributeFormStore } from '@/stores/distributeForm'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { import {
dllJobCancel, dllJobCancel,
dllPrinterErrorStr,
dllUsbCopy,
onCardPositionTick,
onJobPollTick, onJobPollTick,
onUsbPollTick, onUsbPollTick,
pollCardPositionStart,
pollCardPositionStop,
pollJobStart, pollJobStart,
pollJobStop, pollJobStop,
pollUsbStop pollUsbStop
} from '@/api/cardsoon' } from '@/api/cardsoon'
import { mapJobStateToUi, shouldUseProgress } from '@/utils/job-state' import { createDistributeJob } from '@/utils/createDistributeJob'
import type { JobPollPayload, UsbPollPayload } from '@/types/ipc' import { validateJobPreflight } from '@/utils/validateJobPreflight'
import { mapJobStateToUi } from '@/utils/job-state'
import { POSITION_PREPARE, resolveCardPosition } from '@shared/card-position'
import {
USB_TASK_COPYING,
USB_TASK_PREPARING,
clampUsbCopyProgress,
usbTaskStatusHint
} from '@shared/usb-copy-state'
import type { CardPositionPollPayload, JobPollPayload, UsbPollPayload } from '@/types/ipc'
const CIRCLE_LEN = 283 const CIRCLE_LEN = 283
/** 等待进卡/读 U 盘超过该时长仍无进展,给出可操作提示(每个等待回合提示一次) */
const WAIT_WARN_MS = 45000
/** 看门狗检查间隔 */
const WAIT_WATCHDOG_MS = 5000
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { t } = useI18n()
const jobStore = useJobStore() const jobStore = useJobStore()
const collectStore = useCollectStore() const collectStore = useCollectStore()
const formStore = useDistributeFormStore()
const appStore = useAppStore() const appStore = useAppStore()
const isCollect = computed(() => route.name === 'collect-running') const isCollect = computed(() => route.name === 'collect-running')
const headerMode = computed(() => (isCollect.value ? '数据收集模式' : '数据分发模式')) const headerMode = computed(() => (isCollect.value ? t('header.modeCollect') : t('header.modeDistribute')))
const successCount = computed(() => const successCount = computed(() =>
isCollect.value ? collectStore.successCount : jobStore.successCount isCollect.value ? collectStore.successCount : jobStore.successCount
) )
const failCount = computed(() => (isCollect.value ? collectStore.failCount : jobStore.failCount)) const failCount = computed(() => (isCollect.value ? collectStore.failCount : jobStore.failCount))
const phase = ref<'running' | 'completed' | 'failed'>('running')
const progress = ref(0) const progress = ref(0)
const strokeOffset = ref(CIRCLE_LEN) const strokeOffset = ref(CIRCLE_LEN)
const workflowStep = ref(1) const workflowStep = ref(1)
const failureStep = ref(1)
const failureErrorText = ref('')
const waitCard = ref(false) const waitCard = ref(false)
let unsub: (() => void) | null = null const collectHint = ref('')
let fakeTimer: ReturnType<typeof setInterval> | null = null let unsubJob: (() => void) | null = null
let unsubUsb: (() => void) | null = null
let unsubCard: (() => void) | null = null
let finishing = false
let resubmitting = false
let lastCardPosition = -1
let usbAwaitNewCycle = false
let queryFailStreak = 0
let usbQueryFailStreak = 0
let waitSince = 0
let waitWarned = false
let waitWatchdog: ReturnType<typeof setInterval> | null = null
const displayProgress = computed(() => Math.round(progress.value)) const displayProgress = computed(() => Math.round(progress.value))
const statusTitle = computed(() => (waitCard.value ? '等待插卡' : '循环执行中')) const failedSub = computed(() =>
const statusSub = computed(() => isCollect.value
waitCard.value ? '请插入数据卡,任务将自动连续执行' : '任务将自动连续执行' ? t('running.failedSubCollect')
: t('running.failedSubDistribute')
) )
const statusTitle = computed(() => {
if (phase.value === 'completed') {
return isCollect.value ? t('running.collectCompleted') : t('running.taskCompleted')
}
if (isCollect.value) {
return collectHint.value || t('running.dataCollecting')
}
return waitCard.value ? t('running.waitingCard') : t('running.taskRunning')
})
const statusSub = computed(() => {
if (phase.value === 'completed') {
return isCollect.value
? t('running.completedSubCollect')
: t('running.completedSubDistribute')
}
if (isCollect.value) {
return collectHint.value || t('running.collectingHint')
}
return waitCard.value ? t('running.waitCardHint') : t('running.runningHint')
})
function setProgress(value: number): void { function setProgress(value: number): void {
const p = Math.min(100, Math.max(0, value)) const p = Math.min(100, Math.max(0, value))
progress.value = p progress.value = p
strokeOffset.value = CIRCLE_LEN - (CIRCLE_LEN * p) / 100 strokeOffset.value = CIRCLE_LEN - (CIRCLE_LEN * p) / 100
} }
function startFakeProgress(): void { function clearPollListeners(): void {
stopFakeProgress() unsubJob?.()
fakeTimer = setInterval(() => { unsubJob = null
if (progress.value >= 95) return unsubUsb?.()
setProgress(progress.value + 1.5 + Math.random() * 2.5) unsubUsb = null
}, 380) unsubCard?.()
unsubCard = null
} }
function stopFakeProgress(): void { /** 进入"等待进卡/读 U 盘"回合(已在等待中则不重复计时) */
if (fakeTimer) { function markWaiting(): void {
clearInterval(fakeTimer) if (waitSince === 0) {
fakeTimer = null waitSince = Date.now()
waitWarned = false
}
}
/** 离开等待状态(进卡成功 / 任务结束 / 重新提交) */
function clearWaiting(): void {
waitSince = 0
waitWarned = false
}
function stopWaitWatchdog(): void {
if (waitWatchdog) {
clearInterval(waitWatchdog)
waitWatchdog = null
}
clearWaiting()
}
function startWaitWatchdog(): void {
stopWaitWatchdog()
waitWatchdog = setInterval(() => {
if (phase.value !== 'running' || waitSince === 0 || waitWarned) return
if (Date.now() - waitSince < WAIT_WARN_MS) return
waitWarned = true
// 等待过久无进展:收集多为 U 盘数据线/卡片方向问题,分发多为卡片未到位/夹卡
notify.warning(
isCollect.value ? t('notify.waitTimeoutCollect') : t('notify.waitTimeoutDistribute')
)
}, WAIT_WATCHDOG_MS)
}
async function releasePolls(resetMode: boolean): Promise<void> {
clearPollListeners()
await pollCardPositionStop()
if (isCollect.value) {
await pollUsbStop({ resetMode })
} else {
await pollJobStop({ resetMode })
}
}
async function stopTaskPollsOnly(): Promise<void> {
unsubJob?.()
unsubJob = null
unsubUsb?.()
unsubUsb = null
if (isCollect.value) {
await pollUsbStop({ resetMode: false })
} else {
await pollJobStop({ resetMode: false })
}
}
async function startCardPositionWatch(
sessionMode: 'distributing' | 'usbCopying'
): Promise<void> {
await pollCardPositionStop()
unsubCard?.()
unsubCard = onCardPositionTick((payload) =>
applyCardPosition(payload as CardPositionPollPayload)
)
const cardStarted = await pollCardPositionStart(sessionMode)
if (!cardStarted.ok) {
notify.warning(cardStarted.message || t('notify.pollMonitorFailed'))
}
}
async function enterWaitPhase(next: 'completed' | 'failed', errorText = ''): Promise<void> {
clearWaiting()
if (phase.value !== next) {
phase.value = next
if (next === 'completed') {
setProgress(100)
workflowStep.value = isCollect.value ? 3 : 4
} else {
failureStep.value = workflowStep.value
failureErrorText.value = errorText
if (!failureErrorText.value && !isCollect.value) {
try {
const r = await dllPrinterErrorStr(-1)
failureErrorText.value = r.ok && r.data?.text ? r.data.text : ''
} catch {
failureErrorText.value = ''
}
}
jobStore.clearActiveJob()
}
} else if (next === 'failed' && errorText) {
failureErrorText.value = errorText
}
await stopTaskPollsOnly()
const sessionMode = isCollect.value ? 'usbCopying' : 'distributing'
appStore.setMode(sessionMode)
lastCardPosition = -1
await startCardPositionWatch(sessionMode)
void refreshLiveStatus()
}
async function enterFailedPhase(errorText = ''): Promise<void> {
await enterWaitPhase('failed', errorText)
}
async function enterCompletedPhase(): Promise<void> {
await enterWaitPhase('completed')
}
function canAutoResubmit(): boolean {
return phase.value === 'completed' || phase.value === 'failed'
}
function applyCardPosition(p: CardPositionPollPayload): void {
if (!canAutoResubmit() || resubmitting) return
const pos = resolveCardPosition(p.queryCode, p.position)
if (pos === null) return
const enteredPrepare = pos === POSITION_PREPARE && lastCardPosition !== POSITION_PREPARE
lastCardPosition = pos
if (!enteredPrepare) return
void resubmitTask()
}
async function resubmitTask(): Promise<void> {
if (resubmitting || !canAutoResubmit()) return
const fromWaitPhase = phase.value
resubmitting = true
await pollCardPositionStop()
unsubCard?.()
unsubCard = null
const backToWait = async (message?: string): Promise<void> => {
if (message) notify.error(message)
if (fromWaitPhase === 'failed') {
await enterFailedPhase(message || failureErrorText.value)
} else {
await enterCompletedPhase()
}
}
try {
if (isCollect.value) {
const dest = collectStore.destPath.trim()
if (!dest) {
await backToWait(t('notify.dirInvalid'))
return
}
phase.value = 'running'
setProgress(0)
workflowStep.value = 1
collectHint.value = ''
queryFailStreak = 0
usbQueryFailStreak = 0
clearWaiting()
const r = await dllUsbCopy(dest, collectStore.cardOutput, { resubmit: true })
if (!r.ok) {
await backToWait(r.message || t('notify.resubmitCollectFailed'))
return
}
usbAwaitNewCycle = true
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
markWaiting()
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
void refreshLiveStatus()
return
}
if (!jobStore.lastJobJson) {
const err = await validateJobPreflight(formStore)
if (err) {
notify.warning(err)
await backToWait()
return
}
}
phase.value = 'running'
setProgress(0)
workflowStep.value = 1
waitCard.value = false
queryFailStreak = 0
clearWaiting()
const created = await createDistributeJob(formStore, { resubmit: true })
if (!created.ok) {
await backToWait(created.message)
return
}
jobStore.setActiveJob(created.jobId)
const started = await pollJobStart(created.jobId)
if (!started.ok) {
await backToWait(started.message || t('notify.pollStartFailed'))
return
}
unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
void refreshLiveStatus()
} catch (e) {
await backToWait(String(e))
} finally {
resubmitting = false
} }
} }
function applyJobProgress(p: JobPollPayload): void { function applyJobProgress(p: JobPollPayload): void {
if (finishing || phase.value !== 'running') return
if (p.queryErrorCode !== 0) {
queryFailStreak += 1
if (queryFailStreak < 3) return
jobStore.failCount += 1
void enterFailedPhase(t('notify.queryJobFailed', { code: p.queryErrorCode }))
return
}
queryFailStreak = 0
setProgress(p.progress)
const ui = mapJobStateToUi(p.jobState) const ui = mapJobStateToUi(p.jobState)
workflowStep.value = ui.workflowStep workflowStep.value = ui.workflowStep
waitCard.value = ui.hint === 'waitCard' waitCard.value = ui.hint === 'waitCard'
if (shouldUseProgress(p.jobState)) { if (ui.hint === 'waitCard') markWaiting()
setProgress(p.progress) else clearWaiting()
}
if (p.queryErrorCode !== 0) {
notify.error(`查询任务失败: ${p.queryErrorCode}`)
finishDistribute(false)
return
}
if (p.failed) { if (p.failed) {
jobStore.failCount += 1 jobStore.failCount += 1
router.push('/distribute/failed') // 优先使用主进程在失败当刻取到的错误串,避免事后取到通用文案
void enterFailedPhase(p.errorMessage || '')
return return
} }
if (p.cancelled) { if (p.cancelled) {
finishDistribute(false) void finishDistribute(false)
return return
} }
if (p.finished) { if (p.finished) {
jobStore.successCount += 1 jobStore.successCount += 1
void enterCompletedPhase()
return
} }
} }
function applyUsbProgress(p: UsbPollPayload): void { function applyUsbProgress(p: UsbPollPayload): void {
if (p.taskStatus === 1) workflowStep.value = 2 if (finishing || phase.value !== 'running') return
if (p.taskStatus === 1 && p.progress > 0) { if (p.queryCode !== 0) {
setProgress(Math.max(progress.value, p.progress)) usbQueryFailStreak += 1
if (usbQueryFailStreak < 3) return
collectStore.failCount += 1
void enterFailedPhase(t('notify.queryUsbFailed', { code: p.queryCode }))
return
} }
usbQueryFailStreak = 0
if (usbAwaitNewCycle && p.queryCode === 0 && p.taskStatus === USB_TASK_PREPARING) {
usbAwaitNewCycle = false
}
if (p.failed) { if (p.failed) {
collectStore.failCount += 1 collectStore.failCount += 1
notify.error('USB 收集失败') void enterFailedPhase(p.errorMessage || usbTaskStatusHint(p.taskStatus))
finishCollect(false)
return return
} }
if (p.success) { if (p.success) {
collectStore.successCount += 1 collectStore.successCount += 1
setProgress(100)
workflowStep.value = 3 workflowStep.value = 3
notify.success('USB 收集完成') collectHint.value = usbTaskStatusHint(p.taskStatus)
window.setTimeout(() => finishCollect(true), 600) notify.success(t('notify.usbCollectComplete'))
void enterCompletedPhase()
return
} }
const copyProgress = clampUsbCopyProgress(p.progress)
workflowStep.value = p.taskStatus === USB_TASK_COPYING ? 2 : 1
collectHint.value = usbTaskStatusHint(p.taskStatus)
// PREPARING(等待插卡/读 U 盘)开始计时;进入 COPYING 等后续阶段后清除
if (p.taskStatus === USB_TASK_PREPARING) markWaiting()
else clearWaiting()
setProgress(copyProgress)
} }
function finishDistribute(toHome: boolean): void { async function finishDistribute(
stopFakeProgress() toHome: boolean,
pollJobStop() redirect: '/home' | '/distribute/config' = toHome ? '/home' : '/distribute/config'
): Promise<void> {
if (finishing) return
finishing = true
const skipCancel = phase.value === 'completed' || phase.value === 'failed'
await releasePolls(true)
const id = jobStore.jobId
if (id && !skipCancel) {
const r = await dllJobCancel(id)
if (!r.ok) {
notify.warning(r.message || t('notify.cancelIssue'))
}
}
jobStore.clearActiveJob() jobStore.clearActiveJob()
appStore.setMode('ready') appStore.setMode('ready')
router.push(toHome ? '/home' : '/distribute/config') await router.push(redirect)
} }
function finishCollect(toHome: boolean): void { async function finishCollect(toHome: boolean): Promise<void> {
stopFakeProgress() if (finishing) return
pollUsbStop() finishing = true
await releasePolls(true)
appStore.setMode('ready') appStore.setMode('ready')
router.push(toHome ? '/home' : '/collect') await router.push(toHome ? '/home' : '/collect')
} }
onMounted(async () => { onMounted(async () => {
@@ -185,8 +511,11 @@ onMounted(async () => {
} }
workflowStep.value = 1 workflowStep.value = 1
setProgress(0) setProgress(0)
startFakeProgress() collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
unsub = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload)) startWaitWatchdog()
markWaiting()
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
void refreshLiveStatus()
return return
} }
@@ -195,34 +524,86 @@ onMounted(async () => {
return return
} }
appStore.setMode('distributing') appStore.setMode('distributing')
await pollJobStart(jobStore.jobId) setProgress(0)
unsub = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload)) clearWaiting()
startWaitWatchdog()
const started = await pollJobStart(jobStore.jobId)
if (!started.ok) {
notify.error(started.message || t('notify.pollStartFailed'))
await finishDistribute(false)
return
}
unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
void refreshLiveStatus()
}) })
onUnmounted(() => { onUnmounted(() => {
unsub?.() stopWaitWatchdog()
stopFakeProgress() if (finishing || phase.value !== 'running') return
if (isCollect.value) { void releasePolls(true)
if (appStore.mode === 'usbCopying') pollUsbStop() if (isCollect.value && appStore.mode === 'usbCopying') {
} else if (appStore.mode === 'distributing') { appStore.setMode('ready')
pollJobStop() } else if (!isCollect.value && appStore.mode === 'distributing') {
appStore.setMode('ready') appStore.setMode('ready')
} }
}) })
async function onStop(): Promise<void> { async function leaveFailedPage(redirect: '/collect' | '/distribute/config' | '/home'): Promise<void> {
finishing = true
await releasePolls(true)
appStore.setMode('ready')
await router.push(redirect)
}
async function onFailedBack(): Promise<void> {
if (isCollect.value) { if (isCollect.value) {
finishCollect(false) await leaveFailedPage('/collect')
return return
} }
await dllJobCancel(jobStore.jobId) await leaveFailedPage('/distribute/config')
finishDistribute(false) }
async function onFailedReset(): Promise<void> {
if (isCollect.value) {
collectStore.reset()
} else {
formStore.reset()
jobStore.reset()
}
await leaveFailedPage('/home')
}
async function onReturn(): Promise<void> {
if (isCollect.value) {
await finishCollect(false)
return
}
await finishDistribute(false)
}
async function onStop(): Promise<void> {
if (isCollect.value) {
await finishCollect(false)
return
}
await finishDistribute(false)
} }
</script> </script>
<style src="@/styles/pages/page2.css"></style> <style src="@/styles/pages/page2.css"></style>
<style src="@/styles/pages/page3.css"></style>
<style scoped> <style scoped>
.m-error-detail {
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: #dc3545;
font-weight: 600;
max-width: 340px;
word-break: break-all;
}
.m-progress-circle .fill { .m-progress-circle .fill {
stroke-dasharray: 283; stroke-dasharray: 283;
transition: stroke-dashoffset 0.45s ease; transition: stroke-dashoffset 0.45s ease;
+78 -24
View File
@@ -1,35 +1,61 @@
<template> <template>
<AppShell> <AppShell>
<AppHeader mode="卡树数据卡打印机软件" /> <AppHeader :mode="t('header.modeHome')" />
<main class="app-shell__main l-dashboard"> <main class="app-shell__main l-dashboard">
<section class="m-tool-section"> <section class="m-tool-section">
<h3 class="m-section-title">工具</h3> <h3 class="m-section-title">{{ t('home.toolsTitle') }}</h3>
<div class="m-tool-grid"> <div class="m-tool-grid">
<button type="button" class="m-tool-btn" @click="onReset"> <button type="button" class="m-tool-btn" @click="onReset">
<AppIcon name="redo" /> <AppIcon name="redo" />
<span>重置打印机</span> <span>{{ t('common.resetPrinter') }}</span>
</button> </button>
<button type="button" class="m-tool-btn" @click="onReject"> <button
type="button"
class="m-tool-btn"
:disabled="!configStore.rejectApiAvailable"
:title="configStore.rejectApiAvailable ? t('common.discardCard') : t('notify.rejectUnavailable')"
@click="onReject"
>
<AppIcon name="trash" /> <AppIcon name="trash" />
<span>废弃卡片</span> <span>{{ t('common.discardCard') }}</span>
</button> </button>
<div class="m-tool-versus">
<button
type="button"
class="m-versus-half m-versus-half--read"
:title="t('home.readCard')"
@click="onReadCard"
>
<AppIcon name="id-card" size="sm" />
<span>{{ t('home.readCard') }}</span>
</button>
<button
type="button"
class="m-versus-half m-versus-half--eject"
:title="t('home.ejectCard')"
@click="onEjectCard"
>
<span>{{ t('home.ejectCard') }}</span>
<AppIcon name="eject" size="sm" />
</button>
</div>
<button type="button" class="m-tool-btn" @click="onTemplate"> <button type="button" class="m-tool-btn" @click="onTemplate">
<AppIcon name="paint-brush" /> <AppIcon name="paint-brush" />
<span>模板设计</span> <span>{{ t('common.templateDesign') }}</span>
</button> </button>
</div> </div>
</section> </section>
<div class="m-divider" /> <div class="m-divider" />
<section class="m-task-section"> <section class="m-task-section">
<h3 class="m-section-title">任务</h3> <h3 class="m-section-title">{{ t('home.tasksTitle') }}</h3>
<div class="m-task-grid"> <div class="m-task-grid">
<button type="button" class="m-task-card" @click="goDistribute"> <button type="button" class="m-task-card" @click="goDistribute">
<div class="m-task-icon"> <div class="m-task-icon">
<AppIcon name="share" /> <AppIcon name="share" />
</div> </div>
<div class="m-task-info"> <div class="m-task-info">
<h4>数据分发</h4> <h4>{{ t('home.dataDistribute') }}</h4>
<p>分发数据到打印卡片</p> <p>{{ t('home.dataDistributeDesc') }}</p>
</div> </div>
</button> </button>
<button type="button" class="m-task-card" @click="goCollect"> <button type="button" class="m-task-card" @click="goCollect">
@@ -37,8 +63,8 @@
<AppIcon name="download" /> <AppIcon name="download" />
</div> </div>
<div class="m-task-info"> <div class="m-task-info">
<h4>数据收集</h4> <h4>{{ t('home.dataCollect') }}</h4>
<p>从卡片收集导入数据</p> <p>{{ t('home.dataCollectDesc') }}</p>
</div> </div>
</button> </button>
</div> </div>
@@ -50,16 +76,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { notify, notifyRequireInit } from '@/composables/useNotify' import { notify, notifyRequireInit } from '@/composables/useNotify'
import { refreshLiveStatus } from '@/composables/usePrinterStatus'
import AppShell from '@/layouts/AppShell.vue' import AppShell from '@/layouts/AppShell.vue'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue' import AppFooter from '@/components/AppFooter.vue'
import AppIcon from '@/components/AppIcon.vue' import AppIcon from '@/components/AppIcon.vue'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { dllPrinterReject, dllPrinterReset, shellOpenTemplateDir } from '@/api/cardsoon' import { dllPrinterEjectCard, dllPrinterReadCard, dllPrinterReject, dllPrinterReset, openDesignApp } from '@/api/cardsoon'
const { t } = useI18n()
const router = useRouter() const router = useRouter()
const appStore = useAppStore() const appStore = useAppStore()
const configStore = useConfigStore() const configStore = useConfigStore()
@@ -72,36 +101,61 @@ function guardInit(action?: string): boolean {
} }
async function onReset(): Promise<void> { async function onReset(): Promise<void> {
if (!guardInit('重置打印机')) return if (!guardInit(t('common.resetPrinter'))) return
const r = await dllPrinterReset() const r = await dllPrinterReset()
if (r.ok) notify.success('已发送重置指令') if (r.ok) {
else notify.error(r.message || '重置失败') notify.success(t('notify.resetSent'))
await refreshLiveStatus()
} else notify.error(r.message || t('notify.resetFailed'))
} }
async function onReject(): Promise<void> { async function onReject(): Promise<void> {
if (!guardInit('废弃卡片')) return if (!guardInit(t('common.discardCard'))) return
if (!configStore.rejectApiAvailable) { if (!configStore.rejectApiAvailable) {
notify.warning('当前环境不支持废卡接口') notify.warning(t('notify.rejectUnavailable'))
return return
} }
const r = await dllPrinterReject() const r = await dllPrinterReject()
if (r.ok) notify.success('已废弃卡片') if (r.ok) {
else notify.error(r.message || '操作失败') notify.success(t('notify.cardRejected'))
await refreshLiveStatus()
} else notify.error(r.message || t('notify.operationFailed'))
}
async function onReadCard(): Promise<void> {
if (!guardInit(t('home.readCard'))) return
const r = await dllPrinterReadCard()
if (r.ok) {
notify.success(t('notify.readCardSent'))
await refreshLiveStatus()
} else notify.error(r.message || t('notify.readCardFailed'))
}
async function onEjectCard(): Promise<void> {
if (!guardInit(t('home.ejectCard'))) return
const r = await dllPrinterEjectCard()
if (r.ok) {
notify.success(t('notify.ejectCardSent'))
await refreshLiveStatus()
} else notify.error(r.message || t('notify.ejectCardFailed'))
} }
async function onTemplate(): Promise<void> { async function onTemplate(): Promise<void> {
if (!guardInit('打开模板目录')) return const r = await openDesignApp()
const r = await shellOpenTemplateDir() if (!r.ok) {
if (!r.ok) notify.error(r.message || '打开模板目录失败') notify.warning(r.message || t('notify.designCancelled'))
return
}
notify.success(t('notify.designStarted'))
} }
function guardBusy(): boolean { function guardBusy(): boolean {
if (appStore.mode === 'distributing') { if (appStore.mode === 'distributing') {
notify.warning('请先停止数据分发任务') notify.warning(t('notify.stopDistribute'))
return false return false
} }
if (appStore.mode === 'usbCopying') { if (appStore.mode === 'usbCopying') {
notify.warning('USB 收集进行中,请等待完成') notify.warning(t('notify.usbCollecting'))
return false return false
} }
return true return true
+33
View File
@@ -0,0 +1,33 @@
/** workDll 备卡位:检测到后可自动续做 */
export const POSITION_PREPARE = 13
/** 卡位编号合理上限(用于解析 DLL 返回值) */
export const POSITION_MAX = 32
/**
* 解析 SAPI_GetPrinterCardPosition。
* 真机常见:ret=13,out=0(返回值即卡位);或 ret=0,out=卡位。
*/
export function resolveCardPosition(queryCode: number, position: number): number | null {
if (queryCode === 0 && position >= 0 && position <= POSITION_MAX) {
return position
}
if (queryCode > 0 && queryCode <= POSITION_MAX) {
return queryCode
}
if (position > 0 && position <= POSITION_MAX) {
return position
}
return null
}
export function normalizeCardPositionQuery(
ret: number,
outPosition: number
): { queryCode: number; position: number } {
const pos = resolveCardPosition(ret, outPosition)
if (pos !== null) {
return { queryCode: 0, position: pos }
}
return { queryCode: ret, position: outPosition }
}
+3
View File
@@ -0,0 +1,3 @@
export function genTaskId(): string {
return `T${Date.now()}`
}
+81
View File
@@ -0,0 +1,81 @@
export function isNetworkPath(p: string): boolean {
if (!p) return false
return p.startsWith('\\\\') || p.startsWith('//')
}
export function extractHostName(p: string): string {
if (!p) return ''
if (p.startsWith('\\\\')) {
const part = p.substring(2).split('\\')[0]
return part || ''
}
if (p.startsWith('//')) {
const part = p.substring(2).split('/')[0]
return part || ''
}
return ''
}
export function extractDriveLetter(p: string): string {
const trimmed = (p || '').trim()
const m = /^([A-Za-z]):[\\/]/.exec(trimmed)
return m ? m[1].toUpperCase() : ''
}
export interface NetInfoCredential {
host_name: string
user_name: string
password: string
}
export function buildNetworkUrl(host: string, share: string): string {
const h = host.trim()
const s = share.trim().replace(/^[\\/]+/, '').replace(/[\\/]+$/, '')
return s ? `\\\\${h}\\${s}` : `\\\\${h}`
}
export function collectHostsForCopyPaths(
paths: string[],
driveHostMap: Record<string, string>
): string[] {
const hosts = new Set<string>()
for (const raw of paths) {
const p = (raw || '').trim()
if (!p) continue
if (isNetworkPath(p)) {
const h = extractHostName(p)
if (h) hosts.add(h)
continue
}
const letter = extractDriveLetter(p)
if (letter && driveHostMap[letter]) {
hosts.add(driveHostMap[letter])
}
}
return Array.from(hosts)
}
export function buildNetInfo(
networkItems: { hostName?: string; userName?: string; password?: string }[],
getStored: (host: string) => { userName?: string; password?: string } | null
): NetInfoCredential[] {
const creds: NetInfoCredential[] = []
const seen = new Set<string>()
for (const it of networkItems) {
const host = (it.hostName || '').trim()
if (!host || seen.has(host)) continue
seen.add(host)
const stored = getStored(host)
const user_name = (it.userName || stored?.userName || '').trim()
const password = it.password || stored?.password || ''
if (!user_name || !password) {
throw new Error(`缺少网络凭据: ${host}`)
}
creds.push({
host_name: host,
user_name,
password
})
}
return creds
}
+109
View File
@@ -0,0 +1,109 @@
export interface PrinterStatusSnapshot {
ribbonType: string
ribbonAmount: string
statusText: string
serialNo: string
/** 打印机型号/名称(如 TH80),用于判断单/双面能力 */
printerName: string
/** 是否为单面打印机(如 TH80),单面打印机不能选"双面"打印 */
isSingleSide: boolean
}
/** 已知单面打印机型号关键字(命中即视为单面) */
const SINGLE_SIDE_PRINTER_PATTERNS = ['TH80']
function detectSingleSide(printerName: string): boolean {
const s = (printerName || '').trim().toUpperCase()
if (!s) return false
return SINGLE_SIDE_PRINTER_PATTERNS.some((p) => s.includes(p.toUpperCase()))
}
const PRINTER_STATUS_MAP: Record<string, string> = {
I: '空闲',
B: '忙碌',
P: '正在打印'
}
function pickFirst(obj: Record<string, unknown>, keys: string[]): unknown {
for (const k of keys) {
const v = obj[k]
if (v != null && String(v).trim() !== '') return v
}
return undefined
}
function normalizeStatus(raw: unknown): string {
if (raw == null) return '—'
if (typeof raw === 'number' && raw > 0 && raw < 128) {
const c = String.fromCharCode(raw)
return PRINTER_STATUS_MAP[c] ?? c
}
const s = String(raw).trim()
if (!s) return '—'
return PRINTER_STATUS_MAP[s] ?? s
}
/** SAPI_PrinterCheckstatus 返回值 → 展示文案 */
export function statusTextFromCheckstatus(code: number): {
ok: boolean
statusText: string
liveError?: string
} {
if (code === 0) {
return { ok: true, statusText: PRINTER_STATUS_MAP.I ?? '空闲' }
}
if (code > 0 && code < 128) {
const statusText = normalizeStatus(code)
if (statusText !== '—') {
return { ok: true, statusText }
}
}
if (code < 0) {
return { ok: false, statusText: '未连接打印机', liveError: `code=${code}` }
}
return { ok: false, statusText: '—', liveError: `code=${code}` }
}
function snapshotFromRecord(row: Record<string, unknown>): PrinterStatusSnapshot {
const serial = pickFirst(row, [
'serial_no',
'SerialNo',
'serialNo',
'szPrinterSerial',
'PrinterSerial'
])
const printerName = pickFirst(row, [
'PrinterName',
'printer_name',
'PrinterModel',
'model',
'szPrinterName',
'szPrinterModel'
])
const ribbonType = pickFirst(row, ['ribbon_type', 'RibbonType'])
const ribbonAmount = pickFirst(row, ['RibbonAmount', 'ribbon_amount'])
const nameStr = String(printerName ?? '—')
return {
ribbonType: String(ribbonType ?? '—'),
ribbonAmount: String(ribbonAmount ?? '—'),
statusText: '—',
serialNo: String(serial ?? printerName ?? '—'),
printerName: nameStr,
isSingleSide: detectSingleSide(nameStr)
}
}
export function parsePrinterInfoFromDll(json: Record<string, unknown>): PrinterStatusSnapshot {
const flatSerial = pickFirst(json, ['serial_no', 'SerialNo', 'serialNo', 'szPrinterSerial'])
if (flatSerial != null) {
return snapshotFromRecord(json)
}
const list = (json.printerList as Record<string, unknown>[]) || []
if (list.length > 0) {
return snapshotFromRecord(list[0] as Record<string, unknown>)
}
return snapshotFromRecord(json)
}
+33
View File
@@ -0,0 +1,33 @@
/** SAPI_GetUsbCopyState: task_status0=preparing, 1=copying, 2=completed, 3=failed */
export const USB_TASK_PREPARING = 0
export const USB_TASK_COPYING = 1
export const USB_TASK_COMPLETED = 2
export const USB_TASK_FAILED = 3
/** @deprecated 使用 USB_TASK_PREPARING */
export const USB_TASK_IDLE = USB_TASK_PREPARING
/** @deprecated 使用 USB_TASK_COMPLETED */
export const USB_TASK_SUCCESS = USB_TASK_COMPLETED
export function clampUsbCopyProgress(value: number): number {
return Math.min(100, Math.max(0, Math.round(value)))
}
export function usbTaskStatusHint(status: number): string {
switch (status) {
case USB_TASK_PREPARING:
return '准备读取数据卡,请确认卡片已插入读卡位'
case USB_TASK_COPYING:
return '正在从卡片拷贝数据'
case USB_TASK_COMPLETED:
return '拷贝完成'
case USB_TASK_FAILED:
return 'USB 收集失败:未检测到卡片存储、读卡失败或无法移动到 USB 读卡位'
default:
return `USB 任务状态异常 (taskStatus=${status})`
}
}
export function isUsbCopyTerminal(status: number): boolean {
return status === USB_TASK_COMPLETED || status === USB_TASK_FAILED
}
File diff suppressed because one or more lines are too long