初始化

This commit is contained in:
24kycj
2026-05-22 19:56:22 +08:00
commit d0c3fd162a
77 changed files with 17058 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# 本地文档与设计稿(不入库)
docs/
# Cursor / IDE
.cursor/
# 系统
.DS_Store
Thumbs.db
desktop.ini
# 编辑器
.idea/
.vscode/
*.swp
*.suo
# 日志与环境
*.log
.env
.env.*
!.env.example
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
out/
release/
*.log
.env
.env.*
!.env.example
+1
View File
@@ -0,0 +1 @@
16.15.0
+24
View File
@@ -0,0 +1,24 @@
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import vue from '@vitejs/plugin-vue'
const sharedAlias = { '@shared': resolve('src/shared') }
export default defineConfig({
main: {
resolve: { alias: sharedAlias },
plugins: [externalizeDepsPlugin()]
},
preload: {
plugins: [externalizeDepsPlugin()]
},
renderer: {
resolve: {
alias: {
'@': resolve('src/renderer/src'),
...sharedAlias
}
},
plugins: [vue()]
}
})
+10441
View File
File diff suppressed because it is too large Load Diff
+61
View File
@@ -0,0 +1,61 @@
{
"name": "cardsoon-machine",
"version": "0.0.1",
"private": true,
"description": "卡树数据卡打印系统 Electron 客户端",
"main": "./out/main/index.js",
"engines": {
"node": "16.15.0"
},
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
"typecheck": "vue-tsc --noEmit -p tsconfig.web.json",
"dist": "electron-vite build && electron-builder"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.4.0",
"electron-log": "^5.1.2",
"electron-store": "^8.1.0",
"element-plus": "^2.4.4",
"koffi": "^2.9.0",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"vue-router": "^4.3.0"
},
"build": {
"appId": "com.cardsoon.machine",
"productName": "卡树数据卡打印系统",
"directories": {
"output": "release"
},
"extraResources": [
{
"from": "resources/native",
"to": "native"
}
],
"extraFiles": [
{
"from": "resources/native/CapSettings.json",
"to": "CapSettings.json"
}
],
"win": {
"target": [
"nsis"
]
}
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.6.2",
"electron": "20.3.12",
"electron-builder": "^24.13.3",
"electron-vite": "^1.0.29",
"sass": "^1.69.5",
"typescript": "^5.3.3",
"vite": "^4.5.2",
"vue-tsc": "^1.8.27"
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"printerList": [],
"Img": [],
"Text": []
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
export const CS_OK = 0
export const CS_FAIL = -1
export const JOB_STATUS_FINISHED = 100
export const JOB_STATUS_FAILED = 4
export const JOB_STATUS_CANCELLED = 6
export const JOB_STATUS_WAITCARD = 7
export const JOB_STATUS_PRINTING = 2
export const JOB_STATUS_COPYING = 3
export const POLL_INTERVAL_MS = 1000
export const JOB_ID_BUF_SIZE = 64
/** workDll 日志级别:仅致命,屏蔽无打印机时的 E/W 刷屏 */
export const LOG_FATAL_FLAG = 3
+121
View File
@@ -0,0 +1,121 @@
import { app, BrowserWindow, globalShortcut, screen } from 'electron'
import { join } from 'path'
app.commandLine.appendSwitch('disable-gpu-shader-disk-cache')
import log from 'electron-log'
import { suppressKnownDllStderr } from './utils/suppress-dll-stderr'
import { setupNativeWorkingDir } from './services/native-path'
suppressKnownDllStderr()
import { registerIpcHandlers, handleBeforeQuit } from './ipc/register-handlers'
import { setPollMainWindow } from './services/poll-manager'
import {
DESIGN_WIDTH,
contentHeightForWidth,
CONTENT_VIEWPORT_HEIGHT
} from '@shared/viewport'
let mainWindow: BrowserWindow | null = null
const MIN_CONTENT_WIDTH = 960
/** 默认内容区:约 85% 工作区宽,高按 720:390 */
function getDefaultWindowSize(): { width: number; height: number } {
const { width: sw, height: sh } = screen.getPrimaryDisplay().workAreaSize
let w = Math.max(1280, Math.min(Math.floor(sw * 0.85), 1600))
let h = contentHeightForWidth(w)
const maxH = Math.floor(sh * 0.85)
if (h > maxH) {
h = Math.max(contentHeightForWidth(MIN_CONTENT_WIDTH), maxH)
w = Math.round((h * DESIGN_WIDTH) / CONTENT_VIEWPORT_HEIGHT)
}
return { width: w, height: h }
}
function createWindow(): void {
const { width, height } = getDefaultWindowSize()
mainWindow = new BrowserWindow({
useContentSize: true,
width,
height,
minWidth: MIN_CONTENT_WIDTH,
minHeight: contentHeightForWidth(MIN_CONTENT_WIDTH),
show: false,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
})
setPollMainWindow(mainWindow)
mainWindow.on('ready-to-show', () => {
mainWindow?.center()
mainWindow?.show()
if (!app.isPackaged) {
mainWindow?.webContents.openDevTools({ mode: 'detach' })
}
})
mainWindow.webContents.on('before-input-event', (_event, input) => {
if (input.type === 'keyDown' && input.key === 'F12') {
mainWindow?.webContents.toggleDevTools()
}
})
// 内容区 720:390,画布仍 360 高,按宽缩放后底部可完整显示
mainWindow.on('resize', () => {
if (!mainWindow) return
const [cw, ch] = mainWindow.getContentSize()
const wantH = contentHeightForWidth(cw)
if (Math.abs(ch - wantH) > 2) {
mainWindow.setContentSize(cw, wantH)
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL)
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
app.whenReady().then(() => {
try {
setupNativeWorkingDir()
registerIpcHandlers()
createWindow()
if (!app.isPackaged) {
globalShortcut.register('CommandOrControl+Shift+I', () => {
const win = BrowserWindow.getFocusedWindow()
win?.webContents.toggleDevTools()
})
}
} catch (e) {
log.error('startup failed', e)
app.quit()
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('before-quit', () => {
if (!app.isPackaged) {
globalShortcut.unregisterAll()
}
handleBeforeQuit()
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
+244
View File
@@ -0,0 +1,244 @@
import { app, dialog, ipcMain, shell } from 'electron'
import fs from 'fs'
import path from 'path'
import log from 'electron-log'
import { CS_FAIL, CS_OK } from '../constants'
import { assertNotBusy, assertReady, mainAppState } from '../services/app-state'
import { configStore } from '../services/config-store'
import {
getActiveJobId,
startJobPoll,
startUsbPoll,
stopAllPolls,
stopJobPoll,
stopUsbPoll
} from '../services/poll-manager'
import {
dllAdminJobCancel,
dllCopyFromUsb,
dllGetPrinterErrorStr,
dllGetPrinterInfo,
dllInit,
dllPrinterReject,
dllPrinterReset,
dllRestJobEx,
isCancelApiAvailable,
isRejectApiAvailable
} from '../services/work-dll.service'
function ok<T>(data?: T) {
return { ok: true as const, code: CS_OK, data }
}
function fail(code: number, message: string) {
return { ok: false as const, code, message }
}
export function registerIpcHandlers(): void {
ipcMain.handle('dll:init', (_e, params) => {
stopAllPolls()
try {
const sharedDir = params?.sharedDir || (configStore.get('sharedDir') as string)
fs.mkdirSync(sharedDir, { recursive: true })
const code = dllInit({
sharedDir,
keepCombinedImage: params?.keepCombinedImage,
stopOnFailure: params?.stopOnFailure,
cleanTaskFile: params?.cleanTaskFile,
autoRetryTimes: params?.autoRetryTimes,
rejectConfig: params?.rejectConfig,
logLevel: params?.logLevel,
outBack: params?.outBack
})
if (code === CS_OK) {
mainAppState.initialized = true
configStore.set('sharedDir', sharedDir)
return ok({ printerDetected: true })
}
mainAppState.initialized = true
configStore.set('sharedDir', sharedDir)
log.warn(`SAPI_Init returned ${code}; UI ready, printer ops may fail until device connected`)
return ok({
printerDetected: false,
warning: '打印机未连接或驱动未就绪,界面可浏览,业务操作需接真机后重试 Init'
})
} catch (err) {
mainAppState.initialized = false
log.error('dll:init', err)
return fail(CS_FAIL, String(err))
}
})
ipcMain.handle('dll:printer-info', () => {
try {
assertReady()
const r = dllGetPrinterInfo()
if (!r.json) return fail(0, '未连接打印机')
return ok(r.json)
} catch (err) {
return fail(CS_FAIL, String(err))
}
})
ipcMain.handle('dll:printer-reset', () => {
try {
assertReady()
const code = dllPrinterReset()
return code === CS_OK ? ok() : fail(code, '重置失败')
} catch (err) {
return fail(CS_FAIL, String(err))
}
})
ipcMain.handle('dll:printer-reject', () => {
try {
assertReady()
if (!isRejectApiAvailable()) return fail(CS_FAIL, 'REJECT_API_UNAVAILABLE')
const code = dllPrinterReject()
return code === CS_OK ? ok() : fail(code, '废卡失败')
} catch (err) {
return fail(CS_FAIL, String(err))
}
})
ipcMain.handle('dll:printer-error-str', (_e, errorNo?: number) => {
try {
assertReady()
return ok({ text: dllGetPrinterErrorStr(errorNo ?? -1) })
} catch (err) {
return fail(CS_FAIL, String(err))
}
})
ipcMain.handle('dll:job-create', (_e, json: string) => {
try {
assertReady()
assertNotBusy()
const r = dllRestJobEx(json)
if (r.code !== CS_OK) return fail(r.code, 'RestJobEx 失败')
mainAppState.mode = 'distributing'
mainAppState.activeJobId = r.jobId
return ok({ jobId: r.jobId })
} catch (err) {
if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
return fail(CS_FAIL, String(err))
}
})
ipcMain.handle('dll:job-cancel', (_e, jobId: string) => {
try {
assertReady()
const id = jobId || mainAppState.activeJobId
stopJobPoll()
let code = CS_OK
if (isCancelApiAvailable()) {
code = dllAdminJobCancel(id)
} else {
log.info('dll:job-cancel: SAPI_AdminJobCancel not in DLL, poll stopped only')
}
mainAppState.mode = 'ready'
mainAppState.activeJobId = ''
return code === CS_OK ? ok() : fail(code, '取消失败')
} catch (err) {
return fail(CS_FAIL, String(err))
}
})
ipcMain.handle('dll:usb-copy', (_e, req: { destFolder: string; cardOutput: number }) => {
try {
assertReady()
assertNotBusy()
const code = dllCopyFromUsb(req.destFolder, req.cardOutput)
if (code !== CS_OK) {
return fail(code, '可能已有任务在执行')
}
mainAppState.mode = 'usbCopying'
return ok()
} catch (err) {
if (String(err).includes('BUSY')) return fail(CS_FAIL, '已有任务在执行')
return fail(CS_FAIL, String(err))
}
})
ipcMain.handle('poll:job-start', (_e, jobId: string) => {
startJobPoll(jobId)
return ok()
})
ipcMain.handle('poll:job-stop', () => {
stopJobPoll()
return ok()
})
ipcMain.handle('poll:usb-start', () => {
startUsbPoll()
return ok()
})
ipcMain.handle('poll:usb-stop', () => {
stopUsbPoll()
mainAppState.mode = 'ready'
return ok()
})
ipcMain.handle('dialog:open-directory', async () => {
const r = await dialog.showOpenDialog({ properties: ['openDirectory', 'multiSelections'] })
if (r.canceled || !r.filePaths.length) return ok({ paths: [] as string[] })
return ok({ paths: r.filePaths })
})
ipcMain.handle('dialog:open-file', async (_e, filters?: { name: string; extensions: string[] }[]) => {
const r = await dialog.showOpenDialog({
properties: ['openFile'],
filters: filters ?? [{ name: 'Soon', extensions: ['soon'] }]
})
if (r.canceled || !r.filePaths[0]) return ok({ path: '' })
return ok({ path: r.filePaths[0] })
})
ipcMain.handle('fs:path-exists', (_e, paths: string[]) => {
const missing = paths.filter((p) => {
const clean = p.replace(/\\\*\\.\\*$/i, '').replace(/\/\*\.\*$/i, '')
return !fs.existsSync(clean)
})
return ok({ missing })
})
ipcMain.handle('config:get', () =>
ok({
sharedDir: configStore.get('sharedDir'),
templateDir: configStore.get('templateDir'),
skipDllInit: configStore.get('skipDllInit', !app.isPackaged)
})
)
ipcMain.handle('config:set', (_e, patch: Record<string, string>) => {
Object.entries(patch).forEach(([k, v]) => configStore.set(k, v))
return ok(configStore.store)
})
ipcMain.handle('shell:open-path', (_e, target: string) => {
const dir = target || (configStore.get('templateDir') as string)
fs.mkdirSync(dir, { recursive: true })
shell.openPath(dir)
return ok()
})
ipcMain.handle('dll:reject-available', () => ok({ available: isRejectApiAvailable() }))
}
export async function handleBeforeQuit(): Promise<void> {
stopAllPolls()
if (
mainAppState.mode === 'distributing' &&
mainAppState.activeJobId &&
isCancelApiAvailable()
) {
try {
dllAdminJobCancel(mainAppState.activeJobId)
} catch (e) {
log.warn('before-quit cancel', e)
}
}
mainAppState.mode = 'ready'
}
+17
View File
@@ -0,0 +1,17 @@
export type MainWorkflowMode = 'ready' | 'distributing' | 'usbCopying'
export const mainAppState = {
initialized: false,
mode: 'ready' as MainWorkflowMode,
activeJobId: ''
}
export function assertReady(): void {
if (!mainAppState.initialized) throw new Error('NOT_INITIALIZED')
}
export function assertNotBusy(): void {
if (mainAppState.mode === 'distributing' || mainAppState.mode === 'usbCopying') {
throw new Error('BUSY')
}
}
+21
View File
@@ -0,0 +1,21 @@
import Store from 'electron-store'
import { app } from 'electron'
import path from 'path'
interface AppConfig {
sharedDir: string
templateDir: string
/** 开发默认 true:不调用 SAPI_Init,避免无打印机时 DLL 刷错 */
skipDllInit: boolean
}
const defaultShared = path.join(app.getPath('userData'), 'Cardsoon', 'tasks')
export const configStore = new Store<AppConfig>({
name: 'cardsoon-config',
defaults: {
sharedDir: defaultShared,
templateDir: path.join(app.getPath('userData'), 'Cardsoon', 'templates'),
skipDllInit: !app.isPackaged
}
})
+88
View File
@@ -0,0 +1,88 @@
import { app } from 'electron'
import path from 'path'
import fs from 'fs'
import log from 'electron-log'
const RUNTIME_CONFIG_FILES = ['CapSettings.json'] as const
const DEFAULT_PRINT_TASKS = path.join('C:', 'PrintTasks')
const DEFAULT_CAP_SETTINGS = `{
"printerList": [],
"Img": [],
"Text": []
}
`
export function getNativeDir(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'native')
}
return path.join(app.getAppPath(), 'resources', 'native')
}
export function getProcessExecDir(): string {
return path.dirname(process.execPath)
}
function ensureBundledConfig(nativeDir: string): void {
fs.mkdirSync(nativeDir, { recursive: true })
for (const name of RUNTIME_CONFIG_FILES) {
const filePath = path.join(nativeDir, name)
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, DEFAULT_CAP_SETTINGS, 'utf8')
log.info(`Created default native config: ${filePath}`)
}
}
}
function deployConfigFile(src: string, destDir: string): void {
const dest = path.join(destDir, path.basename(src))
try {
fs.mkdirSync(destDir, { recursive: true })
if (!fs.existsSync(dest)) {
fs.copyFileSync(src, dest)
log.debug(`Deployed ${path.basename(src)} -> ${dest}`)
return
}
const srcStat = fs.statSync(src)
const destStat = fs.statSync(dest)
if (srcStat.mtimeMs > destStat.mtimeMs) {
fs.copyFileSync(src, dest)
log.debug(`Updated ${path.basename(src)} -> ${dest}`)
}
} catch (e) {
log.warn(`Deploy ${path.basename(src)} to ${destDir} failed`, e)
}
}
function deployRuntimeConfigs(nativeDir: string): void {
const deployDirs = new Set<string>([
nativeDir,
getProcessExecDir(),
DEFAULT_PRINT_TASKS,
path.join(app.getPath('userData'), 'Cardsoon', 'runtime')
])
for (const name of RUNTIME_CONFIG_FILES) {
const src = path.join(nativeDir, name)
if (!fs.existsSync(src)) continue
deployDirs.forEach((dir) => deployConfigFile(src, dir))
}
try {
fs.mkdirSync(DEFAULT_PRINT_TASKS, { recursive: true })
} catch (e) {
log.warn(`Cannot create ${DEFAULT_PRINT_TASKS}`, e)
}
}
export function setupNativeWorkingDir(): void {
const nativeDir = getNativeDir()
if (!fs.existsSync(path.join(nativeDir, 'workDll.dll'))) {
throw new Error(`Native DLL directory not found or incomplete: ${nativeDir}`)
}
ensureBundledConfig(nativeDir)
deployRuntimeConfigs(nativeDir)
process.chdir(nativeDir)
log.debug(`Native working directory: ${nativeDir}`)
}
+106
View File
@@ -0,0 +1,106 @@
import { BrowserWindow } from 'electron'
import log from 'electron-log'
import { POLL_INTERVAL_MS } from '../constants'
import { mainAppState } from './app-state'
import { dllGetJobStateById, dllGetUsbCopyState } from './work-dll.service'
let jobTimer: ReturnType<typeof setInterval> | null = null
let usbTimer: ReturnType<typeof setInterval> | null = null
let jobId = ''
let mainWindow: BrowserWindow | null = null
export function setPollMainWindow(win: BrowserWindow): void {
mainWindow = win
}
function send(channel: string, payload: unknown): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(channel, payload)
}
}
export function stopJobPoll(): void {
if (jobTimer) {
clearInterval(jobTimer)
jobTimer = null
}
}
export function stopUsbPoll(): void {
if (usbTimer) {
clearInterval(usbTimer)
usbTimer = null
}
}
export function stopAllPolls(): void {
stopJobPoll()
stopUsbPoll()
}
export function startJobPoll(id: string): void {
stopJobPoll()
jobId = id
jobTimer = setInterval(() => {
try {
const r = dllGetJobStateById(jobId)
const failed = r.jobState === 4
const cancelled = r.jobState === 6
const finished = r.jobState === 100
const terminal = failed || cancelled
send('job:poll-tick', {
jobId,
queryErrorCode: r.queryErrorCode,
jobState: r.jobState,
progress: r.progress,
terminal,
failed,
cancelled,
finished
})
if (r.queryErrorCode !== 0) {
log.warn('GetJobStateById query failed', r.queryErrorCode)
stopJobPoll()
return
}
if (failed || cancelled) {
stopJobPoll()
if (cancelled) mainAppState.mode = 'ready'
}
} catch (e) {
log.error('job poll error', e)
stopJobPoll()
}
}, POLL_INTERVAL_MS)
}
export function startUsbPoll(): void {
stopUsbPoll()
usbTimer = setInterval(() => {
try {
const r = dllGetUsbCopyState()
const failed = r.taskStatus === 3
const success = r.taskStatus === 2
const terminal = failed || success
send('usb:poll-tick', {
taskStatus: r.taskStatus,
progress: r.progress,
terminal,
failed,
success
})
if (terminal) {
stopUsbPoll()
mainAppState.mode = 'ready'
}
} catch (e) {
log.error('usb poll error', e)
stopUsbPoll()
mainAppState.mode = 'ready'
}
}, POLL_INTERVAL_MS)
}
export function getActiveJobId(): string {
return jobId
}
+182
View File
@@ -0,0 +1,182 @@
import path from 'path'
import koffi from 'koffi'
import log from 'electron-log'
import { CS_OK, JOB_ID_BUF_SIZE, LOG_FATAL_FLAG } from '../constants'
import { getNativeDir } from './native-path'
export interface InitParams {
sharedDir: string
keepCombinedImage?: boolean
stopOnFailure?: boolean
cleanTaskFile?: boolean
autoRetryTimes?: number
rejectConfig?: boolean
logLevel?: number
outBack?: boolean
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let lib: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_Init: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterInfo: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetPrinterErrorStr: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_RestJobEx: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetJobStateById: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_AdminJobCancel: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_CopyFromUsb: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_GetUsbCopyState: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterResetprinter: any = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let SAPI_PrinterMovetoreject: any = null
let hasRejectApi = false
let hasCancelApi = false
let loggedCancelMissing = false
let loggedRejectMissing = false
function loadLibrary(): void {
if (lib) return
const dllPath = path.join(getNativeDir(), 'workDll.dll')
lib = koffi.load(dllPath)
SAPI_Init = lib.func('int __stdcall SAPI_Init(str, bool, bool, bool, int, bool, int, bool)')
SAPI_GetPrinterInfo = lib.func('int __stdcall SAPI_GetPrinterInfo(_Out_ void **)')
SAPI_GetPrinterErrorStr = lib.func('str __stdcall SAPI_GetPrinterErrorStr(int)')
SAPI_RestJobEx = lib.func('int __stdcall SAPI_RestJobEx(str, _Out_ char *, int)')
SAPI_GetJobStateById = lib.func(
'int __stdcall SAPI_GetJobStateById(str, _Out_ int *, _Out_ int *)'
)
SAPI_CopyFromUsb = lib.func('int __stdcall SAPI_CopyFromUsb(str, int)')
SAPI_GetUsbCopyState = lib.func('int __stdcall SAPI_GetUsbCopyState(_Out_ int *, _Out_ int *)')
SAPI_PrinterResetprinter = lib.func('int __stdcall SAPI_PrinterResetprinter()')
try {
SAPI_AdminJobCancel = lib.func('int __stdcall SAPI_AdminJobCancel(str)')
hasCancelApi = true
} catch {
SAPI_AdminJobCancel = null
hasCancelApi = false
if (!loggedCancelMissing) {
loggedCancelMissing = true
log.info('SAPI_AdminJobCancel not in workDll (optional); stop uses poll-stop only')
}
}
try {
SAPI_PrinterMovetoreject = lib.func('int __stdcall SAPI_PrinterMovetoreject()')
hasRejectApi = true
} catch {
hasRejectApi = false
if (!loggedRejectMissing) {
loggedRejectMissing = true
log.info('SAPI_PrinterMovetoreject not in workDll (optional); reject card disabled')
}
}
}
export function isRejectApiAvailable(): boolean {
loadLibrary()
return hasRejectApi
}
export function isCancelApiAvailable(): boolean {
loadLibrary()
return hasCancelApi
}
export function dllInit(params: InitParams): number {
loadLibrary()
return SAPI_Init!(
params.sharedDir,
params.keepCombinedImage ?? true,
params.stopOnFailure ?? false,
params.cleanTaskFile ?? true,
params.autoRetryTimes ?? 0,
params.rejectConfig ?? true,
params.logLevel ?? LOG_FATAL_FLAG,
params.outBack ?? false
) as number
}
export function dllGetPrinterInfo(): { code: number; json?: Record<string, unknown> } {
loadLibrary()
const outPtr = koffi.alloc('void *', 8)
try {
const len = SAPI_GetPrinterInfo!(outPtr) as number
if (len <= 0) return { code: len }
const ptr = koffi.decode(outPtr, 0, 'void *') as number
const jsonStr = koffi.decode(ptr, 'char', len) as string
koffi.free(ptr)
return { code: len, json: JSON.parse(jsonStr) as Record<string, unknown> }
} finally {
koffi.free(outPtr)
}
}
export function dllGetPrinterErrorStr(errorNo = -1): string {
loadLibrary()
const s = SAPI_GetPrinterErrorStr!(errorNo) as string
return s || ''
}
export function dllRestJobEx(json: string): { code: number; jobId: string } {
loadLibrary()
const buf = Buffer.alloc(JOB_ID_BUF_SIZE)
const code = SAPI_RestJobEx!(json, buf, JOB_ID_BUF_SIZE) as number
const jobId = buf.toString('utf8').replace(/\0.*$/, '').trim()
return { code, jobId }
}
export function dllGetJobStateById(jobId: string): {
queryErrorCode: number
jobState: number
progress: number
} {
loadLibrary()
const jobState = [0]
const copyScheduler = [0]
const queryErrorCode = SAPI_GetJobStateById!(jobId, jobState, copyScheduler) as number
return {
queryErrorCode,
jobState: jobState[0],
progress: copyScheduler[0]
}
}
export function dllAdminJobCancel(jobId: string): number {
loadLibrary()
if (!SAPI_AdminJobCancel) throw new Error('CANCEL_API_UNAVAILABLE')
return SAPI_AdminJobCancel(jobId) as number
}
export function dllCopyFromUsb(destFolder: string, cardOutput: number): number {
loadLibrary()
return SAPI_CopyFromUsb!(destFolder, cardOutput) as number
}
export function dllGetUsbCopyState(): { taskStatus: number; progress: number } {
loadLibrary()
const taskStatus = [0]
const progress = [0]
SAPI_GetUsbCopyState!(taskStatus, progress)
return { taskStatus: taskStatus[0], progress: progress[0] }
}
export function dllPrinterReset(): number {
loadLibrary()
return SAPI_PrinterResetprinter!() as number
}
export function dllPrinterReject(): number {
loadLibrary()
if (!SAPI_PrinterMovetoreject) throw new Error('REJECT_API_UNAVAILABLE')
return SAPI_PrinterMovetoreject() as number
}
+28
View File
@@ -0,0 +1,28 @@
import { app } from 'electron'
const SUPPRESS_PATTERNS = [
'Card Printer not detected',
'PrinterAdaptor.cpp',
'ServerAPI.cpp:654',
'using time over',
'Fail to read template file'
]
function shouldSuppress(chunk: string | Uint8Array): boolean {
const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')
return SUPPRESS_PATTERNS.some((p) => text.includes(p))
}
export function suppressKnownDllStderr(): void {
if (app.isPackaged) return
const stderr = process.stderr
const original = stderr.write.bind(stderr)
stderr.write = ((chunk: string | Uint8Array, ...args: unknown[]) => {
if (shouldSuppress(chunk)) {
return true
}
return (original as (...a: unknown[]) => boolean)(chunk, ...args)
}) as typeof stderr.write
}
+45
View File
@@ -0,0 +1,45 @@
import { contextBridge, ipcRenderer } from 'electron'
const channels = {
invoke: [
'dll:init',
'dll:printer-info',
'dll:printer-reset',
'dll:printer-reject',
'dll:printer-error-str',
'dll:job-create',
'dll:job-cancel',
'dll:usb-copy',
'poll:job-start',
'poll:job-stop',
'poll:usb-start',
'poll:usb-stop',
'dialog:open-directory',
'dialog:open-file',
'fs:path-exists',
'config:get',
'config:set',
'shell:open-path',
'dll:reject-available'
] as const,
on: ['job:poll-tick', 'usb:poll-tick'] as const
}
const cardsoonApi = {
invoke(channel: (typeof channels.invoke)[number], ...args: unknown[]) {
if (!(channels.invoke as readonly string[]).includes(channel)) {
throw new Error(`IPC channel not allowed: ${channel}`)
}
return ipcRenderer.invoke(channel, ...args)
},
on(channel: (typeof channels.on)[number], listener: (...args: unknown[]) => void) {
if (!(channels.on as readonly string[]).includes(channel)) {
throw new Error(`IPC event not allowed: ${channel}`)
}
const subscription = (_event: unknown, payload: unknown) => listener(payload)
ipcRenderer.on(channel, subscription)
return () => ipcRenderer.removeListener(channel, subscription)
}
}
contextBridge.exposeInMainWorld('cardsoonApi', cardsoonApi)
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>卡树数据卡打印系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
<template>
<router-view />
</template>
<script setup lang="ts">
import { useAppBootstrap } from '@/composables/useAppBootstrap'
useAppBootstrap()
</script>
+107
View File
@@ -0,0 +1,107 @@
import type { InitParamsDTO, IpcResult, JobPollPayload, UsbPollPayload } from '@/types/ipc'
import type { PrinterStatusDisplay } from '@/types/printer'
function api() {
return window.cardsoonApi
}
export async function dllInit(params: InitParamsDTO): Promise<IpcResult> {
return api().invoke('dll:init', params) as Promise<IpcResult>
}
export async function dllPrinterInfo(): Promise<IpcResult<Record<string, unknown>>> {
return api().invoke('dll:printer-info') as Promise<IpcResult<Record<string, unknown>>>
}
export function parsePrinterInfo(json: Record<string, unknown>): PrinterStatusDisplay {
const list = (json.printerList as Record<string, unknown>[]) || []
const p = list[0] || {}
return {
ribbonType: String(p.RibbonType ?? '—'),
statusText: String(p.PrinterType ?? '—'),
serialNo: String(p.PrinterName ?? '—'),
printedCount: Number(p.PrintedCount ?? 0)
}
}
export async function dllPrinterReset(): Promise<IpcResult> {
return api().invoke('dll:printer-reset') as Promise<IpcResult>
}
export async function dllPrinterReject(): Promise<IpcResult> {
return api().invoke('dll:printer-reject') as Promise<IpcResult>
}
export async function dllRejectAvailable(): Promise<IpcResult<{ available: boolean }>> {
return api().invoke('dll:reject-available') as Promise<IpcResult<{ available: boolean }>>
}
export async function dllPrinterErrorStr(errorNo = -1): 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 }>> {
return api().invoke('dll:job-create', json) as Promise<IpcResult<{ jobId: string }>>
}
export async function dllJobCancel(jobId: string): Promise<IpcResult> {
return api().invoke('dll:job-cancel', jobId) as Promise<IpcResult>
}
export async function dllUsbCopy(destFolder: string, cardOutput: number): Promise<IpcResult> {
return api().invoke('dll:usb-copy', { destFolder, cardOutput }) as Promise<IpcResult>
}
export async function pollJobStart(jobId: string): Promise<IpcResult> {
return api().invoke('poll:job-start', jobId) as Promise<IpcResult>
}
export async function pollJobStop(): Promise<IpcResult> {
return api().invoke('poll:job-stop') as Promise<IpcResult>
}
export async function pollUsbStart(): Promise<IpcResult> {
return api().invoke('poll:usb-start') as Promise<IpcResult>
}
export async function pollUsbStop(): Promise<IpcResult> {
return api().invoke('poll:usb-stop') as Promise<IpcResult>
}
export function onJobPollTick(cb: (p: JobPollPayload) => void): () => void {
return api().on('job:poll-tick', cb as (...args: unknown[]) => void)
}
export function onUsbPollTick(cb: (p: UsbPollPayload) => void): () => void {
return api().on('usb:poll-tick', cb as (...args: unknown[]) => void)
}
export async function dialogOpenDirectory(): Promise<IpcResult<{ paths: string[] }>> {
return api().invoke('dialog:open-directory') as Promise<IpcResult<{ paths: string[] }>>
}
export async function dialogOpenSoon(): Promise<IpcResult<{ path: string }>> {
return api().invoke('dialog:open-file', [{ name: 'Soon', extensions: ['soon'] }]) as Promise<
IpcResult<{ path: string }>
>
}
export async function fsPathExists(paths: string[]): Promise<IpcResult<{ missing: string[] }>> {
return api().invoke('fs:path-exists', paths) as Promise<IpcResult<{ missing: string[] }>>
}
export async function configGet(): Promise<
IpcResult<{ sharedDir: string; templateDir: string; skipDllInit?: boolean }>
> {
return api().invoke('config:get') as Promise<
IpcResult<{ sharedDir: string; templateDir: string; skipDllInit?: boolean }>
>
}
export async function configSet(patch: Record<string, string>): Promise<IpcResult> {
return api().invoke('config:set', patch) as Promise<IpcResult>
}
export async function shellOpenTemplateDir(): Promise<IpcResult> {
return api().invoke('shell:open-path', '') as Promise<IpcResult>
}
Binary file not shown.
@@ -0,0 +1,38 @@
export const ICON_NAMES = [
'redo',
'trash',
'paint-brush',
'share',
'download',
'arrow-left',
'check-circle',
'info-circle',
'times',
'home',
'folder-open',
'plus',
'exchange',
'stop',
'warning'
] as const
export type IconName = (typeof ICON_NAMES)[number]
/** 与 docs/design/index.html 中 Font Awesome 类名一致 */
export const ICON_FA_CLASS: Record<IconName, string> = {
redo: 'fas fa-redo',
trash: 'fas fa-trash-alt',
'paint-brush': 'fas fa-paint-brush',
share: 'fas fa-share-alt',
download: 'fas fa-download',
'arrow-left': 'fas fa-arrow-left',
'check-circle': 'fas fa-check-circle',
'info-circle': 'fas fa-info-circle',
times: 'fas fa-times',
home: 'fas fa-home',
'folder-open': 'fas fa-folder-open',
plus: 'fas fa-plus',
exchange: 'fas fa-exchange-alt',
stop: 'fas fa-stop',
warning: 'fas fa-exclamation-triangle'
}
@@ -0,0 +1,7 @@
<template>
<footer class="c-footer">
<div>版本V1.0</div>
<div>www.cardsoon.com</div>
<div>版权所有 © 2026 卡树科技</div>
</footer>
</template>
@@ -0,0 +1,29 @@
<template>
<header class="c-header">
<div class="c-header__brand">CARDSOON</div>
<div class="c-header__center">
<div v-if="mode" class="c-mode-badge c-mode-badge--home">{{ mode }}</div>
<div class="c-status-capsule">
<span>色带: <b>{{ status.ribbonType }}</b></span>
<span>状态: <b>{{ status.statusText }}</b></span>
<span>序列号: <b>{{ status.serialNo }}</b></span>
<span>已发行: <b>{{ status.printedCount }}</b></span>
</div>
</div>
<div class="c-header__actions-slot">
<div class="c-header-actions">
<slot />
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useConfigStore } from '@/stores/config'
defineProps<{ mode?: string }>()
const configStore = useConfigStore()
const status = computed(() => configStore.printer)
</script>
@@ -0,0 +1,18 @@
<template>
<i :class="classes" aria-hidden="true" />
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ICON_FA_CLASS, type IconName } from '@/assets/icons'
const props = withDefaults(
defineProps<{
name: IconName
size?: 'sm' | 'md' | 'lg' | 'xl'
}>(),
{ size: 'md' }
)
const classes = computed(() => [ICON_FA_CLASS[props.name], `app-icon--${props.size}`])
</script>
@@ -0,0 +1,115 @@
<template>
<div class="m-settings-modal" :class="{ 'is-open': modelValue }" :aria-hidden="!modelValue">
<div class="m-settings-modal__backdrop" @click="close" />
<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>
</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">
<div class="m-settings-row">
<label for="settingPriority">优先级</label>
<select id="settingPriority" v-model="form.priority" class="c-select">
<option value="low"></option>
<option value="mid"></option>
<option value="high"></option>
</select>
<label for="settingRibbonType">色带类型</label>
<select id="settingRibbonType" v-model="form.ribbonType" class="c-select">
<option value="any">任何</option>
<option value="YMCKO">YMCKO</option>
<option value="YMCK">YMCK</option>
</select>
</div>
<div class="m-settings-row">
<label for="settingCopyFormat">拷贝前格式化类型</label>
<select id="settingCopyFormat" v-model="form.formatType" class="c-select">
<option value="none">不格式化</option>
<option value="fat">快速格式化</option>
<option value="ntfs">完全格式化</option>
</select>
</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="c-button-cs m-settings-modal__confirm" @click="close">
确定
</button>
</footer>
</section>
</div>
</template>
<script setup lang="ts">
import { onUnmounted, watch } from 'vue'
import { useDistributeFormStore } from '@/stores/distributeForm'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
const form = useDistributeFormStore()
function close(): void {
emit('update:modelValue', false)
}
function onKeydown(e: KeyboardEvent): void {
if (e.key === 'Escape') close()
}
watch(
() => props.modelValue,
(open) => {
if (open) window.addEventListener('keydown', onKeydown)
else window.removeEventListener('keydown', onKeydown)
}
)
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</script>
<style src="@/styles/pages/page4.css"></style>
@@ -0,0 +1,35 @@
<template>
<button
type="button"
class="c-nav-btn"
:class="btnClass"
@click="$emit('click')"
>
<AppIcon v-if="icon" :name="icon" size="sm" />
<span v-if="label">{{ label }}</span>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { IconName } from '@/assets/icons'
const props = withDefaults(
defineProps<{
icon?: IconName
label?: string
variant?: 'default' | 'primary' | 'stop'
active?: boolean
}>(),
{ variant: 'default', active: false }
)
defineEmits<{ click: [] }>()
const btnClass = computed(() => ({
'c-nav-btn--primary': props.variant === 'primary',
'c-nav-btn--stop': props.variant === 'stop',
'is-active': props.active
}))
</script>
@@ -0,0 +1,53 @@
<template>
<div class="m-steps-flow">
<template v-for="(step, index) in steps" :key="step.key">
<div class="step-item" :class="stepItemClass(index)">
<div class="step-dot" />
<span class="step-label">{{ step.label }}</span>
</div>
<div v-if="index < steps.length - 1" class="step-line" :class="stepLineClass(index)" />
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
activeStep?: number
failedStep?: number
mode?: 'running' | 'failed'
}>(),
{ activeStep: 2, mode: 'running' }
)
const steps = [
{ key: 'prep', label: '任务准备' },
{ key: 'copy', label: '拷贝数据' },
{ key: 'print', label: '打印卡片' },
{ key: 'done', label: '完成' }
]
const failedStep = computed(() => props.failedStep ?? (props.mode === 'failed' ? 3 : -1))
function stepItemClass(index: number): Record<string, boolean> {
const n = index + 1
if (props.mode === 'failed') {
if (n < failedStep.value) return { 'is-completed': true }
if (n === failedStep.value) return { 'is-error': true }
return {}
}
return { 'is-active': n <= props.activeStep }
}
function stepLineClass(index: number): Record<string, boolean> {
const n = index + 1
if (props.mode === 'failed') {
if (n < failedStep.value - 1) return { 'is-completed': true }
if (n === failedStep.value - 1) return { 'is-error': true }
return {}
}
return { 'is-active': n < props.activeStep }
}
</script>
@@ -0,0 +1,65 @@
import { onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
configGet,
dllInit,
dllPrinterInfo,
dllRejectAvailable,
parsePrinterInfo
} from '@/api/cardsoon'
import { useAppStore } from '@/stores/app'
import { useConfigStore } from '@/stores/config'
let bootstrapped = false
export function useAppBootstrap(): {
retryInit: () => Promise<void>
refreshHeader: () => Promise<void>
} {
const appStore = useAppStore()
const configStore = useConfigStore()
async function refreshHeader(): Promise<void> {
const info = await dllPrinterInfo()
if (info.ok && info.data) {
configStore.setPrinter(parsePrinterInfo(info.data))
}
}
async function doInit(): Promise<void> {
const cfg = await configGet()
const sharedDir = cfg.data?.sharedDir || ''
configStore.setSharedDir(sharedDir)
const skipDll = cfg.data?.skipDllInit ?? import.meta.env.DEV
if (skipDll) {
appStore.setInitialized(true)
return
}
const init = await dllInit({ sharedDir, logLevel: 3 })
if (!init.ok) {
appStore.setInitialized(false, init.message || 'Init 失败')
ElMessage.error(init.message || '初始化失败,请检查任务目录权限')
return
}
appStore.setInitialized(true)
const warn = (init.data as { warning?: string } | undefined)?.warning
if (warn) ElMessage.warning(warn)
try {
await refreshHeader()
} catch {
/* 无打印机时 GetPrinterInfo 可能失败,保留 Mock 展示 */
}
const rej = await dllRejectAvailable()
if (rej.ok && rej.data) configStore.rejectApiAvailable = rej.data.available
}
onMounted(async () => {
if (bootstrapped) return
bootstrapped = true
await doInit()
})
return { retryInit: doInit, refreshHeader }
}
@@ -0,0 +1,27 @@
import { onMounted, onUnmounted, type Ref } from 'vue'
import { DESIGN_WIDTH, DESIGN_HEIGHT } from '@shared/viewport'
export { DESIGN_WIDTH, DESIGN_HEIGHT }
/** 按内容区宽度铺满画布,垂直居中;内容区高按 720:390 预留时底部完整可见 */
export function useScale(shellRef: Ref<HTMLElement | null>): void {
function updateScale(): void {
const shell = shellRef.value
if (!shell) return
const scale = window.innerWidth / DESIGN_WIDTH
const offsetY = Math.max(0, (window.innerHeight - DESIGN_HEIGHT * scale) / 2)
shell.style.transformOrigin = '0 0'
shell.style.transform = `translate(0px, ${offsetY}px) scale(${scale})`
}
onMounted(() => {
updateScale()
window.addEventListener('resize', updateScale)
})
onUnmounted(() => {
window.removeEventListener('resize', updateScale)
})
}
+22
View File
@@ -0,0 +1,22 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
import type { IpcResult } from '@/types/ipc'
interface CardsoonApi {
invoke(channel: string, ...args: unknown[]): Promise<IpcResult>
on(channel: string, listener: (...args: unknown[]) => void): () => void
}
declare global {
interface Window {
cardsoonApi: CardsoonApi
}
}
export {}
+13
View File
@@ -0,0 +1,13 @@
<template>
<div ref="shellRef" class="app-shell">
<slot />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useScale } from '@/composables/useScale'
const shellRef = ref<HTMLElement | null>(null)
useScale(shellRef)
</script>
+15
View File
@@ -0,0 +1,15 @@
import { createApp } from 'vue'
import { ElMessage } from 'element-plus'
import 'element-plus/theme-chalk/el-message.css'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './styles/design-base.css'
import './styles/icons-font.css'
import './styles/shell.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.config.globalProperties.$message = ElMessage
app.mount('#app')
+32
View File
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,9 @@
import type { PrinterStatusDisplay } from '@/types/printer'
/** 阶段一 Header 展示;阶段二由 GetPrinterInfo 替换 */
export const mockPrinterStatus: PrinterStatusDisplay = {
ribbonType: 'YMCKO',
statusText: '50/300',
serialNo: 'S103B29035',
printedCount: 190
}
+34
View File
@@ -0,0 +1,34 @@
import type { Router } from 'vue-router'
import { useJobStore } from '@/stores/job'
import { useAppStore } from '@/stores/app'
/**
* 无 jobId 访问 running → 重定向 config
* distributing 时访问 collect → 重定向 home
* 离开 running(非 failed)→ 清 distributing,回 config
*/
export function setupRouterGuards(router: Router): void {
router.beforeEach((to, from) => {
const job = useJobStore()
const app = useAppStore()
if (to.path === '/distribute/running') {
if (!job.jobId && !job.mockJobStarted) {
return { path: '/distribute/config' }
}
}
if (to.path === '/collect' && app.mode === 'distributing') {
return { path: '/home' }
}
if (from.path === '/distribute/running' && to.path !== '/distribute/failed') {
if (to.path !== '/distribute/config') {
app.setMode('ready')
return { path: '/distribute/config' }
}
}
return true
})
}
+30
View File
@@ -0,0 +1,30 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { setupRouterGuards } from './guards'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', redirect: '/home' },
{ path: '/home', name: 'home', component: () => import('@/views/HomeView.vue') },
{
path: '/distribute/config',
name: 'distribute-config',
component: () => import('@/views/DistributeConfigView.vue')
},
{
path: '/distribute/running',
name: 'distribute-running',
component: () => import('@/views/DistributeRunningView.vue')
},
{
path: '/distribute/failed',
name: 'distribute-failed',
component: () => import('@/views/DistributeFailedView.vue')
},
{ path: '/collect', name: 'collect', component: () => import('@/views/DataCollectView.vue') }
]
})
setupRouterGuards(router)
export default router
+23
View File
@@ -0,0 +1,23 @@
import { defineStore } from 'pinia'
export type AppWorkflowMode = 'ready' | 'distributing' | 'usbCopying'
export const useAppStore = defineStore('app', {
state: () => ({
initialized: false,
initError: '',
mode: 'ready' as AppWorkflowMode
}),
getters: {
isBusy: (state) => state.mode !== 'ready'
},
actions: {
setMode(mode: AppWorkflowMode) {
this.mode = mode
},
setInitialized(ok: boolean, err = '') {
this.initialized = ok
this.initError = err
}
}
})
+14
View File
@@ -0,0 +1,14 @@
import { defineStore } from 'pinia'
export const useCollectStore = defineStore('collect', {
state: () => ({
destPath: 'C:/Users/jerry',
cardOutput: 1 as 1 | 2
}),
actions: {
reset() {
this.destPath = 'C:/Users/jerry'
this.cardOutput = 1
}
}
})
+20
View File
@@ -0,0 +1,20 @@
import { defineStore } from 'pinia'
import type { PrinterStatusDisplay } from '@/types/printer'
import { mockPrinterStatus } from '@/mocks/printer'
export const useConfigStore = defineStore('config', {
state: () => ({
sharedDir: '',
templateDir: '',
printer: { ...mockPrinterStatus } as PrinterStatusDisplay,
rejectApiAvailable: false
}),
actions: {
setPrinter(p: PrinterStatusDisplay) {
this.printer = p
},
setSharedDir(dir: string) {
this.sharedDir = dir
}
}
})
@@ -0,0 +1,58 @@
import { defineStore } from 'pinia'
export interface PathListItem {
path: string
meta: string
}
export interface DistributeFormState {
pathList: PathListItem[]
volumeLabel: string
templateFile: string
copyType: 0 | 1
formatType: 'none' | 'fat' | 'ntfs'
dongleEnabled: boolean
dongleMode: -1 | 0 | 255
priority: 'low' | 'mid' | 'high'
ribbonType: 'any' | 'YMCKO' | 'YMCK'
generateIso: boolean
generateZip: boolean
printCmdToHasi: boolean
presetCopy: boolean
generateHasi: boolean
dongleCountCheck: boolean
failPrintLabel: boolean
}
function createDefaultForm(): DistributeFormState {
return {
pathList: [
{ path: 'D:\\数据备份\\2026-04-20\\*.*', meta: '128 文件 | 3.8 GB' },
{ path: 'C:\\Users\\Public\\Documents\\*.*', meta: '45 文件 | 520 MB' }
],
volumeLabel: 'DATA_CARD',
templateFile: 'D:\\images\\template.jpg',
copyType: 0,
formatType: 'none',
dongleEnabled: true,
dongleMode: -1,
priority: 'low',
ribbonType: 'any',
generateIso: false,
generateZip: false,
printCmdToHasi: false,
presetCopy: false,
generateHasi: false,
dongleCountCheck: false,
failPrintLabel: false
}
}
export const useDistributeFormStore = defineStore('distributeForm', {
state: (): DistributeFormState => createDefaultForm(),
actions: {
reset() {
Object.assign(this, createDefaultForm())
}
}
})
+27
View File
@@ -0,0 +1,27 @@
import { defineStore } from 'pinia'
export const useJobStore = defineStore('job', {
state: () => ({
jobId: '',
mockJobStarted: false,
submitting: false,
successCount: 0,
failCount: 0
}),
actions: {
setActiveJob(jobId: string) {
this.jobId = jobId
this.mockJobStarted = true
},
markMockStarted() {
this.mockJobStarted = true
},
reset() {
this.jobId = ''
this.mockJobStarted = false
this.submitting = false
this.successCount = 0
this.failCount = 0
}
}
})
+407
View File
@@ -0,0 +1,407 @@
@use './tokens.scss';
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: 'Microsoft YaHei', 'Inter', -apple-system, sans-serif;
}
html,
body,
#app {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
overflow: hidden;
background: #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
}
.app-shell {
width: 720px;
height: 360px;
background: var(--cs-bg);
display: flex;
flex-direction: column;
transform-origin: center;
flex-shrink: 0;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
}
.l-main-grid {
flex: 1;
display: grid;
grid-template-columns: 140px 1fr 140px;
padding: 10px;
gap: 10px;
overflow: hidden;
}
.l-main-full {
flex: 1;
display: flex;
flex-direction: column;
padding: 10px;
overflow: hidden;
}
.l-main-flex {
flex: 1;
display: flex;
padding: 8px;
gap: 8px;
overflow: hidden;
}
.c-header {
height: var(--h-header);
background: #fff;
display: flex;
align-items: center;
padding: 0 10px;
border-bottom: 1px solid var(--cs-border);
gap: 10px;
}
.c-header__brand {
font-size: 16px;
font-weight: 800;
letter-spacing: 1px;
color: #444;
flex: 0 0 auto;
min-width: 90px;
}
.c-header__center {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0;
overflow: hidden;
}
.c-header__actions-slot {
flex: 0 0 auto;
min-width: 100px;
display: flex;
align-items: center;
justify-content: flex-end;
}
.c-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.c-mode-badge {
background: #e9ecef;
padding: 1px 12px;
border-radius: 4px;
font-size: 11px;
font-weight: 800;
color: #495057;
border: 1px solid var(--cs-border);
margin-bottom: 2px;
}
.c-status-capsule {
display: flex;
gap: 16px;
font-size: 10px;
color: #888;
white-space: nowrap;
}
.c-status-capsule span {
display: flex;
align-items: center;
gap: 4px;
}
.c-status-capsule b {
color: var(--cs-primary);
font-weight: 700;
}
.c-nav-group {
display: flex;
align-items: center;
gap: 4px;
}
.c-nav-group .c-nav-btn--primary {
margin-left: 8px;
}
.c-nav-btn {
width: 36px;
height: 44px;
background: var(--cs-bg-soft);
border: none;
border-radius: var(--radius-sm);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: 0.2s;
}
.c-nav-btn i {
font-size: 16px;
color: #555;
}
.c-nav-btn span {
font-size: 9px;
margin-top: 2px;
font-weight: 600;
color: #555;
}
.c-nav-btn:hover {
background: #e2e8f0;
}
.c-nav-btn.is-active {
background: #dbe2ef;
}
.c-nav-btn--primary {
width: 48px !important;
height: 48px !important;
background: linear-gradient(135deg, var(--cs-primary) 0%, #2d8a2d 100%) !important;
box-shadow: 0 3px 10px rgba(0, 128, 0, 0.35) !important;
}
.c-nav-btn--primary i,
.c-nav-btn--primary span {
color: #fff !important;
}
.c-nav-btn--primary i {
font-size: 18px !important;
}
.c-nav-btn--primary span {
font-weight: 700 !important;
}
.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,
.c-nav-btn--stop span {
color: #fff !important;
}
.c-nav-btn--stop i {
font-size: 18px !important;
}
.c-nav-btn--stop span {
font-weight: 700 !important;
}
.c-footer {
height: var(--h-footer);
padding: 0 15px;
background: #fff;
border-top: 1px solid var(--cs-border);
display: flex;
justify-content: space-between;
align-items: center;
color: #999;
font-size: 9px;
}
.app-shell__main {
flex: 1;
min-height: 0;
overflow: hidden;
}
.c-panel {
background: #fff;
border: 1px solid var(--cs-border);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
overflow: hidden;
}
.c-panel__header {
height: 32px;
padding: 0 10px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.c-panel__title {
font-size: 12px;
font-weight: 700;
}
.c-panel__body {
flex: 1;
overflow-y: auto;
}
.c-panel__footer {
padding: 4px 10px;
border-top: 1px solid #f0f0f0;
}
.c-select,
.c-input {
padding: 0 4px;
border: 1px solid #ddd;
border-radius: 2px;
font-size: 10px;
background: #fff;
outline: none;
height: 18px;
line-height: 16px;
}
.c-button-cs {
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;
cursor: pointer;
}
.c-progress {
height: 12px;
background: #bdc3c7;
border-radius: 6px;
position: relative;
overflow: hidden;
margin: 2px 10px;
}
.c-progress-fill {
height: 100%;
background: var(--cs-dark-grey);
border-radius: 6px;
}
.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;
z-index: 2;
}
.c-data-table-mini {
width: 100%;
border-collapse: separate;
border-spacing: 0 2px;
font-size: 10px !important;
}
.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;
}
.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 8px;
height: 20px;
}
.c-preview-area {
background: #2d3436;
margin: 8px;
height: 105px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
}
.c-card-small {
width: 135px;
height: 88px;
background: #fff;
border-radius: 4px;
padding: 6px;
display: flex;
flex-direction: column;
}
.c-card-small__row {
font-size: 8px;
color: #333;
margin-bottom: 2px;
}
.c-card-small__label {
font-weight: 800;
}
.c-status-panel {
text-align: center;
margin-bottom: 6px;
}
.c-status-title {
font-size: 20px;
font-weight: 900;
margin-bottom: 1px;
}
.c-status-title.is-error {
color: #e63946;
}
.c-status-title.is-working {
color: #2ecc71;
}
.c-status-sub {
font-size: 9px;
color: #7f8c8d;
font-weight: 700;
}
+604
View File
@@ -0,0 +1,604 @@
/*
Cardsoon System UI (CSUI) - 核心组件库 V1.0
采用 BEM 命名规范:c-[组件名], l-[布局名], is-[状态]
*/
:root {
/* --- 调色盘 (Theme Palette - Derived from case.png) --- */
--cs-primary: #4b7e4a;
--cs-primary-hover: #3d673c;
--cs-dark-grey: #5a6268;
--cs-bg: #f5f7fa;
--cs-white: #ffffff;
--cs-text-main: #333333;
--cs-text-muted: #666666;
--cs-border: #e0e4e8;
--cs-bg-soft: #f0f2f5;
/* --- 尺寸规范 (Metrics) --- */
--h-header: 54px;
--h-footer: 24px;
--radius-sm: 4px;
--radius-md: 8px;
/* --- 阴影 (Shadows) --- */
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 1. 基础重置 (Base Reset) */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family:
'Inter',
-apple-system,
sans-serif;
}
body {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
overflow: hidden; /* 防止缩放出现滚动条 */
background: #1a1a1a; /* 深色背景,突出缩放容器 */
display: flex;
align-items: center;
justify-content: center;
}
/* 2. 系统壳层 (App Shell) */
.app-shell {
width: 720px;
height: 360px;
background: var(--cs-bg);
display: flex;
flex-direction: column;
transform-origin: center; /* 从中心缩放 */
flex-shrink: 0; /* 禁止压缩 */
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
/* 3. 布局模式 (Layout Modes) */
/* 模式 A:三栏网格布局 (Page 1, 2) */
.l-main-grid {
flex: 1;
display: grid;
grid-template-columns: 140px 1fr 140px;
padding: 10px;
gap: 10px;
overflow: hidden;
}
/* 模式 B:全宽布局 (Page 5) */
.l-main-full {
flex: 1;
display: flex;
flex-direction: column;
padding: 10px;
overflow: hidden;
}
/* 模式 C:分栏弹性布局 (Page 7) */
.l-main-flex {
flex: 1;
display: flex;
padding: 8px;
gap: 8px;
overflow: hidden;
}
/* 4. 核心组件 (Components) */
/* [组件] Header */
.c-header {
height: var(--h-header);
background: #fff;
display: flex;
align-items: center;
padding: 0 10px;
border-bottom: 1px solid var(--cs-border);
gap: 10px;
}
.c-header__brand {
font-size: 16px;
font-weight: 800;
letter-spacing: 1px;
color: #444;
flex: 0 0 auto;
min-width: 90px; /* 保证 Page 3 的基础对称性 */
}
.c-header__center {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 0; /* 防止内容溢出遮挡 */
overflow: hidden;
}
.c-header__title {
font-size: 14px;
font-weight: 800;
color: #000;
line-height: 1.2;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
width: 100%;
text-align: center;
}
/* 动作插槽容器 */
.c-header__actions-slot {
flex: 0 0 auto;
min-width: 100px;
display: flex;
align-items: center;
justify-content: flex-end;
}
.c-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.action-divider {
width: 1px;
height: 16px;
background: #ddd;
margin: 0 4px;
}
/* [组件] 状态信息栏 */
.c-status-capsule {
display: flex;
gap: 16px;
font-size: 10px;
color: #888;
white-space: nowrap;
}
.c-status-capsule span {
display: flex;
align-items: center;
gap: 4px;
}
.c-status-capsule b {
color: var(--cs-primary);
font-weight: 700;
}
/* [组件] 步骤进度条 Steps */
.c-steps {
display: flex;
background: #f0f2f5;
height: 28px;
border-bottom: 1px solid var(--cs-border);
}
.c-step {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: #999;
position: relative;
font-weight: 600;
}
.c-step:not(:last-child)::after {
content: '';
position: absolute;
right: -10px;
border-top: 14px solid transparent;
border-bottom: 14px solid transparent;
border-left: 10px solid #f0f2f5;
z-index: 2;
}
.c-step.is-active {
background: var(--cs-primary);
color: white;
}
.c-step.is-active::after {
border-left-color: var(--cs-primary) !important;
}
.c-step.is-completed {
color: var(--cs-primary);
background: #eef9ed;
}
/* [组件] 导航按钮组 */
.c-nav-group {
display: flex;
align-items: center;
gap: 4px;
}
/* 主按钮前增加视觉分隔,突出主按钮 */
.c-nav-group .c-nav-btn--primary {
margin-left: 8px;
}
.c-nav-btn {
width: 36px; /* 极限宽度,确保 7 个按钮全显 */
height: 44px;
background: var(--cs-bg-soft);
border: none;
border-radius: var(--radius-sm);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: 0.2s;
}
.c-nav-btn i {
font-size: 16px;
color: #555;
}
.c-nav-btn span {
font-size: 9px;
margin-top: 2px;
font-weight: 600;
color: #555;
}
.c-nav-btn:hover {
background: #e2e8f0;
}
.c-nav-btn.is-active {
background: #dbe2ef;
}
/* 主按钮强化样式 - 与普通按钮明显区分 */
.c-nav-btn--primary {
width: 48px !important;
height: 48px !important;
background: linear-gradient(135deg, var(--cs-primary) 0%, #2d8a2d 100%) !important;
box-shadow: 0 3px 10px rgba(0, 128, 0, 0.35) !important;
}
.c-nav-btn--primary i {
color: #fff !important;
font-size: 18px !important;
}
.c-nav-btn--primary span {
color: #fff !important;
font-weight: 700 !important;
}
.c-nav-btn--primary:hover {
background: linear-gradient(135deg, #2d8a2d 0%, #1f6b1f 100%) !important;
box-shadow: 0 4px 14px rgba(0, 128, 0, 0.45) !important;
transform: translateY(-1px) !important;
}
/* [组件] 面板 Panel */
.c-panel {
background: #fff;
border: 1px solid var(--cs-border);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
overflow: hidden;
}
.c-panel__header {
height: 32px;
padding: 0 10px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.c-panel__title {
font-size: 12px;
font-weight: 700;
}
.c-panel__body {
flex: 1;
overflow-y: auto;
}
.c-panel__footer {
padding: 4px 10px;
border-top: 1px solid #f0f0f0;
}
/* [组件] 通用按钮 Button */
.c-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 36px;
padding: 0 16px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: 0.2s;
border: 1px solid var(--cs-border);
background: linear-gradient(to bottom, #ffffff, #f8f9fa);
color: #444;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.c-button--primary {
background: linear-gradient(to bottom, #3a7a32, #2d5a27);
color: #fff;
border-color: #24491f;
box-shadow: 0 2px 4px rgba(45, 90, 39, 0.2);
}
.c-button--mini {
height: 18px; /* 强行锁定高度 */
padding: 0 4px;
font-size: 10px;
gap: 3px;
}
.c-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
filter: brightness(1.05);
}
/* [组件] 表单控件 Form */
.c-select,
.c-input {
padding: 0 4px;
border: 1px solid #ddd;
border-radius: 2px;
font-size: 10px;
background: #fff;
outline: none;
height: 18px;
line-height: 16px;
display: inline-flex;
align-items: center;
}
/* ==========================================================================
业务通用模块 (Business Common Modules)
========================================================================== */
/* 1. 模式说明模块 (Mode Info) - 压缩高度 */
.c-mode-panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
margin-bottom: 6px;
text-align: center;
}
.c-mode-badge {
background: #e9ecef;
padding: 1px 12px;
border-radius: 4px;
font-size: 11px;
font-weight: 800;
color: #495057;
border: 1px solid var(--cs-border);
}
.c-mode-desc {
font-size: 9px;
color: #868e96;
font-weight: 700;
margin: 0;
}
/* 2. 状态消息模块 (Status Group) - 压缩高度 */
.c-status-panel {
text-align: center;
margin-bottom: 6px;
}
.c-status-title {
font-size: 20px;
font-weight: 900;
margin-bottom: 1px;
}
.c-status-title.is-error {
color: #e63946;
}
.c-status-title.is-working {
color: #2ecc71;
}
.c-status-sub {
font-size: 9px;
color: #7f8c8d;
font-weight: 700;
}
/* 3. 工作流进度条模块 (Workflow System) - 整体缩小并支持叠层 */
.c-workflow {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 6px;
}
.c-workflow__step {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
width: 62px;
transition: 0.3s;
}
/* 图标容器:支持单图标和叠层图标 (Page 3 样式) */
.c-workflow__icon-wrap {
font-size: 20px;
height: 20px;
line-height: 20px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
/* 叠层图标逻辑 (复刻 Page 3) */
.c-workflow__icon-stack {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.c-workflow__badge {
position: absolute;
bottom: -3px;
right: -4px;
font-size: 10px !important;
color: #e67e22 !important;
background: #fff;
border-radius: 50%;
padding: 1px;
}
.c-workflow__text {
font-size: 8px;
color: #7f8c8d;
font-weight: 800;
white-space: nowrap;
}
/* 工业风箭头同步缩小 */
.c-workflow__arrow {
width: 35px;
height: 8px;
background: #bdc3c7;
position: relative;
margin-top: 6px; /* (20px icon - 8px arrow) / 2 */
}
.c-workflow__arrow::after {
content: '';
position: absolute;
right: -7px;
top: -4px;
border-left: 8px solid #bdc3c7;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
}
/* 状态色与动画 */
.c-workflow__step.is-completed .c-workflow__icon {
color: #00b894;
}
.c-workflow__step.is-active .c-workflow__icon {
color: #00b894;
}
.c-workflow__step.is-error .c-workflow__icon {
color: #d63031;
}
.c-workflow__step.is-pending .c-workflow__icon {
color: #fdcb6e;
opacity: 0.4;
}
.c-workflow__arrow.is-active,
.c-workflow__arrow.is-active::after {
background-color: #00b894;
border-left-color: #00b894;
}
/* 特殊图标叠加 (如待处理标记) */
.c-workflow__badge {
position: absolute;
bottom: -2px;
right: -4px;
font-size: 10px !important;
background: #fff;
border-radius: 50%;
}
/* 4. 居中内容承载器 */
.l-hero-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 10px 0;
}
/* [组件] Footer */
.c-footer {
height: var(--h-footer);
padding: 0 15px;
background: #fff;
border-top: 1px solid var(--cs-border);
display: flex;
justify-content: space-between;
align-items: center;
color: #999;
font-size: 9px;
}
/*
==========================================================================
CSUI (Cardsoon UI) 业务模组 - 基于 Page 7 完美版
==========================================================================
*/
/* 1. 窄版进度条 (Progress Bar) */
.c-progress {
height: 12px; background: #bdc3c7; border-radius: 6px;
position: relative; overflow: hidden; margin: 2px 10px;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
}
.c-progress-fill {
height: 100%; background: var(--cs-dark-grey); border-radius: 6px;
}
.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 2px rgba(0, 0, 0, 0.8); z-index: 2; line-height: 12px;
}
/* 2. 紧凑型数据表格 (Data Table) */
.c-data-table-mini { width: 100%; border-collapse: separate; border-spacing: 0 2px; font-size: 10px !important; }
.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; }
.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 8px; height: 20px;
}
/* 3. 模组化按钮规范 */
.c-button-cs {
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;
cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
}
.c-button-cs:hover { background: var(--cs-primary-hover) !important; }
/* 4. 文件列表项规范 (File Item) */
.c-file-item { display: flex; align-items: center; padding: 6px 10px; border-bottom: 1px solid #f8f8f8; }
.c-file-item__icon { width: 32px; font-size: 22px; color: var(--cs-dark-grey); margin-right: 12px; text-align: center; }
.c-file-item__info { flex: 1; min-width: 0; }
.c-file-item__name { font-size: 11px; font-weight: 700; color: #333; }
.c-file-item__meta { font-size: 9px; color: #999; }
.c-file-item__delete { border: none; background: none; color: #e74c3c; font-size: 14px; cursor: pointer; transition: 0.2s; }
.c-file-item__delete:hover { color: #ff0000; transform: scale(1.1); }
/* 5. 模组化动态行 */
.c-dynamic-row { display: flex; align-items: center; gap: 6px; min-height: 20px; margin-top: 2px; }
.c-dynamic-row > span { width: 35%; font-size: 10px; color: #666; font-weight: 700; }
.c-dynamic-row .c-select, .c-dynamic-row .c-input {
height: 20px; font-size: 10px; border: 1px solid #dcdfe6; border-radius: 3px; padding: 0 8px;
}
/* 6. 拟物化预览模组 (Preview Module - Sync from Page 7) */
.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;
}
@@ -0,0 +1,67 @@
/* Font Awesome 6.4 本地字体(打包进 assets,离线可用) */
@font-face {
font-family: 'Font Awesome 6 Free';
font-style: normal;
font-weight: 900;
font-display: block;
src: url('../assets/fonts/fa-solid-900.woff2') format('woff2');
}
.fas,
.fa-solid {
font-family: 'Font Awesome 6 Free';
font-weight: 900;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
display: inline-block;
font-style: normal;
font-variant: normal;
line-height: 1;
text-rendering: auto;
}
.fa-redo::before {
content: '\f01e';
}
.fa-trash-alt::before {
content: '\f2ed';
}
.fa-paint-brush::before {
content: '\f1fc';
}
.fa-share-alt::before {
content: '\f1e0';
}
.fa-download::before {
content: '\f019';
}
.fa-arrow-left::before {
content: '\f060';
}
.fa-check-circle::before {
content: '\f058';
}
.fa-info-circle::before {
content: '\f05a';
}
.fa-times::before {
content: '\f00d';
}
.fa-home::before {
content: '\f015';
}
.fa-folder-open::before {
content: '\f07c';
}
.fa-plus::before {
content: '\f067';
}
.fa-exchange-alt::before {
content: '\f362';
}
.fa-stop::before {
content: '\f04d';
}
.fa-exclamation-triangle::before {
content: '\f071';
}
+128
View File
@@ -0,0 +1,128 @@
/*
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);
}
@@ -0,0 +1,128 @@
/*
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);
}
+360
View File
@@ -0,0 +1,360 @@
/*
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);
}
@@ -0,0 +1,360 @@
/*
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);
}
+172
View File
@@ -0,0 +1,172 @@
/*
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;
}
@@ -0,0 +1,172 @@
/*
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;
}
+660
View File
@@ -0,0 +1,660 @@
/*
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;
}
@@ -0,0 +1,660 @@
/*
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;
}
+117
View File
@@ -0,0 +1,117 @@
/* Electron 壳层:覆盖 design 原型用的深色信箱背景 */
html,
body,
#app {
background: var(--cs-bg) !important;
}
#app {
width: 100vw;
height: 100vh;
display: block;
overflow: hidden;
position: relative;
}
/* 720×360 逻辑画布;窗口内容区高 720:390useScale 按宽 scale */
.app-shell {
position: absolute;
left: 0;
top: 0;
width: 720px;
height: 360px;
box-shadow: none;
transform-origin: 0 0;
}
/*
* 首页两侧空白主要来自 page2.css .l-dashboard
* padding 左右 + justify-content:center + 左栏固定 180px / 右栏 max-width:400px
* DevTools 里选中 main.app-shell__main.l-dashboard 可看到盒模型
*/
.app-shell__main.l-dashboard {
padding: 20px 40px;
gap: 48px;
justify-content: space-between;
}
.app-shell__main.l-dashboard .m-task-section {
max-width: none;
}
.app-shell__main {
flex: 1;
min-height: 0;
overflow: hidden;
}
.app-shell__main.l-dashboard,
.app-shell__main.l-main-flex,
.app-shell__main.l-main-full,
.app-shell__main.l-mobile-landscape {
flex: 1;
min-height: 0;
}
.c-button-cs:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.m-tool-btn,
.m-task-card {
font-family: inherit;
appearance: none;
}
.c-mode-badge--home {
margin-bottom: 2px;
}
/* 与 page2.css 中 .m-tool-btn i / .m-task-icon i 对齐 */
.m-tool-btn .fas {
font-size: 16px;
color: #6c757d;
width: 20px;
text-align: center;
transition: color 0.2s ease;
}
.m-tool-btn:hover .fas {
color: var(--cs-primary);
}
.m-task-icon .fas {
font-size: 18px;
color: #6c757d;
transition: color 0.25s ease;
}
.m-task-card:hover .m-task-icon .fas {
color: #fff;
}
.app-icon--sm {
font-size: 14px;
}
.app-icon--md {
font-size: 16px;
}
.app-icon--lg {
font-size: 18px;
}
.app-icon--xl {
font-size: 70px;
}
.c-nav-btn--stop .app-icon--sm,
.c-nav-btn--stop .app-icon--md {
color: #fff;
}
.m-error-icon .app-icon--xl {
color: #fff;
}
+19
View File
@@ -0,0 +1,19 @@
:root {
--cs-primary: #4b7e4a;
--cs-primary-hover: #3d673c;
--cs-dark-grey: #5a6268;
--cs-bg: #f5f7fa;
--cs-white: #ffffff;
--cs-text-main: #333333;
--cs-text-muted: #666666;
--cs-border: #e0e4e8;
--cs-bg-soft: #f0f2f5;
--h-header: 54px;
--h-footer: 24px;
--radius-sm: 4px;
--radius-md: 8px;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-hover: 0 4px 12px rgba(0, 0, 0, 0.1);
}
+36
View File
@@ -0,0 +1,36 @@
export interface IpcResult<T = void> {
ok: boolean
code: number
data?: T
message?: string
}
export interface InitParamsDTO {
sharedDir: string
keepCombinedImage?: boolean
stopOnFailure?: boolean
cleanTaskFile?: boolean
autoRetryTimes?: number
rejectConfig?: boolean
logLevel?: number
outBack?: boolean
}
export interface JobPollPayload {
jobId: string
queryErrorCode: number
jobState: number
progress: number
terminal: boolean
failed: boolean
cancelled: boolean
finished: boolean
}
export interface UsbPollPayload {
taskStatus: number
progress: number
terminal: boolean
failed: boolean
success: boolean
}
+6
View File
@@ -0,0 +1,6 @@
export interface PrinterStatusDisplay {
ribbonType: string
statusText: string
serialNo: string
printedCount: number
}
@@ -0,0 +1,34 @@
import type { DistributeFormState } from '@/stores/distributeForm'
function cleanPath(p: string): string {
return p.replace(/\\\*\\.\\*$/i, '').replace(/\/\*\.\*$/i, '').trim()
}
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> = {
task_id: taskId,
print_copys: 1,
has_print_task: hasPrint,
has_copy_task: hasCopy,
label: form.volumeLabel || 'DATA_CARD',
file_type: String(form.copyType),
zone_type: form.copyType === 1 ? '1' : '0',
need_format: form.formatType !== 'none',
format_file: form.formatType === 'ntfs' ? 'NTFS' : 'FAT',
disk_size: '4GB',
dongle_install_count: form.dongleEnabled ? form.dongleMode : -1
}
if (hasCopy) {
body.path_file = form.pathList.map((x) => cleanPath(x.path))
}
if (hasPrint) {
body.json_file = form.templateFile.trim()
body.print_flag = 1
}
if (form.generateIso) body.is_generate_iso = true
if (form.generateZip) body.is_generate_zip = true
return body
}
+64
View File
@@ -0,0 +1,64 @@
export function mapJobStateToUi(jobState: number) {
switch (jobState) {
case 100:
return {
workflowStep: 4 as const,
workflowStatus: 'completed' as const,
terminal: true,
failed: false,
hint: ''
}
case 4:
return {
workflowStep: 3 as const,
workflowStatus: 'error' as const,
terminal: true,
failed: true,
hint: ''
}
case 6:
return {
workflowStep: 1 as const,
workflowStatus: 'working' as const,
terminal: true,
failed: false,
hint: 'cancelled'
}
case 7:
return {
workflowStep: 1 as const,
workflowStatus: 'working' as const,
terminal: false,
failed: false,
hint: 'waitCard'
}
case 2:
return {
workflowStep: 3 as const,
workflowStatus: 'working' as const,
terminal: false,
failed: false,
hint: ''
}
case 3:
return {
workflowStep: 2 as const,
workflowStatus: 'working' as const,
terminal: false,
failed: false,
hint: ''
}
default:
return {
workflowStep: 1 as const,
workflowStatus: 'working' as const,
terminal: false,
failed: false,
hint: ''
}
}
}
export function shouldUseProgress(jobState: number): boolean {
return jobState === 2 || jobState === 3
}
@@ -0,0 +1,10 @@
import type { DistributeFormState } from '@/stores/distributeForm'
export function validateJobConfig(f: DistributeFormState): string | null {
const hasCopy = f.pathList.length > 0
const hasPrint = !!f.templateFile.trim()
if (!hasCopy && !hasPrint) return '请配置拷贝路径或打印模板'
if (hasPrint && !f.templateFile.trim()) return '请选择 .soon 模板'
if (hasCopy && f.pathList.some((p) => !p.path.trim())) return '路径不能为空'
return null
}
@@ -0,0 +1,140 @@
<template>
<AppShell>
<AppHeader mode="数据导入模式">
<div class="c-nav-group">
<NavButton icon="home" label="首页" @click="router.push('/home')" />
<NavButton icon="trash" label="清空" @click="collectStore.reset()" />
<NavButton
icon="check-circle"
label="提交"
variant="primary"
:active="true"
@click="onSubmit"
/>
</div>
</AppHeader>
<main class="app-shell__main l-mobile-landscape">
<section class="m-config-panel">
<h3 class="m-panel-title">
<AppIcon name="folder-open" size="sm" />
数据导入地址
</h3>
<div class="m-path-box">
<span class="m-path-text">{{ collectStore.destPath }}</span>
</div>
<button type="button" class="m-path-btn" :disabled="!canUse" @click="addPath">
<AppIcon name="plus" size="sm" />
添加路径
</button>
<p v-if="usbProgress >= 0" class="usb-progress-hint">USB 进度: {{ usbProgress }}%</p>
</section>
<div class="m-divider-v" />
<section class="m-config-panel">
<h3 class="m-panel-title">
<AppIcon name="exchange" size="sm" />
出卡方向
</h3>
<div class="m-radio-group">
<label class="m-radio-item">
<input v-model="collectStore.cardOutput" type="radio" :value="1" />
<span class="radio-custom" />
<span>向前出卡</span>
</label>
<label class="m-radio-item">
<input v-model="collectStore.cardOutput" type="radio" :value="2" />
<span class="radio-custom" />
<span>向后出卡</span>
</label>
</div>
</section>
</main>
<AppFooter />
</AppShell>
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
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 { useCollectStore } from '@/stores/collect'
import { useAppStore } from '@/stores/app'
import {
dialogOpenDirectory,
dllUsbCopy,
onUsbPollTick,
pollUsbStart,
pollUsbStop
} from '@/api/cardsoon'
import type { UsbPollPayload } from '@/types/ipc'
const router = useRouter()
const collectStore = useCollectStore()
const appStore = useAppStore()
const canUse = computed(() => appStore.initialized)
const usbProgress = ref(-1)
let unsub: (() => void) | null = null
async function addPath(): Promise<void> {
const r = await dialogOpenDirectory()
if (r.ok && r.data?.paths[0]) collectStore.destPath = r.data.paths[0]
}
async function onSubmit(): Promise<void> {
if (!canUse.value) {
ElMessage.warning('系统未初始化')
return
}
if (appStore.mode === 'distributing') {
ElMessage.warning('请先停止数据分发任务')
return
}
const r = await dllUsbCopy(collectStore.destPath, collectStore.cardOutput)
if (!r.ok) {
ElMessage.error(r.message || '可能已有任务在执行')
return
}
appStore.setMode('usbCopying')
await pollUsbStart()
unsub = onUsbPollTick((p) => {
const payload = p as UsbPollPayload
if (payload.taskStatus === 1) usbProgress.value = payload.progress
if (payload.success) {
ElMessage.success('USB 收集完成')
cleanup()
router.push('/home')
}
if (payload.failed) {
ElMessage.error('USB 收集失败')
cleanup()
}
})
}
function cleanup(): void {
unsub?.()
unsub = null
pollUsbStop()
appStore.setMode('ready')
usbProgress.value = -1
}
onUnmounted(() => {
cleanup()
})
</script>
<style src="@/styles/pages/page1.css"></style>
<style scoped>
.usb-progress-hint {
font-size: 10px;
color: var(--cs-primary);
font-weight: 700;
margin: 0;
}
</style>
@@ -0,0 +1,280 @@
<template>
<AppShell>
<AppHeader mode="数据分发模式">
<div class="c-nav-group">
<NavButton icon="home" label="首页" @click="router.push('/home')" />
<NavButton icon="trash" label="清空" @click="formStore.reset()" />
<NavButton
icon="check-circle"
label="提交"
variant="primary"
:active="true"
@click="onSubmit"
/>
</div>
</AppHeader>
<main class="app-shell__main l-main-flex">
<section class="c-panel m-panel--left">
<div class="c-panel__header">
<span class="c-panel__title">路径配置</span>
<div class="c-nav-group">
<button type="button" class="c-button-cs" :disabled="!canUse" @click="addPath">
添加路径
</button>
<button type="button" class="c-button-cs" @click="settingsOpen = true">设置</button>
</div>
</div>
<div class="m-path-hint">
<AppIcon name="info-circle" size="sm" />
<span>系统将拷贝该目录下的所有子项但不包含文件夹本身</span>
</div>
<div class="m-panel-toolbar">
<div class="toolbar-row">
<div class="toolbar-item">
<span>卷标</span>
<input v-model="formStore.volumeLabel" type="text" class="c-input" />
</div>
<div class="toolbar-item">
<span>拷贝类型</span>
<select v-model="formStore.copyType" class="c-select">
<option :value="0">文件拷贝</option>
<option :value="1">镜像刻录</option>
</select>
</div>
</div>
<div class="toolbar-row">
<div class="toolbar-item">
<span>格式化类型</span>
<select v-model="formStore.formatType" class="c-select">
<option value="none">不格式化</option>
<option value="fat">快速格式化</option>
<option value="ntfs">完全格式化</option>
</select>
</div>
<label class="c-checkbox-item">
<input v-model="formStore.dongleEnabled" type="checkbox" />
<span>加密狗</span>
<input
v-model.number="formStore.dongleMode"
type="number"
class="c-input dog-count"
:disabled="!formStore.dongleEnabled"
/>
<span class="dog-hint">{{ dongleHint }}</span>
</label>
</div>
</div>
<div class="c-panel__body">
<div v-for="(item, idx) in formStore.pathList" :key="idx" class="c-path-item">
<div class="c-path-item__info">
<div class="c-path-item__name">{{ item.path }}</div>
<div class="c-path-item__meta">{{ item.meta }}</div>
</div>
<button type="button" class="c-path-item__delete" @click="removePath(idx)">
<AppIcon name="times" size="sm" />
</button>
</div>
</div>
<div class="c-panel__footer" style="padding: 4px 0">
<div class="c-progress">
<div class="c-progress-fill" :style="{ width: loadPercent + '%' }" />
<div class="c-progress-text">{{ loadProgressText }}</div>
</div>
</div>
</section>
<section class="c-panel m-panel--right">
<div class="c-panel__header">
<span class="c-panel__title">标签预览</span>
<div class="c-nav-group">
<button type="button" class="c-button-cs" :disabled="!canUse" @click="pickTemplate">
添加标签
</button>
</div>
</div>
<div class="c-preview-area">
<div class="c-card-small">
<div class="c-card-small__row">
<span class="c-card-small__label">检查项: 胸部平扫 & 下腹部平扫 CT</span>
</div>
<div class="c-card-small__row">
<span class="c-card-small__label">病人: 张三丰</span>
</div>
<div class="c-card-small__row">
<span class="c-card-small__label">编号: 38894545</span>
</div>
<div class="c-card-small__row c-card-small__row--barcode">
<span class="c-card-small__label">条形码:</span>
<div class="c-barcode-mock" />
</div>
</div>
<div class="c-card-small c-card-small--back">
<span>BACKSIDE PREVIEW</span>
</div>
</div>
<div class="c-panel__body">
<div class="m-data-section">
<table class="c-data-table-mini">
<tr>
<td>IMAGE [正面]</td>
<td class="c-path-cell">
{{ previewImagePath }}
<span class="c-path-update" @click="pickTemplate">..</span>
</td>
</tr>
<tr>
<td>CHECK_DATE [背面]</td>
<td>2023.03.34</td>
</tr>
<tr>
<td>STUDY_ID [背面]</td>
<td>ZJ8341C40234</td>
</tr>
<tr>
<td>PATIENT_NAME [背面]</td>
<td>张三丰</td>
</tr>
</table>
</div>
<div class="m-dynamic-fields" />
</div>
</section>
</main>
<DistributeSettingsModal v-model="settingsOpen" />
<AppFooter />
</AppShell>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
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 DistributeSettingsModal from '@/components/DistributeSettingsModal.vue'
import { useDistributeFormStore } from '@/stores/distributeForm'
import { useJobStore } from '@/stores/job'
import { useAppStore } from '@/stores/app'
import { validateJobConfig } from '@/utils/validateJobConfig'
import { buildJobConfig } from '@/utils/buildJobConfig'
import { dialogOpenDirectory, dialogOpenSoon, dllJobCreate, fsPathExists } from '@/api/cardsoon'
const router = useRouter()
const formStore = useDistributeFormStore()
const jobStore = useJobStore()
const appStore = useAppStore()
const settingsOpen = ref(false)
const canUse = computed(() => appStore.initialized)
const loadPercent = computed(() => {
if (!formStore.pathList.length) return 0
return Math.min(85, 40 + formStore.pathList.length * 22)
})
const loadProgressText = computed(() => {
if (!formStore.pathList.length) return '已加载: 0 GB / 2 GB (0%)'
return `已加载: 1.7 GB / 2 GB (${loadPercent.value}%)`
})
const previewImagePath = computed(
() => formStore.templateFile || 'D:\\images\\template.jpg'
)
const dongleHint = computed(() => {
if (!formStore.dongleEnabled) return '(未启用)'
if (formStore.dongleMode === -1) return '-1 无加密狗)'
if (formStore.dongleMode === 0) return '0 一次性)'
if (formStore.dongleMode === 255) return '255 无限次)'
return '0=一次 255=无限)'
})
async function addPath(): Promise<void> {
const r = await dialogOpenDirectory()
if (!r.ok || !r.data?.paths.length) return
r.data.paths.forEach((p) => {
formStore.pathList.push({ path: `${p}\\*.*`, meta: '待拷贝' })
})
}
function removePath(idx: number): void {
formStore.pathList.splice(idx, 1)
}
async function pickTemplate(): Promise<void> {
const r = await dialogOpenSoon()
if (r.ok && r.data?.path) {
formStore.templateFile = r.data.path
ElMessage.success('已选择模板')
}
}
async function onSubmit(): Promise<void> {
if (!canUse.value) {
ElMessage.warning('系统未初始化')
return
}
if (jobStore.submitting) return
const err = validateJobConfig(formStore)
if (err) {
ElMessage.warning(err)
return
}
const paths = formStore.pathList.map((x) => x.path)
if (paths.length) {
const ex = await fsPathExists(paths)
if (ex.ok && ex.data?.missing.length) {
ElMessage.error(`路径不存在: ${ex.data.missing.join(', ')}`)
return
}
}
jobStore.submitting = true
try {
const json = JSON.stringify(buildJobConfig(formStore))
const created = await dllJobCreate(json)
if (!created.ok || !created.data?.jobId) {
ElMessage.error(created.message || '创建任务失败')
return
}
jobStore.setActiveJob(created.data.jobId)
appStore.setMode('distributing')
await router.push('/distribute/running')
} finally {
jobStore.submitting = false
}
}
</script>
<style src="@/styles/pages/page4.css"></style>
<style scoped>
.c-card-small__row--barcode {
display: flex;
align-items: center;
gap: 5px;
}
.c-barcode-mock {
flex: 1;
height: 12px;
background: repeating-linear-gradient(
90deg,
#000,
#000 1px,
transparent 1px,
transparent 3px
);
}
.c-card-small--back {
background: #f8f9fa;
display: flex;
align-items: center;
justify-content: center;
color: #ccc;
font-size: 10px;
font-weight: 800;
}
.c-path-update {
cursor: pointer;
}
</style>
@@ -0,0 +1,84 @@
<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.mockJobStarted = false
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>
@@ -0,0 +1,135 @@
<template>
<AppShell>
<AppHeader mode="数据分发模式">
<NavButton icon="stop" label="停止" variant="stop" @click="onStop" />
</AppHeader>
<main class="app-shell__main l-main-full">
<section class="l-hero-container">
<div class="m-left-panel">
<div class="c-status-panel">
<h2 class="c-status-title is-looping">{{ statusTitle }}</h2>
<p class="c-status-sub">{{ statusSub }}</p>
<p class="c-status-counter">
任务已经完成<span class="ok">{{ jobStore.successCount }}</span
>其中失败次数是<span class="err">{{ jobStore.failCount }}</span>
</p>
</div>
<WorkflowSteps :active-step="ui.workflowStep" mode="running" />
</div>
<div class="m-right-panel">
<div class="m-progress-circle">
<svg viewBox="0 0 100 100">
<circle class="bg" cx="50" cy="50" r="45" />
<circle
class="fill"
cx="50"
cy="50"
r="45"
:style="{ strokeDashoffset: strokeOffset }"
/>
</svg>
<div class="m-progress-value">{{ displayProgress }}%</div>
</div>
</div>
</section>
</main>
<AppFooter />
</AppShell>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
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 WorkflowSteps from '@/components/WorkflowSteps.vue'
import { useJobStore } from '@/stores/job'
import { useAppStore } from '@/stores/app'
import {
dllJobCancel,
onJobPollTick,
pollJobStart,
pollJobStop
} from '@/api/cardsoon'
import { mapJobStateToUi, shouldUseProgress } from '@/utils/job-state'
import type { JobPollPayload } from '@/types/ipc'
const router = useRouter()
const jobStore = useJobStore()
const appStore = useAppStore()
const CIRCLE_LEN = 283
const progress = ref(0)
const strokeOffset = ref(CIRCLE_LEN)
const ui = ref(mapJobStateToUi(0))
let unsub: (() => void) | null = null
const displayProgress = computed(() => progress.value)
const statusTitle = computed(() => (ui.value.hint === 'waitCard' ? '等待插卡' : '循环执行中'))
const statusSub = computed(() => '请插入数据卡,任务将自动连续执行')
function applyProgress(p: JobPollPayload): void {
ui.value = mapJobStateToUi(p.jobState)
if (shouldUseProgress(p.jobState)) {
progress.value = Math.min(100, Math.max(0, p.progress))
strokeOffset.value = CIRCLE_LEN - (CIRCLE_LEN * progress.value) / 100
}
if (p.queryErrorCode !== 0) {
ElMessage.error(`查询任务失败: ${p.queryErrorCode}`)
pollJobStop()
return
}
if (p.failed) {
jobStore.failCount += 1
pollJobStop()
appStore.setMode('ready')
router.push('/distribute/failed')
return
}
if (p.cancelled) {
pollJobStop()
appStore.setMode('ready')
router.push('/distribute/config')
return
}
if (p.finished) {
jobStore.successCount += 1
}
}
onMounted(async () => {
if (!jobStore.jobId) {
router.replace('/distribute/config')
return
}
appStore.setMode('distributing')
await pollJobStart(jobStore.jobId)
unsub = onJobPollTick((payload) => applyProgress(payload as JobPollPayload))
})
onUnmounted(() => {
unsub?.()
pollJobStop()
if (appStore.mode === 'distributing') appStore.setMode('ready')
})
async function onStop(): Promise<void> {
await dllJobCancel(jobStore.jobId)
await pollJobStop()
jobStore.mockJobStarted = false
appStore.setMode('ready')
router.push('/distribute/config')
}
</script>
<style src="@/styles/pages/page2.css"></style>
<style scoped>
.m-progress-circle .fill {
stroke-dasharray: 283;
transition: stroke-dashoffset 0.5s ease;
}
</style>
+108
View File
@@ -0,0 +1,108 @@
<template>
<AppShell>
<AppHeader mode="卡树数据卡打印机软件" />
<main class="app-shell__main l-dashboard">
<section class="m-tool-section">
<h3 class="m-section-title">工具</h3>
<div class="m-tool-grid">
<button type="button" class="m-tool-btn" @click="onReset">
<AppIcon name="redo" />
<span>重置打印机</span>
</button>
<button type="button" class="m-tool-btn" @click="onReject">
<AppIcon name="trash" />
<span>废弃卡片</span>
</button>
<button type="button" class="m-tool-btn" @click="onTemplate">
<AppIcon name="paint-brush" />
<span>模板设计</span>
</button>
</div>
</section>
<div class="m-divider" />
<section class="m-task-section">
<h3 class="m-section-title">任务</h3>
<div class="m-task-grid">
<button type="button" class="m-task-card" @click="goDistribute">
<div class="m-task-icon">
<AppIcon name="share" />
</div>
<div class="m-task-info">
<h4>数据分发</h4>
<p>分发数据到打印卡片</p>
</div>
</button>
<button type="button" class="m-task-card" @click="goCollect">
<div class="m-task-icon">
<AppIcon name="download" />
</div>
<div class="m-task-info">
<h4>数据收集</h4>
<p>从卡片收集导入数据</p>
</div>
</button>
</div>
</section>
</main>
<AppFooter />
</AppShell>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import AppShell from '@/layouts/AppShell.vue'
import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
import AppIcon from '@/components/AppIcon.vue'
import { useAppStore } from '@/stores/app'
import { useConfigStore } from '@/stores/config'
import { dllPrinterReject, dllPrinterReset, shellOpenTemplateDir } from '@/api/cardsoon'
const router = useRouter()
const appStore = useAppStore()
const configStore = useConfigStore()
const canUse = computed(() => appStore.initialized)
function guardInit(): boolean {
if (canUse.value) return true
ElMessage.warning('系统未初始化')
return false
}
async function onReset(): Promise<void> {
if (!guardInit()) return
const r = await dllPrinterReset()
ElMessage[r.ok ? 'success' : 'error'](r.ok ? '已发送重置指令' : r.message || '重置失败')
}
async function onReject(): Promise<void> {
if (!guardInit()) return
if (!configStore.rejectApiAvailable) {
ElMessage.warning('当前环境不支持废卡接口')
return
}
const r = await dllPrinterReject()
ElMessage[r.ok ? 'success' : 'error'](r.ok ? '已废弃卡片' : r.message || '操作失败')
}
async function onTemplate(): Promise<void> {
if (!guardInit()) return
await shellOpenTemplateDir()
}
function goDistribute(): void {
router.push('/distribute/config')
}
function goCollect(): void {
if (appStore.mode === 'distributing') {
ElMessage.warning('请先停止数据分发任务')
return
}
router.push('/collect')
}
</script>
<style src="@/styles/pages/page2.css"></style>
+10
View File
@@ -0,0 +1,10 @@
/** 设计稿逻辑画布 720×360 */
export const DESIGN_WIDTH = 720
export const DESIGN_HEIGHT = 360
/** 内容区高度(720 宽时 390):比画布多约 30px,避免按宽缩放后底部被裁 */
export const CONTENT_VIEWPORT_HEIGHT = 390
export function contentHeightForWidth(contentWidth: number): number {
return Math.round((contentWidth * CONTENT_VIEWPORT_HEIGHT) / DESIGN_WIDTH)
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": {
"@shared/*": ["src/shared/*"]
},
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": [
"electron.vite.config.ts",
"src/main/**/*",
"src/preload/**/*",
"src/shared/**/*"
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/renderer/src/*"],
"@shared/*": ["src/shared/*"]
},
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"strict": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true
},
"include": [
"src/renderer/src/**/*.ts",
"src/renderer/src/**/*.vue",
"src/shared/**/*.ts"
]
}
File diff suppressed because one or more lines are too long