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
})
]
} }
}) })
+1031 -312
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', () => {
+326 -112
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,
autoRetryTimes: params?.autoRetryTimes,
rejectConfig: params?.rejectConfig,
logLevel: params?.logLevel,
outBack: params?.outBack
})
log.info(`SAPI_Init finished, code=${code}`)
dllInitAttempted = true
mainAppState.initialized = true
configStore.set('sharedDir', sharedDir)
if (code === CS_OK) {
return ok()
} }
log.warn(`SAPI_Init returned ${code}; UI ready, printer ops may fail until device connected`) })
tracedHandle('dll:printer-info', async () => {
try {
assertReady()
const dll = await loadDllModule()
const r = dll.dllGetPrinterInfo()
if (!r.json) {
const cached = configStore.get('lastPrinterStatus')
if (cached) {
return ok({ return ok({
warning: '打印机未连接或驱动未就绪,界面可浏览,接好设备后可在设置中重试 Init' ...cached,
fromCache: true,
liveError: r.code <= 0 ? '未连接打印机' : `GetPrinterInfo code=${r.code}`
}) })
}
return fail(0, '未连接打印机')
}
const snapshot = parsePrinterInfoFromDll(r.json)
configStore.set('lastPrinterStatus', snapshot)
return ok({ ...r.json, snapshot })
} catch (err) { } catch (err) {
mainAppState.initialized = false const cached = configStore.get('lastPrinterStatus')
log.error('dll:init', err) if (cached) {
return ok({ ...cached, fromCache: true, liveError: String(err) })
}
return fail(CS_FAIL, String(err)) return fail(CS_FAIL, String(err))
} }
}) })
ipcMain.handle('dll:printer-info', () => { tracedHandle('dll:printer-status', async () => {
if (!mainAppState.initialized) {
const cached = configStore.get('lastPrinterStatus')
return ok({
statusText: cached?.statusText ?? '未初始化',
fromCache: true
})
}
try { try {
assertReady() assertReady()
const r = dllGetPrinterInfo() const dll = await loadDllModule()
if (!r.json) return fail(0, '未连接打印机') if (!dll.isPrinterCheckstatusApiAvailable()) {
return ok(r.json) return fail(CS_FAIL, '当前 DLL 不支持打印机状态查询')
}
const code = dll.dllPrinterCheckstatus()
const parsed = statusTextFromCheckstatus(code)
const cached = configStore.get('lastPrinterStatus')
if (parsed.ok) {
const snapshot: PrinterStatusSnapshot = {
ribbonType: cached?.ribbonType ?? '—',
ribbonAmount: cached?.ribbonAmount ?? '—',
statusText: parsed.statusText,
serialNo: cached?.serialNo ?? '—',
printerName: cached?.printerName ?? '—',
isSingleSide: cached?.isSingleSide ?? false
}
configStore.set('lastPrinterStatus', snapshot)
return ok({ statusText: parsed.statusText, statusCode: code })
}
if (cached) {
return ok({
statusText: parsed.statusText,
fromCache: true,
liveError: parsed.liveError
})
}
return fail(code, parsed.liveError || parsed.statusText)
} catch (err) {
const cached = configStore.get('lastPrinterStatus')
if (cached) {
return ok({ statusText: cached.statusText, fromCache: true, liveError: String(err) })
}
return fail(CS_FAIL, String(err))
}
})
tracedHandle('dll:printer-reset', async () => {
try {
assertReady()
const dll = await loadDllModule()
const code = dll.dllPrinterReset()
// 物理动作指令:负数为错误,0 或正数(状态/警告码)均视为指令已被打印机接受
if (code < 0) {
log.warn('SAPI_PrinterResetprinter returned non-OK code', code)
return fail(code, '重置失败')
}
return ok({ code })
} catch (err) { } catch (err) {
return fail(CS_FAIL, String(err)) return fail(CS_FAIL, String(err))
} }
}) })
ipcMain.handle('dll:printer-reset', () => { tracedHandle('dll:printer-reject', async () => {
try { try {
assertReady() assertReady()
const code = dllPrinterReset() const dll = await loadDllModule()
return code === CS_OK ? ok() : fail(code, '重置失败') if (!dll.isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE')
const code = dll.dllPrinterReject()
if (code < 0) {
log.warn('SAPI_PrinterMovetoreject returned non-OK code', code)
return fail(code, '废卡失败')
}
return ok({ code })
} catch (err) { } catch (err) {
return fail(CS_FAIL, String(err)) return fail(CS_FAIL, String(err))
} }
}) })
ipcMain.handle('dll:printer-reject', () => { // 读卡:移动卡片到读取区(SAPI_PrinterMovetousbreader
tracedHandle('dll:printer-read-card', async () => {
try { try {
assertReady() assertReady()
if (!isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE') const dll = await loadDllModule()
const code = dllPrinterReject() if (!dll.isUsbReaderApiAvailable()) return fail(CS_FAIL, 'USB_READER_API_UNAVAILABLE')
return code === CS_OK ? ok() : fail(code, '废卡失败') const code = dll.dllPrinterMoveToUsbReader()
if (code < 0) {
log.warn('SAPI_PrinterMovetousbreader returned non-OK code', code)
return fail(code, '读卡失败')
}
return ok({ code })
} catch (err) { } catch (err) {
return fail(CS_FAIL, String(err)) return fail(CS_FAIL, String(err))
} }
}) })
ipcMain.handle('dll:printer-error-str', (_e, errorNo?: number) => { // 退卡:移动卡片到出卡区(SAPI_PrinterMovetohopper
tracedHandle('dll:printer-eject-card', async () => {
try { try {
assertReady() assertReady()
return ok({ text: dllGetPrinterErrorStr(errorNo ?? -1) }) const dll = await loadDllModule()
if (!dll.isHopperApiAvailable()) return fail(CS_FAIL, 'HOPPER_API_UNAVAILABLE')
const code = dll.dllPrinterMoveToHopper()
if (code < 0) {
log.warn('SAPI_PrinterMovetohopper returned non-OK code', code)
return fail(code, '退卡失败')
}
// 卡已物理退出但返回正数状态码时,不再误报失败
return ok({ code })
} catch (err) { } catch (err) {
return fail(CS_FAIL, String(err)) return fail(CS_FAIL, String(err))
} }
}) })
ipcMain.handle('dll:job-create', (_e, json: string) => { tracedHandle('dll:printer-error-str', async (_e, errorNo?: number) => {
if (!mainAppState.initialized) return ok({ text: '' })
try {
const dll = await loadDllModule()
return ok({ text: dll.dllGetPrinterErrorStr(errorNo ?? -1) })
} catch (err) {
log.warn('dll:printer-error-str', err)
return ok({ text: '' })
}
})
tracedHandle('dll:job-create', async (_e, json: string, opts?: { resubmit?: boolean }) => {
try { try {
assertReady() assertReady()
if (opts?.resubmit) {
if (mainAppState.mode !== 'distributing') {
return fail(CS_FAIL, '当前不在分发任务会话中')
}
stopCardPositionPoll()
} else {
assertNotBusy() assertNotBusy()
const r = dllRestJobEx(json) }
if (r.code !== CS_OK) return fail(r.code, 'RestJobEx 失败') const dll = await loadDllModule()
const sharedDir = configStore.get('sharedDir') as string
let staged: { json: string; taskDir: string }
try {
staged = stageJobPayloadJson(json, sharedDir, dll)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return fail(CS_FAIL, msg)
}
log.info('RestJobEx staging', {
taskDir: staged.taskDir,
summary: summarizeStagedPayload(staged.json)
})
const r = dll.dllRestJobEx(staged.json)
if (r.code !== CS_OK) {
log.warn('RestJobEx rejected', { code: r.code, json: staged.json.slice(0, 800) })
const errText = dll.dllGetPrinterErrorStr(r.code)
let detail = errText ? `${errText} (code=${r.code})` : `RestJobEx 失败 (code=${r.code})`
if (r.code === -1 && !errText) {
detail +=
':请确认模板路径、变量 CSV(udf_file)及拷贝路径有效,且打印机/任务目录已就绪'
}
return fail(r.code, detail)
}
if (!r.jobId?.trim()) {
return fail(CS_FAIL, 'RestJobEx 未返回 jobId')
}
mainAppState.mode = 'distributing' mainAppState.mode = 'distributing'
mainAppState.activeJobId = r.jobId mainAppState.activeJobId = r.jobId
return ok({ jobId: r.jobId }) return ok({ jobId: r.jobId })
} catch (err) { } catch (err) {
if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行') const msg = String(err)
return fail(CS_FAIL, String(err)) if (msg.includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
if (msg.includes('NOT_INITIALIZED')) {
return fail(CS_FAIL, '系统未初始化,请重启应用')
}
return fail(CS_FAIL, msg)
} }
}) })
ipcMain.handle('dll:job-cancel', (_e, jobId: string) => { tracedHandle('dll:job-cancel', async (_e, jobId: string) => {
try { const id = String(jobId || mainAppState.activeJobId || '').trim()
assertReady()
const id = jobId || mainAppState.activeJobId
stopJobPoll(true) stopJobPoll(true)
let code = CS_OK
if (isCancelApiAvailable()) {
code = dllAdminJobCancel(id)
} else {
log.info('dll:job-cancel: SAPI_AdminJobCancel not in DLL, poll stopped only')
}
mainAppState.mode = 'ready' mainAppState.mode = 'ready'
mainAppState.activeJobId = '' mainAppState.activeJobId = ''
if (!id) return ok()
if (!mainAppState.initialized) return ok()
try {
const dll = await loadDllModule()
if (!dll.isCancelApiAvailable()) return ok()
const code = dll.dllAdminJobCancel(id)
return code === CS_OK ? ok() : fail(code, '取消失败') return code === CS_OK ? ok() : fail(code, '取消失败')
} catch (err) { } catch (err) {
return fail(CS_FAIL, String(err)) log.warn('dll:job-cancel', err)
return ok()
} }
}) })
ipcMain.handle('dll:usb-copy', (_e, req: { destFolder: string; cardOutput: number }) => { tracedHandle('dll:usb-copy', async (_e, req: { destFolder: string; cardOutput: number; resubmit?: boolean }) => {
try { try {
assertReady() assertReady()
if (req.resubmit) {
if (mainAppState.mode !== 'usbCopying') {
return fail(CS_FAIL, '当前不在数据收集会话中')
}
stopCardPositionPoll()
} else {
assertNotBusy() assertNotBusy()
const code = dllCopyFromUsb(req.destFolder, req.cardOutput) }
const destFolder = String(req.destFolder || '').trim()
if (!destFolder) return fail(CS_FAIL, '请先选择数据导入目录')
fs.mkdirSync(destFolder, { recursive: true })
const dll = await loadDllModule()
if (dll.isUsbReaderApiAvailable()) {
const moveCode = dll.dllPrinterMoveToUsbReader()
if (moveCode !== CS_OK) {
log.warn('MoveToUsbReader before copy', { moveCode })
}
}
const code = dll.dllCopyFromUsb(destFolder, req.cardOutput)
if (code !== CS_OK) { if (code !== CS_OK) {
return fail(code, '可能已有任务在执行') const errText = dll.dllGetPrinterErrorStr(code)
return fail(code, errText || '启动 USB 收集失败')
} }
mainAppState.mode = 'usbCopying' mainAppState.mode = 'usbCopying'
startUsbPoll()
return ok() return ok()
} catch (err) { } catch (err) {
if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行') if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
@@ -168,28 +315,49 @@ export function registerIpcHandlers(): void {
} }
}) })
ipcMain.handle('poll:job-start', (_e, jobId: string) => { tracedHandle('poll:job-start', (_e, jobId: string) => {
startJobPoll(jobId) startJobPoll(jobId)
return ok() return ok()
}) })
ipcMain.handle('poll:job-stop', () => { tracedHandle('poll:job-stop', (_e, opts?: { resetMode?: boolean }) => {
stopJobPoll(true) stopJobPoll(opts?.resetMode !== false)
return ok() return ok()
}) })
ipcMain.handle('poll:usb-start', () => { tracedHandle('poll:usb-start', () => {
startUsbPoll() startUsbPoll()
return ok() return ok()
}) })
ipcMain.handle('poll:usb-stop', () => { tracedHandle('poll:usb-stop', (_e, opts?: { resetMode?: boolean }) => {
stopUsbPoll() stopUsbPoll(opts?.resetMode !== false)
mainAppState.mode = 'ready'
return ok() return ok()
}) })
ipcMain.handle('dialog:open-directory', async () => { tracedHandle('poll:card-position-start', async (_e, sessionMode?: 'distributing' | 'usbCopying') => {
try {
assertReady()
if (sessionMode === 'distributing' || sessionMode === 'usbCopying') {
mainAppState.mode = sessionMode
}
const dll = await loadDllModule()
if (!dll.isCardPositionApiAvailable()) {
return fail(CS_FAIL, '当前 DLL 不支持卡位查询,无法自动续做')
}
startCardPositionPoll()
return ok()
} catch (err) {
return fail(CS_FAIL, String(err))
}
})
tracedHandle('poll:card-position-stop', () => {
stopCardPositionPoll()
return ok()
})
tracedHandle('dialog:open-directory', async () => {
const win = getPollMainWindow() const win = getPollMainWindow()
const r = await dialog.showOpenDialog(win ?? undefined, { const r = await dialog.showOpenDialog(win ?? undefined, {
properties: ['openDirectory', 'multiSelections'] properties: ['openDirectory', 'multiSelections']
@@ -198,7 +366,7 @@ export function registerIpcHandlers(): void {
return ok({ paths: r.filePaths }) return ok({ paths: r.filePaths })
}) })
ipcMain.handle('dialog:open-file', async (_e, filters?: { name: string; extensions: string[] }[]) => { tracedHandle('dialog:open-file', async (_e, filters?: { name: string; extensions: string[] }[]) => {
const win = getPollMainWindow() const win = getPollMainWindow()
const r = await dialog.showOpenDialog(win ?? undefined, { const r = await dialog.showOpenDialog(win ?? undefined, {
properties: ['openFile'], properties: ['openFile'],
@@ -208,7 +376,7 @@ export function registerIpcHandlers(): void {
return ok({ path: r.filePaths[0] }) return ok({ path: r.filePaths[0] })
}) })
ipcMain.handle('fs:path-exists', (_e, paths: string[]) => { tracedHandle('fs:path-exists', (_e, paths: string[]) => {
const missing = paths const missing = paths
.map((raw) => ({ raw, dir: cleanPathPattern(raw) })) .map((raw) => ({ raw, dir: cleanPathPattern(raw) }))
.filter(({ dir }) => !dir || !fs.existsSync(dir)) .filter(({ dir }) => !dir || !fs.existsSync(dir))
@@ -216,7 +384,7 @@ export function registerIpcHandlers(): void {
return ok({ missing }) return ok({ missing })
}) })
ipcMain.handle('fs:dir-size', (_e, paths: string[]) => { tracedHandle('fs:dir-size', (_e, paths: string[]) => {
const items = paths.map((raw) => { const items = paths.map((raw) => {
const dir = cleanPathPattern(raw) const dir = cleanPathPattern(raw)
if (!fs.existsSync(dir)) return { path: raw, bytes: 0, missing: true as const } if (!fs.existsSync(dir)) return { path: raw, bytes: 0, missing: true as const }
@@ -231,7 +399,43 @@ export function registerIpcHandlers(): void {
return ok({ items }) return ok({ items })
}) })
ipcMain.handle('fs:parse-soon', (_e, filePath: string) => { tracedHandle('fs:resolve-network-hosts', (_e, paths: string[]) => {
const list = Array.isArray(paths) ? paths.map((p) => String(p || '')) : []
const driveHostMap = resolveDriveHostMap(list)
const hosts = resolveHostsFromPaths(list)
return ok({ hosts, driveHostMap })
})
tracedHandle('secrets:get', () => {
return ok(getSecrets())
})
tracedHandle('secrets:set', (_e, patch: Partial<SecretsPayload>) => {
if (!patch || typeof patch !== 'object') return ok(getSecrets())
const toMerge: Partial<SecretsPayload> = {}
if (patch.networkCredentials !== undefined) {
toMerge.networkCredentials = patch.networkCredentials
}
if (patch.dongleAuthCode !== undefined) {
toMerge.dongleAuthCode = patch.dongleAuthCode
}
return ok(setSecrets(toMerge))
})
tracedHandle(
'fs:write-job-csv',
(_e, payload: { taskId: string; rows: JobCsvRow[] }) => {
try {
const sharedDir = configStore.get('sharedDir') as string
const csvPath = writeJobCsv(sharedDir, payload.taskId, payload.rows || [])
return ok({ path: csvPath })
} catch (err) {
return fail(CS_FAIL, err instanceof Error ? err.message : String(err))
}
}
)
tracedHandle('fs:parse-soon', (_e, filePath: string) => {
try { try {
const soonPath = String(filePath || '').trim() const soonPath = String(filePath || '').trim()
if (!soonPath) return fail(CS_FAIL, '模板路径为空') if (!soonPath) return fail(CS_FAIL, '模板路径为空')
@@ -244,28 +448,27 @@ export function registerIpcHandlers(): void {
} }
}) })
ipcMain.handle('config:get', () => { tracedHandle('config:get', () => {
const payload: { const payload: {
sharedDir: string sharedDir: string
templateDir: string templateDir: string
skipDllInit?: boolean traceEnabled: boolean
lastPrinterStatus?: PrinterStatusSnapshot
dllInitialized: boolean
} = { } = {
sharedDir: configStore.get('sharedDir'), sharedDir: configStore.get('sharedDir'),
templateDir: configStore.get('templateDir') templateDir: configStore.get('templateDir'),
} traceEnabled: configStore.get('traceEnabled', true),
if (!app.isPackaged) { lastPrinterStatus: configStore.get('lastPrinterStatus'),
payload.skipDllInit = configStore.get('skipDllInit', false) dllInitialized: mainAppState.initialized
} }
return ok(payload) return ok(payload)
}) })
ipcMain.handle('config:set', (_e, patch: Record<string, unknown>) => { tracedHandle('config:set', (_e, patch: Record<string, unknown>) => {
Object.entries(patch).forEach(([k, v]) => { Object.entries(patch).forEach(([k, v]) => {
if (k === 'skipDllInit') { if (k === 'traceEnabled' || k === 'dllTraceEnabled') {
if (app.isPackaged) return configStore.set('traceEnabled', parseBool(v))
const b =
v === true || v === 'true' || v === 1 || v === '1' || String(v).toLowerCase() === 'true'
configStore.set(k, b)
return return
} }
configStore.set(k, v as string) configStore.set(k, v as string)
@@ -273,26 +476,37 @@ export function registerIpcHandlers(): void {
return ok(configStore.store) return ok(configStore.store)
}) })
ipcMain.handle('shell:open-path', (_e, target: string) => { tracedHandle('shell:open-path', (_e, target: string) => {
const dir = target || (configStore.get('templateDir') as string) const dir = target || (configStore.get('templateDir') as string)
fs.mkdirSync(dir, { recursive: true }) fs.mkdirSync(dir, { recursive: true })
shell.openPath(dir) shell.openPath(dir)
return ok() return ok()
}) })
ipcMain.handle('dll:reject-available', () => ok({ available: isRejectApiAvailable() })) tracedHandle('design:open', async () => {
const r = await openDesignApp(getDesignAppPath())
if (!r.ok) return fail(CS_FAIL, r.message)
return ok()
})
tracedHandle('dll:reject-available', async () => {
const dll = await loadDllModule()
return ok({ available: dll.isRejectApiAvailable() })
})
} }
export async function handleBeforeQuit(): Promise<void> { export async function handleBeforeQuit(): Promise<void> {
const dll = isDllInitAttempted() ? await loadDllModule().catch(() => null) : null
const shouldCancel = const shouldCancel =
mainAppState.mode === 'distributing' && mainAppState.mode === 'distributing' &&
!!mainAppState.activeJobId && !!mainAppState.activeJobId &&
isCancelApiAvailable() isJobPollActive() &&
!!dll?.isCancelApiAvailable()
const cancelJobId = mainAppState.activeJobId const cancelJobId = mainAppState.activeJobId
stopAllPolls() stopAllPolls()
if (shouldCancel && cancelJobId) { if (shouldCancel && cancelJobId && dll) {
try { try {
dllAdminJobCancel(cancelJobId) dll.dllAdminJobCancel(cancelJobId)
} catch (e) { } catch (e) {
log.warn('before-quit cancel', e) log.warn('before-quit cancel', e)
} }
+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 }
}
+112 -26
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,29 +44,52 @@ export function stopJobPoll(resetMode = false): void {
} }
} }
export function stopUsbPoll(): void { export function stopUsbPoll(resetMode = false): void {
usbPollGen += 1
if (usbTimer) { if (usbTimer) {
clearInterval(usbTimer) clearInterval(usbTimer)
usbTimer = null usbTimer = null
} }
if (resetMode && mainAppState.mode === 'usbCopying') {
mainAppState.mode = 'ready'
}
}
export function stopCardPositionPoll(): void {
if (cardTimer) {
clearInterval(cardTimer)
cardTimer = null
}
} }
export function stopAllPolls(): void { export function stopAllPolls(): void {
stopJobPoll(true) stopJobPoll(true)
stopUsbPoll() stopUsbPoll(true)
stopCardPositionPoll()
} }
export function startJobPoll(id: string): void { export function startJobPoll(id: string): void {
stopJobPoll(false) stopJobPoll(false)
jobId = id jobId = id
jobTimer = setInterval(() => { jobTimer = setInterval(() => {
void (async () => {
try { try {
const r = dllGetJobStateById(jobId) const dll = await loadDllModule()
const r = dll.dllGetJobStateById(jobId)
const failed = r.jobState === 4 const failed = r.jobState === 4
const cancelled = r.jobState === 6 const cancelled = r.jobState === 6
const finished = r.jobState === 100 const finished = r.jobState === 100
const terminal = failed || cancelled const terminal = failed || cancelled
send('job:poll-tick', { // 失败当刻立即取打印机错误串(与 USB 收集轮询对齐),避免渲染层事后取到通用/过期文案
let errorMessage = ''
if (failed) {
try {
errorMessage = dll.dllGetPrinterErrorStr(-1) || ''
} catch (e) {
log.warn('GetPrinterErrorStr on job failed failed', e)
}
}
const tick = {
jobId, jobId,
queryErrorCode: r.queryErrorCode, queryErrorCode: r.queryErrorCode,
jobState: r.jobState, jobState: r.jobState,
@@ -64,50 +97,103 @@ export function startJobPoll(id: string): void {
terminal, terminal,
failed, failed,
cancelled, cancelled,
finished finished,
}) errorMessage
}
emitTrace('[poll] job:poll-tick', tick)
send('job:poll-tick', tick)
if (r.queryErrorCode !== 0) { if (r.queryErrorCode !== 0) {
log.warn('GetJobStateById query failed', r.queryErrorCode) log.warn('GetJobStateById query failed', r.queryErrorCode)
stopJobPoll(true)
return return
} }
if (failed || cancelled) { if (failed || cancelled || finished) {
stopJobPoll(true) stopJobPoll(false)
} }
} catch (e) { } catch (e) {
log.error('job poll error', e) log.error('job poll error', e)
stopJobPoll(true) stopJobPoll(false)
} }
})()
}, POLL_INTERVAL_MS) }, POLL_INTERVAL_MS)
} }
export function startUsbPoll(): void { export function startUsbPoll(): void {
stopUsbPoll() stopUsbPoll(false)
const gen = usbPollGen
void pollUsbOnce(gen).catch((e) => log.error('usb poll error', e))
usbTimer = setInterval(() => { usbTimer = setInterval(() => {
try { void pollUsbOnce(gen).catch((e) => {
const r = dllGetUsbCopyState() log.error('usb poll error', e)
const failed = r.taskStatus === 3 stopUsbPoll(false)
const success = r.taskStatus === 2 })
}, POLL_INTERVAL_MS)
}
async function pollUsbOnce(gen: number): Promise<void> {
if (gen !== usbPollGen) return
const dll = await loadDllModule()
if (gen !== usbPollGen) return
const r = dll.dllGetUsbCopyState()
if (gen !== usbPollGen) return
const copyProgress = clampUsbCopyProgress(r.progress)
const failed = r.taskStatus === USB_TASK_FAILED
const success = r.taskStatus === USB_TASK_COMPLETED
const terminal = failed || success const terminal = failed || success
send('usb:poll-tick', { let errorMessage = ''
if (failed) {
const errText = dll.dllGetPrinterErrorStr(-1)
errorMessage = errText || usbTaskStatusHint(USB_TASK_FAILED)
}
const tick = {
queryCode: r.queryCode,
taskStatus: r.taskStatus, taskStatus: r.taskStatus,
progress: r.progress, progress: copyProgress,
terminal, terminal,
failed, failed,
success success,
}) errorMessage
}
emitTrace('[poll] usb:poll-tick', tick)
if (gen !== usbPollGen) return
send('usb:poll-tick', tick)
if (r.queryCode !== CS_OK) {
log.warn('GetUsbCopyState query failed', r.queryCode)
return
}
if (terminal) { if (terminal) {
stopUsbPoll() stopUsbPoll(false)
mainAppState.mode = 'ready'
} }
}
async function pollCardPositionOnce(): Promise<void> {
try {
const dll = await loadDllModule()
if (!dll.isCardPositionApiAvailable()) return
const r = dll.dllGetPrinterCardPosition()
const tick = normalizeCardPositionQuery(r.queryCode, r.position)
emitTrace('[poll] card:position-tick', tick)
send('card:position-tick', tick)
} catch (e) { } catch (e) {
log.error('usb poll error', e) log.error('card position poll error', e)
stopUsbPoll()
mainAppState.mode = 'ready'
} }
}
export function startCardPositionPoll(): void {
stopCardPositionPoll()
void pollCardPositionOnce()
cardTimer = setInterval(() => {
void pollCardPositionOnce()
}, POLL_INTERVAL_MS) }, POLL_INTERVAL_MS)
} }
export function getActiveJobId(): string { export function getActiveJobId(): string {
return jobId return jobId
} }
export function isJobPollActive(): boolean {
return jobTimer !== null
}
export function isUsbPollActive(): boolean {
return usbTimer !== null
}
+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()
}
+262 -14
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 isUploadApiAvailable(): boolean {
loadLibrary()
return hasUploadApi
}
export function isUsbReaderApiAvailable(): boolean {
loadLibrary()
return hasUsbReaderApi
}
export function isHopperApiAvailable(): boolean {
loadLibrary()
return hasHopperApi
}
export function isCardPositionApiAvailable(): boolean {
loadLibrary()
return hasCardPositionApi
}
export function isPrinterCheckstatusApiAvailable(): boolean {
loadLibrary()
return hasCheckstatusApi
}
export function dllInit(params: InitParams): number { export function dllInit(params: InitParams): number {
return traceCall(
'SAPI_Init',
{
sharedDir: params.sharedDir,
keepCombinedImage: params.keepCombinedImage ?? true,
stopOnFailure: params.stopOnFailure ?? false,
cleanTaskFile: params.cleanTaskFile ?? false,
autoRetryTimes: params.autoRetryTimes ?? 0,
rejectConfig: params.rejectConfig ?? false,
logLevel: params.logLevel ?? LOG_FATAL_FLAG,
outBack: params.outBack ?? false
},
() => {
loadLibrary() loadLibrary()
return SAPI_Init!( return SAPI_Init!(
params.sharedDir, params.sharedDir,
params.keepCombinedImage ?? true, params.keepCombinedImage ?? true,
params.stopOnFailure ?? false, params.stopOnFailure ?? false,
params.cleanTaskFile ?? true, params.cleanTaskFile ?? false,
params.autoRetryTimes ?? 0, params.autoRetryTimes ?? 0,
params.rejectConfig ?? false, params.rejectConfig ?? false,
params.logLevel ?? LOG_FATAL_FLAG, params.logLevel ?? LOG_FATAL_FLAG,
params.outBack ?? false params.outBack ?? false
) as number ) as number
}
)
} }
export function dllGetPrinterInfo(): { code: number; json?: Record<string, unknown> } { function dllGetPrinterInfoInternal(
apiName: 'SAPI_GetPrinterInfo',
fn: (outPtr: Buffer) => number
): { code: number; json?: Record<string, unknown> } {
return traceCall(apiName, undefined, () => {
loadLibrary() loadLibrary()
const outPtr = koffi.alloc('void *', 8) const outPtr = koffi.alloc('void *', 8)
try { try {
const len = SAPI_GetPrinterInfo!(outPtr) as number const len = fn(outPtr) as number
if (len <= 0) return { code: len } return readPrinterJsonFromOutPtr(len, outPtr)
const ptr = koffi.decode(outPtr, 0, 'void *') as number
const jsonStr = koffi.decode(ptr, 'char', len) as string
koffi.free(ptr)
return { code: len, json: JSON.parse(jsonStr) as Record<string, unknown> }
} finally { } finally {
koffi.free(outPtr) koffi.free(outPtr)
} }
})
}
export function dllGetPrinterInfo(): { code: number; json?: Record<string, unknown> } {
return dllGetPrinterInfoInternal('SAPI_GetPrinterInfo', (p) => SAPI_GetPrinterInfo!(p))
} }
export function dllGetPrinterErrorStr(errorNo = -1): string { export function dllGetPrinterErrorStr(errorNo = -1): string {
return traceCall('SAPI_GetPrinterErrorStr', { errorNo }, () => {
loadLibrary() loadLibrary()
const s = SAPI_GetPrinterErrorStr!(errorNo) as string const s = SAPI_GetPrinterErrorStr!(errorNo) as string
return s || '' return s || ''
})
}
export function dllUploadFile(userDir: string, fileName: string, fileText: string): number {
return traceCall(
'SAPI_UploadFile',
{ userDir, fileName, bytes: Buffer.byteLength(fileText ?? '', 'utf8') },
() => {
loadLibrary()
if (!SAPI_UploadFile) throw new Error('UPLOAD_API_UNAVAILABLE')
return SAPI_UploadFile(userDir, fileName, fileText) as number
}
)
} }
export function dllRestJobEx(json: string): { code: number; jobId: string } { export function dllRestJobEx(json: string): { code: number; jobId: string } {
return traceCall('SAPI_RestJobEx', { jsonBytes: Buffer.byteLength(json ?? '', 'utf8') }, () => {
loadLibrary() loadLibrary()
const buf = Buffer.alloc(JOB_ID_BUF_SIZE) const buf = Buffer.alloc(JOB_ID_BUF_SIZE)
const code = SAPI_RestJobEx!(json, buf, JOB_ID_BUF_SIZE) as number const code = SAPI_RestJobEx!(json, buf, JOB_ID_BUF_SIZE) as number
const jobId = buf.toString('utf8').replace(/\0.*$/, '').trim() const jobId = buf.toString('utf8').replace(/\0.*$/, '').trim()
return { code, jobId } return { code, jobId }
})
} }
export function dllGetJobStateById(jobId: string): { export function dllGetJobStateById(jobId: string): {
@@ -140,6 +334,7 @@ export function dllGetJobStateById(jobId: string): {
jobState: number jobState: number
progress: number progress: number
} { } {
return traceCall('SAPI_GetJobStateById', { jobId }, () => {
loadLibrary() loadLibrary()
const jobState = [0] const jobState = [0]
const copyScheduler = [0] const copyScheduler = [0]
@@ -149,34 +344,87 @@ export function dllGetJobStateById(jobId: string): {
jobState: jobState[0], jobState: jobState[0],
progress: copyScheduler[0] progress: copyScheduler[0]
} }
})
} }
export function dllAdminJobCancel(jobId: string): number { export function dllAdminJobCancel(jobId: string): number {
return traceCall('SAPI_AdminJobCancel', { jobId }, () => {
loadLibrary() loadLibrary()
if (!SAPI_AdminJobCancel) throw new Error('CANCEL_API_UNAVAILABLE') if (!SAPI_AdminJobCancel) throw new Error('CANCEL_API_UNAVAILABLE')
return SAPI_AdminJobCancel(jobId) as number return SAPI_AdminJobCancel(jobId) as number
})
} }
export function dllCopyFromUsb(destFolder: string, cardOutput: number): number { export function dllCopyFromUsb(destFolder: string, cardOutput: number): number {
return traceCall('SAPI_CopyFromUsb', { destFolder, cardOutput }, () => {
loadLibrary() loadLibrary()
return SAPI_CopyFromUsb!(destFolder, cardOutput) as number return SAPI_CopyFromUsb!(destFolder, cardOutput) as number
})
} }
export function dllGetUsbCopyState(): { taskStatus: number; progress: number } { export function dllGetUsbCopyState(): {
queryCode: number
taskStatus: number
progress: number
} {
return traceCall('SAPI_GetUsbCopyState', undefined, () => {
loadLibrary() loadLibrary()
const taskStatus = [0] const taskStatus = [0]
const progress = [0] const copyProgress = [0]
SAPI_GetUsbCopyState!(taskStatus, progress) const queryCode = SAPI_GetUsbCopyState!(taskStatus, copyProgress) as number
return { taskStatus: taskStatus[0], progress: progress[0] } return {
queryCode,
taskStatus: taskStatus[0],
progress: copyProgress[0]
}
})
} }
export function dllPrinterReset(): number { export function dllPrinterReset(): number {
return traceCall('SAPI_PrinterResetprinter', undefined, () => {
loadLibrary() loadLibrary()
return SAPI_PrinterResetprinter!() as number return SAPI_PrinterResetprinter!() as number
})
}
export function dllPrinterMoveToUsbReader(): number {
return traceCall('SAPI_PrinterMovetousbreader', undefined, () => {
loadLibrary()
if (!SAPI_PrinterMovetousbreader) throw new Error('USB_READER_API_UNAVAILABLE')
return SAPI_PrinterMovetousbreader() as number
})
}
export function dllPrinterMoveToHopper(): number {
return traceCall('SAPI_PrinterMovetohopper', undefined, () => {
loadLibrary()
if (!SAPI_PrinterMovetohopper) throw new Error('HOPPER_API_UNAVAILABLE')
return SAPI_PrinterMovetohopper() as number
})
} }
export function dllPrinterReject(): number { export function dllPrinterReject(): number {
return traceCall('SAPI_PrinterMovetoreject', undefined, () => {
loadLibrary() loadLibrary()
if (!SAPI_PrinterMovetoreject) throw new Error('REJECT_API_UNAVAILABLE') if (!SAPI_PrinterMovetoreject) throw new Error('REJECT_API_UNAVAILABLE')
return SAPI_PrinterMovetoreject() as number return SAPI_PrinterMovetoreject() as number
})
}
export function dllGetPrinterCardPosition(): { queryCode: number; position: number } {
return traceCall('SAPI_GetPrinterCardPosition', undefined, () => {
loadLibrary()
if (!SAPI_GetPrinterCardPosition) throw new Error('CARD_POSITION_API_UNAVAILABLE')
const out = [0]
const ret = SAPI_GetPrinterCardPosition!(out) as number
return normalizeCardPositionQuery(ret, out[0])
})
}
export function dllPrinterCheckstatus(): number {
return traceCall('SAPI_PrinterCheckstatus', undefined, () => {
loadLibrary()
if (!SAPI_PrinterCheckstatus) throw new Error('CHECKSTATUS_API_UNAVAILABLE')
return SAPI_PrinterCheckstatus!() as number
})
} }
+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()
if (info.ok && info.data) {
configStore.setPrinter(parsePrinterInfo(info.data))
return
}
configStore.setPrinter({ ...configStore.printer, statusText: '未连接打印机' })
}
async function doInit(): Promise<void> {
const cfg = await configGet() const cfg = await configGet()
const sharedDir = cfg.data?.sharedDir || '' if (cfg.ok && cfg.data?.lastPrinterStatus) {
configStore.setSharedDir(sharedDir) configStore.setPrinter(cfg.data.lastPrinterStatus)
}
if (import.meta.env.DEV && cfg.data?.skipDllInit === true) { if (cfg.ok && cfg.data?.sharedDir) {
appStore.setInitialized(false, '开发模式已跳过 DLL 初始化') configStore.setSharedDir(cfg.data.sharedDir)
configStore.setPrinter({ ...configStore.printer, statusText: '未初始化(开发)' }) }
return return cfg
} }
const init = await dllInit({ sharedDir }) async function syncRejectApi(): Promise<void> {
if (!init.ok) {
appStore.setInitialized(false, init.message || 'Init 失败')
configStore.setPrinter({ ...configStore.printer, statusText: '未初始化' })
notify.error(init.message || '初始化失败,请检查任务目录权限')
return
}
appStore.setInitialized(true)
configStore.setPrinter({ ...configStore.printer, statusText: '系统已初始化' })
const warn = (init.data as { warning?: string } | undefined)?.warning
if (warn) notify.warning(warn)
try { try {
const rej = await dllRejectAvailable() const rej = await dllRejectAvailable()
if (rej.ok && rej.data) configStore.rejectApiAvailable = rej.data.available if (rej.ok && rej.data) configStore.rejectApiAvailable = rej.data.available
@@ -58,13 +42,39 @@ export function useAppBootstrap(): {
} }
} }
async function bootstrap(): Promise<void> {
const cfg = await hydrateFromConfig()
if (cfg.ok && cfg.data?.dllInitialized) {
appStore.setInitialized(true)
placeholderStatus(configStore, t('printerStatus.ready'))
await syncRejectApi()
window.setTimeout(() => void refreshPrinterAfterInit(), 1500)
return
}
const sharedDir = cfg.data?.sharedDir || ''
configStore.setSharedDir(sharedDir)
const init = await dllInit({ sharedDir })
if (!init.ok) {
appStore.setInitialized(false, init.message || t('printerStatus.initFailed'))
configStore.setPrinter({ ...configStore.printer, statusText: t('printerStatus.initFailed') })
notify.error(init.message || t('notify.initFailedHint'))
return
}
appStore.setInitialized(true)
const initMeta = init.data as { warning?: string } | undefined
if (initMeta?.warning) notify.warning(initMeta.warning)
placeholderStatus(configStore, initMeta?.warning ? t('printerStatus.notConnected') : t('printerStatus.ready'))
await syncRejectApi()
window.setTimeout(() => void refreshPrinterAfterInit(), 1500)
}
onMounted(() => { onMounted(() => {
if (bootstrapped) return if (bootstrapped) return
bootstrapped = true bootstrapped = true
window.setTimeout(() => { window.setTimeout(() => {
void doInit() void bootstrap()
}, 300) }, 100)
}) })
return { retryInit: doInit, refreshHeader }
} }
+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()
}
)
}
+30 -17
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: t('common.low'), value: 'low' },
{ label: t('common.mid'), value: 'mid' },
{ label: t('common.high'), value: 'high' }
]
}
export function ribbonTypeOptions(t: T): SelectOption[] {
return [
{ label: t('common.any'), value: 'any' },
{ label: 'YMCKO', value: 'YMCKO' }, { label: 'YMCKO', value: 'YMCKO' },
{ label: 'YMCK', value: 'YMCK' } { label: 'YMCK', value: 'YMCK' }
] ]
}
+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
}
+11 -15
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')
try {
await router.push('/distribute/running') await router.push('/distribute/running')
} catch {
await dllJobCancel(newJobId)
jobStore.clearActiveJob()
appStore.setMode('ready')
notify.error(t('notify.cannotEnterRunning'))
}
} finally { } finally {
jobStore.submitting = false jobStore.submitting = false
} }
@@ -295,6 +557,32 @@ async function onSubmit(): Promise<void> {
<style src="@/styles/pages/page4.css"></style> <style src="@/styles/pages/page4.css"></style>
<style scoped> <style scoped>
.m-net-cred-hint {
margin: 0 8px 6px;
font-size: 12px;
color: #606266;
}
.m-print-side-picker {
display: flex;
gap: 16px;
padding: 6px 12px 0;
font-size: 12px;
color: #606266;
}
.m-print-side-option {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.m-print-side-hint {
color: #e6a23c;
font-weight: 500;
}
.c-card-small--slot { .c-card-small--slot {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -322,10 +610,15 @@ async function onSubmit(): Promise<void> {
background: #f8f9fa; background: #f8f9fa;
} }
.c-field-value { .c-card-side-pick {
display: block; cursor: pointer;
overflow: hidden; }
text-overflow: ellipsis;
white-space: nowrap; .c-card-side-pick--off {
cursor: not-allowed;
}
.c-card-side--dim {
opacity: 0.35;
} }
</style> </style>
@@ -1,84 +0,0 @@
<template>
<AppShell>
<AppHeader mode="数据分发模式">
<div class="c-nav-group">
<NavButton icon="arrow-left" label="返回" @click="onBack" />
<NavButton icon="redo" label="重置" variant="primary" @click="onReset" />
</div>
</AppHeader>
<main class="app-shell__main l-main-full">
<section class="l-hero-container">
<div class="m-left-panel">
<div class="c-status-panel">
<h2 class="c-status-title is-error">任务失败</h2>
<p class="c-status-sub">请检查设备故障后重新插入数据卡</p>
<p class="c-status-counter">
任务已经完成<span class="ok">{{ jobStore.successCount }}</span
>其中失败次数是<span class="err">{{ jobStore.failCount }}</span>
</p>
<p v-if="errorText" class="m-error-detail">{{ errorText }}</p>
</div>
<WorkflowSteps mode="failed" :failed-step="3" />
</div>
<div class="m-right-panel">
<div class="m-error-icon">
<AppIcon name="warning" size="xl" />
</div>
</div>
</section>
</main>
<AppFooter />
</AppShell>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
import NavButton from '@/components/NavButton.vue'
import AppIcon from '@/components/AppIcon.vue'
import WorkflowSteps from '@/components/WorkflowSteps.vue'
import { useDistributeFormStore } from '@/stores/distributeForm'
import { useJobStore } from '@/stores/job'
import { useAppStore } from '@/stores/app'
import { dllPrinterErrorStr, pollJobStop } from '@/api/cardsoon'
const router = useRouter()
const formStore = useDistributeFormStore()
const jobStore = useJobStore()
const appStore = useAppStore()
const errorText = ref('')
onMounted(async () => {
appStore.setMode('ready')
await pollJobStop()
const r = await dllPrinterErrorStr(-1)
errorText.value = r.ok && r.data?.text ? r.data.text : ''
})
function onBack(): void {
jobStore.clearActiveJob()
router.push('/distribute/config')
}
function onReset(): void {
formStore.reset()
jobStore.reset()
router.push('/home')
}
</script>
<style src="@/styles/pages/page3.css"></style>
<style scoped>
.m-error-detail {
margin-top: 6px;
font-size: 10px;
color: #dc3545;
font-weight: 600;
max-width: 320px;
word-break: break-all;
}
</style>
@@ -1,29 +1,58 @@
<template> <template>
<AppShell> <AppShell>
<AppHeader :mode="headerMode"> <AppHeader :mode="headerMode">
<NavButton icon="stop" label="停止" variant="stop" @click="onStop" /> <div v-if="phase === 'failed'" class="c-nav-group">
<NavButton icon="arrow-left" :label="t('common.back')" @click="onFailedBack" />
<NavButton icon="redo" :label="t('common.reset')" variant="primary" @click="onFailedReset" />
</div>
<NavButton
v-else-if="phase === 'completed'"
icon="home"
:label="t('common.back')"
@click="onReturn"
/>
<NavButton v-else icon="stop" :label="t('common.stop')" variant="stop" @click="onStop" />
</AppHeader> </AppHeader>
<main class="app-shell__main l-main-full"> <main class="app-shell__main l-main-full">
<section class="l-hero-container"> <section class="l-hero-container">
<div class="m-left-panel"> <div class="m-left-panel">
<div class="c-status-panel"> <div class="c-status-panel">
<h2 class="c-status-title is-looping">{{ statusTitle }}</h2> <template v-if="phase === 'failed'">
<h2 class="c-status-title is-error">{{ t('running.taskFailed') }}</h2>
<p class="c-status-sub">{{ failedSub }}</p>
<p class="c-status-counter">
{{ t('running.progressCounter', { success: successCount, fail: failCount }) }}
</p>
<p v-if="failureErrorText" class="m-error-detail">{{ failureErrorText }}</p>
</template>
<template v-else>
<h2 class="c-status-title" :class="{ 'is-looping': phase === 'running' }">
{{ statusTitle }}
</h2>
<p class="c-status-sub">{{ statusSub }}</p> <p class="c-status-sub">{{ statusSub }}</p>
<p class="c-status-counter"> <p class="c-status-counter">
任务已经完成<span class="ok">{{ successCount }}</span>其中失败次数是<span {{ t('running.progressCounter', { success: successCount, fail: failCount }) }}
class="err"
>{{ failCount }}</span
>
</p> </p>
</template>
</div> </div>
<WorkflowSteps <WorkflowSteps
v-if="phase === 'failed'"
mode="failed"
:variant="isCollect ? 'collect' : 'distribute'"
:failed-step="failureStep"
/>
<WorkflowSteps
v-else
:active-step="workflowStep" :active-step="workflowStep"
:variant="isCollect ? 'collect' : 'distribute'" :variant="isCollect ? 'collect' : 'distribute'"
mode="running" mode="running"
/> />
</div> </div>
<div class="m-right-panel"> <div class="m-right-panel">
<div class="m-progress-circle"> <div v-if="phase === 'failed'" class="m-error-icon">
<AppIcon name="warning" size="xl" />
</div>
<div v-else class="m-progress-circle">
<svg viewBox="0 0 100 100"> <svg viewBox="0 0 100 100">
<circle class="bg" cx="50" cy="50" r="45" /> <circle class="bg" cx="50" cy="50" r="45" />
<circle <circle
@@ -46,135 +75,432 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { notify } from '@/composables/useNotify' import { notify } from '@/composables/useNotify'
import { refreshLiveStatus } from '@/composables/usePrinterStatus'
import AppShell from '@/layouts/AppShell.vue' import AppShell from '@/layouts/AppShell.vue'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue' import AppFooter from '@/components/AppFooter.vue'
import NavButton from '@/components/NavButton.vue' import NavButton from '@/components/NavButton.vue'
import AppIcon from '@/components/AppIcon.vue'
import WorkflowSteps from '@/components/WorkflowSteps.vue' import WorkflowSteps from '@/components/WorkflowSteps.vue'
import { useJobStore } from '@/stores/job' import { useJobStore } from '@/stores/job'
import { useCollectStore } from '@/stores/collect' import { useCollectStore } from '@/stores/collect'
import { useDistributeFormStore } from '@/stores/distributeForm'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
import { import {
dllJobCancel, dllJobCancel,
dllPrinterErrorStr,
dllUsbCopy,
onCardPositionTick,
onJobPollTick, onJobPollTick,
onUsbPollTick, onUsbPollTick,
pollCardPositionStart,
pollCardPositionStop,
pollJobStart, pollJobStart,
pollJobStop, pollJobStop,
pollUsbStop pollUsbStop
} from '@/api/cardsoon' } from '@/api/cardsoon'
import { mapJobStateToUi, shouldUseProgress } from '@/utils/job-state' import { createDistributeJob } from '@/utils/createDistributeJob'
import type { JobPollPayload, UsbPollPayload } from '@/types/ipc' import { validateJobPreflight } from '@/utils/validateJobPreflight'
import { mapJobStateToUi } from '@/utils/job-state'
import { POSITION_PREPARE, resolveCardPosition } from '@shared/card-position'
import {
USB_TASK_COPYING,
USB_TASK_PREPARING,
clampUsbCopyProgress,
usbTaskStatusHint
} from '@shared/usb-copy-state'
import type { CardPositionPollPayload, JobPollPayload, UsbPollPayload } from '@/types/ipc'
const CIRCLE_LEN = 283 const CIRCLE_LEN = 283
/** 等待进卡/读 U 盘超过该时长仍无进展,给出可操作提示(每个等待回合提示一次) */
const WAIT_WARN_MS = 45000
/** 看门狗检查间隔 */
const WAIT_WATCHDOG_MS = 5000
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { t } = useI18n()
const jobStore = useJobStore() const jobStore = useJobStore()
const collectStore = useCollectStore() const collectStore = useCollectStore()
const formStore = useDistributeFormStore()
const appStore = useAppStore() const appStore = useAppStore()
const isCollect = computed(() => route.name === 'collect-running') const isCollect = computed(() => route.name === 'collect-running')
const headerMode = computed(() => (isCollect.value ? '数据收集模式' : '数据分发模式')) const headerMode = computed(() => (isCollect.value ? t('header.modeCollect') : t('header.modeDistribute')))
const successCount = computed(() => const successCount = computed(() =>
isCollect.value ? collectStore.successCount : jobStore.successCount isCollect.value ? collectStore.successCount : jobStore.successCount
) )
const failCount = computed(() => (isCollect.value ? collectStore.failCount : jobStore.failCount)) const failCount = computed(() => (isCollect.value ? collectStore.failCount : jobStore.failCount))
const phase = ref<'running' | 'completed' | 'failed'>('running')
const progress = ref(0) const progress = ref(0)
const strokeOffset = ref(CIRCLE_LEN) const strokeOffset = ref(CIRCLE_LEN)
const workflowStep = ref(1) const workflowStep = ref(1)
const failureStep = ref(1)
const failureErrorText = ref('')
const waitCard = ref(false) const waitCard = ref(false)
let unsub: (() => void) | null = null const collectHint = ref('')
let fakeTimer: ReturnType<typeof setInterval> | null = null let unsubJob: (() => void) | null = null
let unsubUsb: (() => void) | null = null
let unsubCard: (() => void) | null = null
let finishing = false
let resubmitting = false
let lastCardPosition = -1
let usbAwaitNewCycle = false
let queryFailStreak = 0
let usbQueryFailStreak = 0
let waitSince = 0
let waitWarned = false
let waitWatchdog: ReturnType<typeof setInterval> | null = null
const displayProgress = computed(() => Math.round(progress.value)) const displayProgress = computed(() => Math.round(progress.value))
const statusTitle = computed(() => (waitCard.value ? '等待插卡' : '循环执行中')) const failedSub = computed(() =>
const statusSub = computed(() => isCollect.value
waitCard.value ? '请插入数据卡,任务将自动连续执行' : '任务将自动连续执行' ? t('running.failedSubCollect')
: t('running.failedSubDistribute')
) )
const statusTitle = computed(() => {
if (phase.value === 'completed') {
return isCollect.value ? t('running.collectCompleted') : t('running.taskCompleted')
}
if (isCollect.value) {
return collectHint.value || t('running.dataCollecting')
}
return waitCard.value ? t('running.waitingCard') : t('running.taskRunning')
})
const statusSub = computed(() => {
if (phase.value === 'completed') {
return isCollect.value
? t('running.completedSubCollect')
: t('running.completedSubDistribute')
}
if (isCollect.value) {
return collectHint.value || t('running.collectingHint')
}
return waitCard.value ? t('running.waitCardHint') : t('running.runningHint')
})
function setProgress(value: number): void { function setProgress(value: number): void {
const p = Math.min(100, Math.max(0, value)) const p = Math.min(100, Math.max(0, value))
progress.value = p progress.value = p
strokeOffset.value = CIRCLE_LEN - (CIRCLE_LEN * p) / 100 strokeOffset.value = CIRCLE_LEN - (CIRCLE_LEN * p) / 100
} }
function startFakeProgress(): void { function clearPollListeners(): void {
stopFakeProgress() unsubJob?.()
fakeTimer = setInterval(() => { unsubJob = null
if (progress.value >= 95) return unsubUsb?.()
setProgress(progress.value + 1.5 + Math.random() * 2.5) unsubUsb = null
}, 380) unsubCard?.()
unsubCard = null
} }
function stopFakeProgress(): void { /** 进入"等待进卡/读 U 盘"回合(已在等待中则不重复计时) */
if (fakeTimer) { function markWaiting(): void {
clearInterval(fakeTimer) if (waitSince === 0) {
fakeTimer = null waitSince = Date.now()
waitWarned = false
}
}
/** 离开等待状态(进卡成功 / 任务结束 / 重新提交) */
function clearWaiting(): void {
waitSince = 0
waitWarned = false
}
function stopWaitWatchdog(): void {
if (waitWatchdog) {
clearInterval(waitWatchdog)
waitWatchdog = null
}
clearWaiting()
}
function startWaitWatchdog(): void {
stopWaitWatchdog()
waitWatchdog = setInterval(() => {
if (phase.value !== 'running' || waitSince === 0 || waitWarned) return
if (Date.now() - waitSince < WAIT_WARN_MS) return
waitWarned = true
// 等待过久无进展:收集多为 U 盘数据线/卡片方向问题,分发多为卡片未到位/夹卡
notify.warning(
isCollect.value ? t('notify.waitTimeoutCollect') : t('notify.waitTimeoutDistribute')
)
}, WAIT_WATCHDOG_MS)
}
async function releasePolls(resetMode: boolean): Promise<void> {
clearPollListeners()
await pollCardPositionStop()
if (isCollect.value) {
await pollUsbStop({ resetMode })
} else {
await pollJobStop({ resetMode })
}
}
async function stopTaskPollsOnly(): Promise<void> {
unsubJob?.()
unsubJob = null
unsubUsb?.()
unsubUsb = null
if (isCollect.value) {
await pollUsbStop({ resetMode: false })
} else {
await pollJobStop({ resetMode: false })
}
}
async function startCardPositionWatch(
sessionMode: 'distributing' | 'usbCopying'
): Promise<void> {
await pollCardPositionStop()
unsubCard?.()
unsubCard = onCardPositionTick((payload) =>
applyCardPosition(payload as CardPositionPollPayload)
)
const cardStarted = await pollCardPositionStart(sessionMode)
if (!cardStarted.ok) {
notify.warning(cardStarted.message || t('notify.pollMonitorFailed'))
}
}
async function enterWaitPhase(next: 'completed' | 'failed', errorText = ''): Promise<void> {
clearWaiting()
if (phase.value !== next) {
phase.value = next
if (next === 'completed') {
setProgress(100)
workflowStep.value = isCollect.value ? 3 : 4
} else {
failureStep.value = workflowStep.value
failureErrorText.value = errorText
if (!failureErrorText.value && !isCollect.value) {
try {
const r = await dllPrinterErrorStr(-1)
failureErrorText.value = r.ok && r.data?.text ? r.data.text : ''
} catch {
failureErrorText.value = ''
}
}
jobStore.clearActiveJob()
}
} else if (next === 'failed' && errorText) {
failureErrorText.value = errorText
}
await stopTaskPollsOnly()
const sessionMode = isCollect.value ? 'usbCopying' : 'distributing'
appStore.setMode(sessionMode)
lastCardPosition = -1
await startCardPositionWatch(sessionMode)
void refreshLiveStatus()
}
async function enterFailedPhase(errorText = ''): Promise<void> {
await enterWaitPhase('failed', errorText)
}
async function enterCompletedPhase(): Promise<void> {
await enterWaitPhase('completed')
}
function canAutoResubmit(): boolean {
return phase.value === 'completed' || phase.value === 'failed'
}
function applyCardPosition(p: CardPositionPollPayload): void {
if (!canAutoResubmit() || resubmitting) return
const pos = resolveCardPosition(p.queryCode, p.position)
if (pos === null) return
const enteredPrepare = pos === POSITION_PREPARE && lastCardPosition !== POSITION_PREPARE
lastCardPosition = pos
if (!enteredPrepare) return
void resubmitTask()
}
async function resubmitTask(): Promise<void> {
if (resubmitting || !canAutoResubmit()) return
const fromWaitPhase = phase.value
resubmitting = true
await pollCardPositionStop()
unsubCard?.()
unsubCard = null
const backToWait = async (message?: string): Promise<void> => {
if (message) notify.error(message)
if (fromWaitPhase === 'failed') {
await enterFailedPhase(message || failureErrorText.value)
} else {
await enterCompletedPhase()
}
}
try {
if (isCollect.value) {
const dest = collectStore.destPath.trim()
if (!dest) {
await backToWait(t('notify.dirInvalid'))
return
}
phase.value = 'running'
setProgress(0)
workflowStep.value = 1
collectHint.value = ''
queryFailStreak = 0
usbQueryFailStreak = 0
clearWaiting()
const r = await dllUsbCopy(dest, collectStore.cardOutput, { resubmit: true })
if (!r.ok) {
await backToWait(r.message || t('notify.resubmitCollectFailed'))
return
}
usbAwaitNewCycle = true
collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
markWaiting()
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
void refreshLiveStatus()
return
}
if (!jobStore.lastJobJson) {
const err = await validateJobPreflight(formStore)
if (err) {
notify.warning(err)
await backToWait()
return
}
}
phase.value = 'running'
setProgress(0)
workflowStep.value = 1
waitCard.value = false
queryFailStreak = 0
clearWaiting()
const created = await createDistributeJob(formStore, { resubmit: true })
if (!created.ok) {
await backToWait(created.message)
return
}
jobStore.setActiveJob(created.jobId)
const started = await pollJobStart(created.jobId)
if (!started.ok) {
await backToWait(started.message || t('notify.pollStartFailed'))
return
}
unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
void refreshLiveStatus()
} catch (e) {
await backToWait(String(e))
} finally {
resubmitting = false
} }
} }
function applyJobProgress(p: JobPollPayload): void { function applyJobProgress(p: JobPollPayload): void {
if (finishing || phase.value !== 'running') return
if (p.queryErrorCode !== 0) {
queryFailStreak += 1
if (queryFailStreak < 3) return
jobStore.failCount += 1
void enterFailedPhase(t('notify.queryJobFailed', { code: p.queryErrorCode }))
return
}
queryFailStreak = 0
setProgress(p.progress)
const ui = mapJobStateToUi(p.jobState) const ui = mapJobStateToUi(p.jobState)
workflowStep.value = ui.workflowStep workflowStep.value = ui.workflowStep
waitCard.value = ui.hint === 'waitCard' waitCard.value = ui.hint === 'waitCard'
if (shouldUseProgress(p.jobState)) { if (ui.hint === 'waitCard') markWaiting()
setProgress(p.progress) else clearWaiting()
}
if (p.queryErrorCode !== 0) {
notify.error(`查询任务失败: ${p.queryErrorCode}`)
finishDistribute(false)
return
}
if (p.failed) { if (p.failed) {
jobStore.failCount += 1 jobStore.failCount += 1
router.push('/distribute/failed') // 优先使用主进程在失败当刻取到的错误串,避免事后取到通用文案
void enterFailedPhase(p.errorMessage || '')
return return
} }
if (p.cancelled) { if (p.cancelled) {
finishDistribute(false) void finishDistribute(false)
return return
} }
if (p.finished) { if (p.finished) {
jobStore.successCount += 1 jobStore.successCount += 1
void enterCompletedPhase()
return
} }
} }
function applyUsbProgress(p: UsbPollPayload): void { function applyUsbProgress(p: UsbPollPayload): void {
if (p.taskStatus === 1) workflowStep.value = 2 if (finishing || phase.value !== 'running') return
if (p.taskStatus === 1 && p.progress > 0) { if (p.queryCode !== 0) {
setProgress(Math.max(progress.value, p.progress)) usbQueryFailStreak += 1
if (usbQueryFailStreak < 3) return
collectStore.failCount += 1
void enterFailedPhase(t('notify.queryUsbFailed', { code: p.queryCode }))
return
} }
usbQueryFailStreak = 0
if (usbAwaitNewCycle && p.queryCode === 0 && p.taskStatus === USB_TASK_PREPARING) {
usbAwaitNewCycle = false
}
if (p.failed) { if (p.failed) {
collectStore.failCount += 1 collectStore.failCount += 1
notify.error('USB 收集失败') void enterFailedPhase(p.errorMessage || usbTaskStatusHint(p.taskStatus))
finishCollect(false)
return return
} }
if (p.success) { if (p.success) {
collectStore.successCount += 1 collectStore.successCount += 1
setProgress(100)
workflowStep.value = 3 workflowStep.value = 3
notify.success('USB 收集完成') collectHint.value = usbTaskStatusHint(p.taskStatus)
window.setTimeout(() => finishCollect(true), 600) notify.success(t('notify.usbCollectComplete'))
void enterCompletedPhase()
return
} }
const copyProgress = clampUsbCopyProgress(p.progress)
workflowStep.value = p.taskStatus === USB_TASK_COPYING ? 2 : 1
collectHint.value = usbTaskStatusHint(p.taskStatus)
// PREPARING(等待插卡/读 U 盘)开始计时;进入 COPYING 等后续阶段后清除
if (p.taskStatus === USB_TASK_PREPARING) markWaiting()
else clearWaiting()
setProgress(copyProgress)
} }
function finishDistribute(toHome: boolean): void { async function finishDistribute(
stopFakeProgress() toHome: boolean,
pollJobStop() redirect: '/home' | '/distribute/config' = toHome ? '/home' : '/distribute/config'
): Promise<void> {
if (finishing) return
finishing = true
const skipCancel = phase.value === 'completed' || phase.value === 'failed'
await releasePolls(true)
const id = jobStore.jobId
if (id && !skipCancel) {
const r = await dllJobCancel(id)
if (!r.ok) {
notify.warning(r.message || t('notify.cancelIssue'))
}
}
jobStore.clearActiveJob() jobStore.clearActiveJob()
appStore.setMode('ready') appStore.setMode('ready')
router.push(toHome ? '/home' : '/distribute/config') await router.push(redirect)
} }
function finishCollect(toHome: boolean): void { async function finishCollect(toHome: boolean): Promise<void> {
stopFakeProgress() if (finishing) return
pollUsbStop() finishing = true
await releasePolls(true)
appStore.setMode('ready') appStore.setMode('ready')
router.push(toHome ? '/home' : '/collect') await router.push(toHome ? '/home' : '/collect')
} }
onMounted(async () => { onMounted(async () => {
@@ -185,8 +511,11 @@ onMounted(async () => {
} }
workflowStep.value = 1 workflowStep.value = 1
setProgress(0) setProgress(0)
startFakeProgress() collectHint.value = usbTaskStatusHint(USB_TASK_PREPARING)
unsub = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload)) startWaitWatchdog()
markWaiting()
unsubUsb = onUsbPollTick((payload) => applyUsbProgress(payload as UsbPollPayload))
void refreshLiveStatus()
return return
} }
@@ -195,34 +524,86 @@ onMounted(async () => {
return return
} }
appStore.setMode('distributing') appStore.setMode('distributing')
await pollJobStart(jobStore.jobId) setProgress(0)
unsub = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload)) clearWaiting()
startWaitWatchdog()
const started = await pollJobStart(jobStore.jobId)
if (!started.ok) {
notify.error(started.message || t('notify.pollStartFailed'))
await finishDistribute(false)
return
}
unsubJob = onJobPollTick((payload) => applyJobProgress(payload as JobPollPayload))
void refreshLiveStatus()
}) })
onUnmounted(() => { onUnmounted(() => {
unsub?.() stopWaitWatchdog()
stopFakeProgress() if (finishing || phase.value !== 'running') return
if (isCollect.value) { void releasePolls(true)
if (appStore.mode === 'usbCopying') pollUsbStop() if (isCollect.value && appStore.mode === 'usbCopying') {
} else if (appStore.mode === 'distributing') { appStore.setMode('ready')
pollJobStop() } else if (!isCollect.value && appStore.mode === 'distributing') {
appStore.setMode('ready') appStore.setMode('ready')
} }
}) })
async function onStop(): Promise<void> { async function leaveFailedPage(redirect: '/collect' | '/distribute/config' | '/home'): Promise<void> {
finishing = true
await releasePolls(true)
appStore.setMode('ready')
await router.push(redirect)
}
async function onFailedBack(): Promise<void> {
if (isCollect.value) { if (isCollect.value) {
finishCollect(false) await leaveFailedPage('/collect')
return return
} }
await dllJobCancel(jobStore.jobId) await leaveFailedPage('/distribute/config')
finishDistribute(false) }
async function onFailedReset(): Promise<void> {
if (isCollect.value) {
collectStore.reset()
} else {
formStore.reset()
jobStore.reset()
}
await leaveFailedPage('/home')
}
async function onReturn(): Promise<void> {
if (isCollect.value) {
await finishCollect(false)
return
}
await finishDistribute(false)
}
async function onStop(): Promise<void> {
if (isCollect.value) {
await finishCollect(false)
return
}
await finishDistribute(false)
} }
</script> </script>
<style src="@/styles/pages/page2.css"></style> <style src="@/styles/pages/page2.css"></style>
<style src="@/styles/pages/page3.css"></style>
<style scoped> <style scoped>
.m-error-detail {
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: #dc3545;
font-weight: 600;
max-width: 340px;
word-break: break-all;
}
.m-progress-circle .fill { .m-progress-circle .fill {
stroke-dasharray: 283; stroke-dasharray: 283;
transition: stroke-dashoffset 0.45s ease; transition: stroke-dashoffset 0.45s ease;
+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