diff --git a/.gitignore b/.gitignore index f201b55..30be35f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,15 @@ migration_manual_overrides.json 05-settings-page.png nul { +.dotnet8/ +print-agent-dotnet/**/bin/ +print-agent-dotnet/**/obj/ +print-agent-dotnet/**/*.user +print-agent-dotnet/dist/ +downloads/**/*.exe +print-terminals-live*.png +temp_agent_team_test/ +temp_db_purchase_dates.tsv +codex_release_files.txt +dingtalk-requests.txt +行政手动处理清单*.xlsx diff --git a/backend/package.json b/backend/package.json index 9795466..915918b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,6 +7,7 @@ "dev": "nodemon --exec ts-node src/index.ts", "build": "tsc", "start": "node dist/index.js", + "test:print": "node --require ts-node/register --test src/services/__tests__/printService.test.ts", "seed:demo": "node scripts/seedDemoData.js", "repair:mojibake": "node scripts/repairMojibake.js", "prisma:generate": "prisma generate", diff --git a/backend/prisma/migrations/20260325_remote_printing_v1/migration.sql b/backend/prisma/migrations/20260325_remote_printing_v1/migration.sql new file mode 100644 index 0000000..e8cf220 --- /dev/null +++ b/backend/prisma/migrations/20260325_remote_printing_v1/migration.sql @@ -0,0 +1,83 @@ +CREATE TABLE `print_terminal` ( + `id` INT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL, + `platform` VARCHAR(30) NOT NULL, + `machine_name` VARCHAR(100) NOT NULL, + `status` VARCHAR(20) NOT NULL DEFAULT 'offline', + `is_default` BOOLEAN NOT NULL DEFAULT false, + `last_seen_at` DATETIME(3) NULL, + `selected_printer_name` VARCHAR(150) NULL, + `printer_snapshot_json` TEXT NULL, + `label_profile_json` TEXT NULL, + `terminal_token_hash` VARCHAR(128) NOT NULL, + `paired_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `current_error_message` VARCHAR(255) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `print_terminal_terminal_token_hash_key`(`terminal_token_hash`), + INDEX `print_terminal_status_idx`(`status`), + INDEX `print_terminal_is_default_idx`(`is_default`), + INDEX `print_terminal_last_seen_at_idx`(`last_seen_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `print_terminal_pair_code` ( + `id` INT NOT NULL AUTO_INCREMENT, + `code` VARCHAR(32) NOT NULL, + `expires_at` DATETIME(3) NOT NULL, + `created_by` INT NOT NULL, + `used_at` DATETIME(3) NULL, + `used_by_terminal_id` INT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `print_terminal_pair_code_code_key`(`code`), + INDEX `print_terminal_pair_code_expires_at_idx`(`expires_at`), + INDEX `print_terminal_pair_code_created_by_idx`(`created_by`), + INDEX `print_terminal_pair_code_used_by_terminal_id_idx`(`used_by_terminal_id`), + PRIMARY KEY (`id`), + CONSTRAINT `print_terminal_pair_code_created_by_fkey` + FOREIGN KEY (`created_by`) REFERENCES `user`(`id`) + ON DELETE RESTRICT + ON UPDATE CASCADE, + CONSTRAINT `print_terminal_pair_code_used_by_terminal_id_fkey` + FOREIGN KEY (`used_by_terminal_id`) REFERENCES `print_terminal`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `print_job` ( + `id` INT NOT NULL AUTO_INCREMENT, + `job_type` VARCHAR(30) NOT NULL DEFAULT 'asset_label', + `status` VARCHAR(20) NOT NULL DEFAULT 'queued', + `target_terminal_id` INT NOT NULL, + `claimed_by_terminal_id` INT NULL, + `requested_by` INT NOT NULL, + `payload_json` TEXT NOT NULL, + `error_message` TEXT NULL, + `claimed_at` DATETIME(3) NULL, + `started_at` DATETIME(3) NULL, + `completed_at` DATETIME(3) NULL, + `attempt_count` INT NOT NULL DEFAULT 0, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `print_job_status_created_at_idx`(`status`, `created_at`), + INDEX `print_job_target_terminal_id_status_created_at_idx`(`target_terminal_id`, `status`, `created_at`), + INDEX `print_job_requested_by_idx`(`requested_by`), + INDEX `print_job_claimed_by_terminal_id_idx`(`claimed_by_terminal_id`), + PRIMARY KEY (`id`), + CONSTRAINT `print_job_target_terminal_id_fkey` + FOREIGN KEY (`target_terminal_id`) REFERENCES `print_terminal`(`id`) + ON DELETE RESTRICT + ON UPDATE CASCADE, + CONSTRAINT `print_job_claimed_by_terminal_id_fkey` + FOREIGN KEY (`claimed_by_terminal_id`) REFERENCES `print_terminal`(`id`) + ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT `print_job_requested_by_fkey` + FOREIGN KEY (`requested_by`) REFERENCES `user`(`id`) + ON DELETE RESTRICT + ON UPDATE CASCADE +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 3a908e9..1148e59 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -168,12 +168,87 @@ model User { processed_sync_changes SyncChange[] @relation("SyncChangeProcessedBy") inventory_tasks_created InventoryTask[] @relation("InventoryTaskCreatedBy") inventory_actions InventoryTaskAction[] @relation("InventoryTaskActionOperator") + print_terminal_pair_codes PrintTerminalPairCode[] @relation("PrintTerminalPairCodeCreator") + requested_print_jobs PrintJob[] @relation("PrintJobRequester") @@index([role]) @@index([status]) @@map("user") } +model PrintTerminal { + id Int @id @default(autoincrement()) + name String @db.VarChar(100) + platform String @db.VarChar(30) + machine_name String @db.VarChar(100) + status String @default("offline") @db.VarChar(20) + is_default Boolean @default(false) + last_seen_at DateTime? + selected_printer_name String? @db.VarChar(150) + printer_snapshot_json String? @db.Text + label_profile_json String? @db.Text + terminal_token_hash String @unique @db.VarChar(128) + paired_at DateTime @default(now()) + current_error_message String? @db.VarChar(255) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + used_pair_codes PrintTerminalPairCode[] @relation("PrintTerminalPairCodeUsedBy") + target_jobs PrintJob[] @relation("PrintJobTargetTerminal") + claimed_jobs PrintJob[] @relation("PrintJobClaimedByTerminal") + + @@index([status]) + @@index([is_default]) + @@index([last_seen_at]) + @@map("print_terminal") +} + +model PrintTerminalPairCode { + id Int @id @default(autoincrement()) + code String @unique @db.VarChar(32) + expires_at DateTime + created_by Int + used_at DateTime? + used_by_terminal_id Int? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + creator User @relation("PrintTerminalPairCodeCreator", fields: [created_by], references: [id]) + usedByTerminal PrintTerminal? @relation("PrintTerminalPairCodeUsedBy", fields: [used_by_terminal_id], references: [id]) + + @@index([expires_at]) + @@index([created_by]) + @@index([used_by_terminal_id]) + @@map("print_terminal_pair_code") +} + +model PrintJob { + id Int @id @default(autoincrement()) + job_type String @default("asset_label") @db.VarChar(30) + status String @default("queued") @db.VarChar(20) + target_terminal_id Int + claimed_by_terminal_id Int? + requested_by Int + payload_json String @db.Text + error_message String? @db.Text + claimed_at DateTime? + started_at DateTime? + completed_at DateTime? + attempt_count Int @default(0) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + targetTerminal PrintTerminal @relation("PrintJobTargetTerminal", fields: [target_terminal_id], references: [id]) + claimedByTerminal PrintTerminal? @relation("PrintJobClaimedByTerminal", fields: [claimed_by_terminal_id], references: [id]) + requester User @relation("PrintJobRequester", fields: [requested_by], references: [id]) + + @@index([status, created_at]) + @@index([target_terminal_id, status, created_at]) + @@index([requested_by]) + @@index([claimed_by_terminal_id]) + @@map("print_job") +} + model SiteConfig { id Int @id @default(autoincrement()) site_name String @db.VarChar(100) diff --git a/backend/src/controllers/printController.ts b/backend/src/controllers/printController.ts new file mode 100644 index 0000000..82f99b0 --- /dev/null +++ b/backend/src/controllers/printController.ts @@ -0,0 +1,249 @@ +import type { Request, Response } from 'express' +import { + cancelQueuedPrintJob, + claimNextPrintJob, + createAssetLabelPrintJob as createAssetLabelPrintJobService, + createPrintTerminalPairCode as createPrintTerminalPairCodeService, + heartbeatPrintTerminalAgent, + isPrintServiceError, + listPrintJobs, + listPrintTerminals, + pairPrintTerminalAgent, + reportPrintAgentTestPrint, + setDefaultPrintTerminal as setDefaultPrintTerminalService, + updateClaimedPrintJobStatus, + updatePrintTerminalAdminStatus +} from '../services/printService' +import { errorResponse, successResponse } from '../utils/response' + +const handlePrintServiceError = ( + res: Response, + error: unknown, + fallbackMessage: string +) => { + if (isPrintServiceError(error)) { + return errorResponse(res, error.message, error.statusCode) + } + + console.error('[printController]', error) + const message = + error instanceof Error && error.message ? error.message : fallbackMessage + return errorResponse(res, message) +} + +const parsePositiveInt = (value: unknown) => { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : null +} + +const getCurrentUserId = (res: Response) => { + return parsePositiveInt(res.locals.user?.id) +} + +const getCurrentTerminalId = (res: Response) => { + return parsePositiveInt(res.locals.printTerminal?.id) +} + +export const getPrintTerminals = async (req: Request, res: Response) => { + try { + const terminals = await listPrintTerminals() + successResponse(res, { list: terminals }, '获取打印终端成功') + } catch (error) { + handlePrintServiceError(res, error, '获取打印终端失败') + } +} + +export const createPrintTerminalPairCode = async (req: Request, res: Response) => { + const userId = getCurrentUserId(res) + if (!userId) { + return errorResponse(res, '当前登录用户无效', 401) + } + + try { + const pairCode = await createPrintTerminalPairCodeService(userId) + successResponse(res, pairCode, '已生成一次性配对码') + } catch (error) { + handlePrintServiceError(res, error, '生成配对码失败') + } +} + +export const setDefaultPrintTerminal = async (req: Request, res: Response) => { + const terminalId = parsePositiveInt(req.params.id) + if (!terminalId) { + return errorResponse(res, '打印终端编号无效', 422) + } + + try { + const terminal = await setDefaultPrintTerminalService(terminalId) + successResponse(res, terminal, '默认打印电脑已更新') + } catch (error) { + handlePrintServiceError(res, error, '设置默认打印电脑失败') + } +} + +export const updatePrintTerminalStatus = async (req: Request, res: Response) => { + const terminalId = parsePositiveInt(req.params.id) + const nextStatus = typeof req.body?.status === 'string' ? req.body.status.trim() : '' + + if (!terminalId) { + return errorResponse(res, '打印终端编号无效', 422) + } + + if (nextStatus !== 'offline' && nextStatus !== 'disabled') { + return errorResponse(res, '仅支持启用为离线或停用终端', 422) + } + + try { + const terminal = await updatePrintTerminalAdminStatus(terminalId, nextStatus) + successResponse(res, terminal, '终端状态已更新') + } catch (error) { + handlePrintServiceError(res, error, '更新终端状态失败') + } +} + +export const createAssetLabelPrintJob = async (req: Request, res: Response) => { + const userId = getCurrentUserId(res) + if (!userId) { + return errorResponse(res, '当前登录用户无效', 401) + } + + try { + const result = await createAssetLabelPrintJobService(req.body?.equipment_ids, userId) + successResponse(res, result.job, result.queue_notice) + } catch (error) { + handlePrintServiceError(res, error, '创建打印任务失败') + } +} + +export const getPrintJobs = async (req: Request, res: Response) => { + try { + const page = parsePositiveInt(req.query.page) || 1 + const pageSize = parsePositiveInt(req.query.pageSize) || 20 + const status = typeof req.query.status === 'string' ? req.query.status : null + const result = await listPrintJobs(page, pageSize, status) + + successResponse( + res, + { + list: result.list, + total: result.pagination.total, + page: result.pagination.page, + pageSize: result.pagination.pageSize + }, + '获取打印任务成功' + ) + } catch (error) { + handlePrintServiceError(res, error, '获取打印任务失败') + } +} + +export const cancelPrintJob = async (req: Request, res: Response) => { + const jobId = parsePositiveInt(req.params.id) + if (!jobId) { + return errorResponse(res, '打印任务编号无效', 422) + } + + try { + await cancelQueuedPrintJob(jobId) + successResponse(res, null, '打印任务已取消') + } catch (error) { + handlePrintServiceError(res, error, '取消打印任务失败') + } +} + +export const pairPrintAgent = async (req: Request, res: Response) => { + try { + const result = await pairPrintTerminalAgent({ + code: req.body?.code, + name: req.body?.terminal_name || req.body?.name, + platform: req.body?.platform, + machine_name: req.body?.machine_name + }) + + successResponse(res, result, '打印终端配对成功') + } catch (error) { + handlePrintServiceError(res, error, '打印终端配对失败') + } +} + +export const heartbeatPrintAgent = async (req: Request, res: Response) => { + const terminalId = getCurrentTerminalId(res) + if (!terminalId) { + return errorResponse(res, '打印终端未认证', 401) + } + + try { + const result = await heartbeatPrintTerminalAgent(terminalId, { + name: req.body?.terminal_name || req.body?.name, + platform: req.body?.platform, + machine_name: req.body?.machine_name, + selected_printer_name: req.body?.selected_printer_name, + printer_snapshot: req.body?.printer_snapshot, + label_profile: req.body?.label_profile + }) + + successResponse(res, result, '心跳已接收') + } catch (error) { + handlePrintServiceError(res, error, '心跳上报失败') + } +} + +export const claimPrintAgentJob = async (req: Request, res: Response) => { + const terminalId = getCurrentTerminalId(res) + if (!terminalId) { + return errorResponse(res, '打印终端未认证', 401) + } + + try { + const result = await claimNextPrintJob(terminalId) + successResponse(res, result, result.message) + } catch (error) { + handlePrintServiceError(res, error, '领取打印任务失败') + } +} + +export const updatePrintAgentJobStatus = async (req: Request, res: Response) => { + const terminalId = getCurrentTerminalId(res) + const jobId = parsePositiveInt(req.params.id) + + if (!terminalId) { + return errorResponse(res, '打印终端未认证', 401) + } + + if (!jobId) { + return errorResponse(res, '打印任务编号无效', 422) + } + + try { + const result = await updateClaimedPrintJobStatus(terminalId, jobId, { + status: req.body?.status, + error_message: req.body?.error_message, + retryable: req.body?.retryable + }) + + successResponse(res, result.job, result.message) + } catch (error) { + handlePrintServiceError(res, error, '更新打印任务状态失败') + } +} + +export const printAgentTestPrint = async (req: Request, res: Response) => { + const terminalId = getCurrentTerminalId(res) + if (!terminalId) { + return errorResponse(res, '打印终端未认证', 401) + } + + try { + const result = await reportPrintAgentTestPrint(terminalId, { + selected_printer_name: req.body?.selected_printer_name, + printer_snapshot: req.body?.printer_snapshot, + label_profile: req.body?.label_profile, + success: req.body?.success, + error_message: req.body?.error_message + }) + + successResponse(res, result, result.message) + } catch (error) { + handlePrintServiceError(res, error, '测试打印状态上报失败') + } +} diff --git a/backend/src/controllers/printDownloadController.ts b/backend/src/controllers/printDownloadController.ts new file mode 100644 index 0000000..a7beddb --- /dev/null +++ b/backend/src/controllers/printDownloadController.ts @@ -0,0 +1,125 @@ +import fs from 'fs' +import path from 'path' +import type { Request, Response } from 'express' +import { errorResponse, successResponse } from '../utils/response' +import { + getPrintAgentLatestManifest, + getXPrinterOfficialLatestManifest, + listPrintAgentReleaseManifests, + listXPrinterArchiveManifests, + resolvePrintAgentLatestBinary, + resolvePrintAgentReleaseBinary, + resolveXPrinterArchiveBinary +} from '../services/printDownloadService' + +const getBaseUrl = (req: Request) => { + const protocol = (req.get('x-forwarded-proto') || req.protocol || 'http').split(',')[0] + const host = req.get('x-forwarded-host') || req.get('host') + return `${protocol}://${host}` +} + +const sendBinaryDownload = ( + res: Response, + filePath: string, + downloadName: string, + missingMessage: string +) => { + if (!fs.existsSync(filePath)) { + return errorResponse(res, missingMessage, 404) + } + + return res.download(path.resolve(filePath), downloadName) +} + +export const getPrintAgentLatestDownload = async (req: Request, res: Response) => { + try { + const { manifest, filePath } = resolvePrintAgentLatestBinary() + return sendBinaryDownload(res, filePath, manifest.filename, '最新打印助手安装包尚未上传') + } catch (error: any) { + return errorResponse(res, error.message || '读取打印助手安装包失败', 404) + } +} + +export const getPrintAgentReleaseList = async (req: Request, res: Response) => { + try { + const baseUrl = getBaseUrl(req) + const latestManifest = getPrintAgentLatestManifest() + const releases = listPrintAgentReleaseManifests().map((manifest) => ({ + ...manifest, + download_url: `${baseUrl}/downloads/print-agent/releases/${manifest.version}` + })) + + successResponse( + res, + { + latest_version: latestManifest.version, + latest_download_url: `${baseUrl}/downloads/print-agent/latest`, + releases + }, + '获取成功' + ) + } catch (error: any) { + errorResponse(res, error.message || '读取打印助手版本列表失败', 404) + } +} + +export const getPrintAgentReleaseDownload = async (req: Request, res: Response) => { + try { + const version = String(req.params.version || '').trim() + if (!version) { + return errorResponse(res, '版本号不能为空', 422) + } + + const { manifest, filePath } = resolvePrintAgentReleaseBinary(version) + return sendBinaryDownload( + res, + filePath, + manifest.filename, + `打印助手 ${version} 安装包尚未上传` + ) + } catch (error: any) { + return errorResponse(res, error.message || '读取打印助手安装包失败', 404) + } +} + +export const getXPrinterOfficialLatestController = async (req: Request, res: Response) => { + try { + const manifest = getXPrinterOfficialLatestManifest() + successResponse(res, manifest, '获取成功') + } catch (error: any) { + errorResponse(res, error.message || '读取芯烨官方驱动信息失败', 404) + } +} + +export const getXPrinterArchiveListController = async (req: Request, res: Response) => { + try { + const baseUrl = getBaseUrl(req) + const archives = listXPrinterArchiveManifests().map((manifest) => ({ + ...manifest, + download_url: `${baseUrl}/downloads/printer-driver/xprinter/archive/${manifest.version}` + })) + + successResponse(res, archives, '获取成功') + } catch (error: any) { + errorResponse(res, error.message || '读取芯烨归档驱动失败', 404) + } +} + +export const getXPrinterArchiveDownloadController = async (req: Request, res: Response) => { + try { + const version = String(req.params.version || '').trim() + if (!version) { + return errorResponse(res, '版本号不能为空', 422) + } + + const { manifest, filePath } = resolveXPrinterArchiveBinary(version) + return sendBinaryDownload( + res, + filePath, + manifest.filename, + `芯烨归档驱动 ${version} 尚未上传` + ) + } catch (error: any) { + return errorResponse(res, error.message || '读取芯烨归档驱动失败', 404) + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 80732bf..8a85668 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -27,6 +27,10 @@ import syncRoutes from './routes/sync' import notificationRoutes from './routes/notifications' import inventoryRoutes from './routes/inventory' import publicInventoryRoutes from './routes/publicInventory' +import printTerminalRoutes from './routes/printTerminals' +import printJobRoutes from './routes/printJobs' +import printAgentRoutes from './routes/printAgent' +import printDownloadRoutes from './routes/printDownloads' import { keycloak, memoryStore } from './utils/keycloak' import { protect, requireAdmin } from './middleware/auth' import { startHrSyncScheduler } from './services/hrSyncService' @@ -85,6 +89,10 @@ app.use('/api/sync', protect(), requireAdmin(), syncRoutes) app.use('/api/notifications', notificationRoutes) app.use('/api/inventory', inventoryRoutes) app.use('/api/public/inventory', publicInventoryRoutes) +app.use('/api/print-terminals', protect(), requireAdmin(), printTerminalRoutes) +app.use('/api/print-jobs', protect(), requireAdmin(), printJobRoutes) +app.use('/api/print-agent', printAgentRoutes) +app.use('/downloads', printDownloadRoutes) // Health check app.get('/api/health', (req, res) => { diff --git a/backend/src/middleware/printAgentAuth.ts b/backend/src/middleware/printAgentAuth.ts new file mode 100644 index 0000000..ec72b00 --- /dev/null +++ b/backend/src/middleware/printAgentAuth.ts @@ -0,0 +1,44 @@ +import type { RequestHandler } from 'express' +import { findPrintTerminalByToken, PRINT_TERMINAL_STATUS } from '../services/printService' + +export const requirePrintAgentAuth = (): RequestHandler => { + return async (req, res, next) => { + const authorization = req.get('authorization') || '' + const match = authorization.match(/^Bearer\s+(.+)$/i) + const token = match?.[1]?.trim() + + if (!token) { + return res.status(401).json({ + code: 401, + message: '缺少打印终端令牌', + data: null + }) + } + + try { + const terminal = await findPrintTerminalByToken(token) + if (!terminal) { + return res.status(401).json({ + code: 401, + message: '打印终端令牌无效', + data: null + }) + } + + if (terminal.status === PRINT_TERMINAL_STATUS.disabled) { + return res.status(403).json({ + code: 403, + message: '打印终端已停用', + data: null + }) + } + + res.locals.printTerminal = terminal + return next() + } catch (error) { + return next(error) + } + } +} + +export const protectPrintAgent = requirePrintAgentAuth diff --git a/backend/src/routes/printAgent.ts b/backend/src/routes/printAgent.ts new file mode 100644 index 0000000..d56ed08 --- /dev/null +++ b/backend/src/routes/printAgent.ts @@ -0,0 +1,22 @@ +import { Router } from 'express' +import { + claimPrintAgentJob, + heartbeatPrintAgent, + pairPrintAgent, + printAgentTestPrint, + updatePrintAgentJobStatus +} from '../controllers/printController' +import { protectPrintAgent } from '../middleware/printAgentAuth' + +const router = Router() + +router.post('/pair', pairPrintAgent) + +router.use(protectPrintAgent()) + +router.post('/heartbeat', heartbeatPrintAgent) +router.post('/jobs/claim', claimPrintAgentJob) +router.post('/jobs/:id/status', updatePrintAgentJobStatus) +router.post('/test-print', printAgentTestPrint) + +export default router diff --git a/backend/src/routes/printDownloads.ts b/backend/src/routes/printDownloads.ts new file mode 100644 index 0000000..f75caab --- /dev/null +++ b/backend/src/routes/printDownloads.ts @@ -0,0 +1,21 @@ +import { Router } from 'express' +import { + getPrintAgentLatestDownload, + getPrintAgentReleaseDownload, + getPrintAgentReleaseList, + getXPrinterArchiveDownloadController, + getXPrinterArchiveListController, + getXPrinterOfficialLatestController +} from '../controllers/printDownloadController' + +const router = Router() + +router.get('/print-agent/latest', getPrintAgentLatestDownload) +router.get('/print-agent/releases', getPrintAgentReleaseList) +router.get('/print-agent/releases/:version', getPrintAgentReleaseDownload) + +router.get('/printer-driver/xprinter/latest', getXPrinterOfficialLatestController) +router.get('/printer-driver/xprinter/archive', getXPrinterArchiveListController) +router.get('/printer-driver/xprinter/archive/:version', getXPrinterArchiveDownloadController) + +export default router diff --git a/backend/src/routes/printJobs.ts b/backend/src/routes/printJobs.ts new file mode 100644 index 0000000..5a85885 --- /dev/null +++ b/backend/src/routes/printJobs.ts @@ -0,0 +1,14 @@ +import { Router } from 'express' +import { + cancelPrintJob, + createAssetLabelPrintJob, + getPrintJobs +} from '../controllers/printController' + +const router = Router() + +router.post('/asset-labels', createAssetLabelPrintJob) +router.get('/', getPrintJobs) +router.patch('/:id/cancel', cancelPrintJob) + +export default router diff --git a/backend/src/routes/printTerminals.ts b/backend/src/routes/printTerminals.ts new file mode 100644 index 0000000..e2d619e --- /dev/null +++ b/backend/src/routes/printTerminals.ts @@ -0,0 +1,16 @@ +import { Router } from 'express' +import { + createPrintTerminalPairCode, + getPrintTerminals, + setDefaultPrintTerminal, + updatePrintTerminalStatus +} from '../controllers/printController' + +const router = Router() + +router.get('/', getPrintTerminals) +router.post('/pair-codes', createPrintTerminalPairCode) +router.patch('/:id/default', setDefaultPrintTerminal) +router.patch('/:id/status', updatePrintTerminalStatus) + +export default router diff --git a/backend/src/services/__tests__/printService.test.ts b/backend/src/services/__tests__/printService.test.ts new file mode 100644 index 0000000..4fa1964 --- /dev/null +++ b/backend/src/services/__tests__/printService.test.ts @@ -0,0 +1,144 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + resolveEffectiveTerminalStatus, + normalizeLabelProfile, + pickNextClaimablePrintJob, + resolveTerminalReadiness +} from '../printService' + +test('normalizeLabelProfile 应回填 60x40 与默认方向', () => { + const profile = normalizeLabelProfile(null) + + assert.deepEqual(profile, { + paper_size: '60x40mm', + width_mm: 60, + height_mm: 40, + orientation: 'landscape', + scale_mode: 'fit' + }) +}) + +test('resolveTerminalReadiness 在终端启用且打印机与标签配置齐全时返回 ready', () => { + const readiness = resolveTerminalReadiness({ + status: 'online', + last_seen_at: '2026-03-25T10:00:00.000Z', + selected_printer_name: 'XPrinter XP-420B', + label_profile: { + paper_size: '60x40mm', + width_mm: 60, + height_mm: 40, + orientation: 'landscape', + scale_mode: 'fit' + } + }) + + assert.deepEqual(readiness, { + is_ready: true, + reason: 'ready' + }) +}) + +test('resolveTerminalReadiness 在打印机缺失时返回 missing_printer', () => { + const readiness = resolveTerminalReadiness({ + status: 'online', + last_seen_at: '2026-03-25T10:00:00.000Z', + selected_printer_name: '', + label_profile: { + paper_size: '60x40mm', + width_mm: 60, + height_mm: 40, + orientation: 'landscape', + scale_mode: 'fit' + } + }) + + assert.deepEqual(readiness, { + is_ready: false, + reason: 'missing_printer' + }) +}) + +test('resolveTerminalReadiness 在标签配置未完成时返回 invalid_label_profile', () => { + const readiness = resolveTerminalReadiness({ + status: 'online', + last_seen_at: '2026-03-25T10:00:00.000Z', + selected_printer_name: 'XPrinter XP-420B', + label_profile: null + }) + + assert.deepEqual(readiness, { + is_ready: false, + reason: 'invalid_label_profile' + }) +}) + +test('pickNextClaimablePrintJob 应只选当前终端最早的 queued 任务', () => { + const job = pickNextClaimablePrintJob( + [ + { + id: 4, + status: 'queued', + target_terminal_id: 2, + created_at: '2026-03-25T08:00:00.000Z' + }, + { + id: 2, + status: 'claimed', + target_terminal_id: 1, + created_at: '2026-03-25T07:00:00.000Z' + }, + { + id: 3, + status: 'queued', + target_terminal_id: 1, + created_at: '2026-03-25T06:30:00.000Z' + }, + { + id: 1, + status: 'queued', + target_terminal_id: 1, + created_at: '2026-03-25T06:00:00.000Z' + } + ], + 1 + ) + + assert.equal(job?.id, 1) +}) + +test('resolveEffectiveTerminalStatus 在心跳超时后应返回 offline', () => { + const status = resolveEffectiveTerminalStatus( + { + status: 'online', + last_seen_at: '2026-03-25T10:00:00.000Z' + }, + new Date('2026-03-25T10:02:31.000Z') + ) + + assert.equal(status, 'offline') +}) + +test('resolveEffectiveTerminalStatus 对 disabled 终端保持 disabled', () => { + const status = resolveEffectiveTerminalStatus( + { + status: 'disabled', + last_seen_at: '2026-03-25T10:00:00.000Z' + }, + new Date('2026-03-25T10:00:30.000Z') + ) + + assert.equal(status, 'disabled') +}) + +test('resolveEffectiveTerminalStatus 对手动设为 offline 的终端保持 offline', () => { + const status = resolveEffectiveTerminalStatus( + { + status: 'offline', + last_seen_at: '2026-03-25T10:00:30.000Z' + }, + new Date('2026-03-25T10:00:40.000Z') + ) + + assert.equal(status, 'offline') +}) diff --git a/backend/src/services/printDownloadService.ts b/backend/src/services/printDownloadService.ts new file mode 100644 index 0000000..ae2df54 --- /dev/null +++ b/backend/src/services/printDownloadService.ts @@ -0,0 +1,157 @@ +import fs from 'fs' +import path from 'path' + +export interface PrintAgentReleaseManifest { + version: string + published_at: string + filename: string + notes: string + source_type: string + source_path?: string + relative_path?: string +} + +export interface DriverOfficialLatestManifest { + version: string + title: string + source_type: 'official_latest' + source_url: string + checked_at: string + notes: string +} + +export interface DriverArchiveManifest { + version: string + published_at: string + filename: string + notes: string + source_type: 'company_archive' + source_path: string + relative_path: string +} + +const readJsonFile = (filePath: string) => { + const raw = fs.readFileSync(filePath, 'utf8') + return JSON.parse(raw) as T +} + +const resolveDownloadsRoot = () => { + const candidates = [ + process.env.DOWNLOADS_DIR, + path.resolve(process.cwd(), 'downloads'), + path.resolve(process.cwd(), '..', 'downloads'), + path.resolve(__dirname, '..', '..', 'downloads'), + path.resolve(__dirname, '..', '..', '..', 'downloads') + ].filter(Boolean) as string[] + + return candidates.find((candidate) => fs.existsSync(candidate)) || candidates[0] +} + +const DOWNLOADS_ROOT = resolveDownloadsRoot() + +const resolveInsideDownloads = (...segments: string[]) => path.join(DOWNLOADS_ROOT, ...segments) + +const ensureManifestExists = (manifestPath: string) => { + if (!fs.existsSync(manifestPath)) { + throw new Error(`下载清单不存在:${manifestPath}`) + } +} + +const resolveBinaryPath = (manifestDir: string, fileName: string, relativePath?: string) => { + if (relativePath) { + return resolveInsideDownloads(relativePath.replace(/^downloads[\\/]/, '')) + } + return path.join(manifestDir, fileName) +} + +export const getDownloadsRoot = () => DOWNLOADS_ROOT + +export const getPrintAgentLatestManifest = () => { + const manifestPath = resolveInsideDownloads('print-agent', 'latest', 'manifest.json') + ensureManifestExists(manifestPath) + return readJsonFile(manifestPath) +} + +export const listPrintAgentReleaseManifests = () => { + const releasesDir = resolveInsideDownloads('print-agent', 'releases') + if (!fs.existsSync(releasesDir)) { + return [] as PrintAgentReleaseManifest[] + } + + const manifests = fs + .readdirSync(releasesDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(releasesDir, entry.name, 'manifest.json')) + .filter((manifestPath) => fs.existsSync(manifestPath)) + .map((manifestPath) => readJsonFile(manifestPath)) + .sort((left, right) => right.published_at.localeCompare(left.published_at)) + + return manifests +} + +export const resolvePrintAgentLatestBinary = () => { + const manifest = getPrintAgentLatestManifest() + const manifestDir = resolveInsideDownloads('print-agent', 'latest') + const filePath = resolveBinaryPath(manifestDir, manifest.filename, manifest.relative_path) + + return { + manifest, + filePath + } +} + +export const resolvePrintAgentReleaseBinary = (version: string) => { + const manifestDir = resolveInsideDownloads('print-agent', 'releases', version) + const manifestPath = path.join(manifestDir, 'manifest.json') + ensureManifestExists(manifestPath) + + const manifest = readJsonFile(manifestPath) + const filePath = resolveBinaryPath(manifestDir, manifest.filename, manifest.relative_path) + + return { + manifest, + filePath + } +} + +export const getXPrinterOfficialLatestManifest = () => { + const manifestPath = resolveInsideDownloads( + 'printer-driver', + 'xprinter', + 'latest', + 'manifest.json' + ) + ensureManifestExists(manifestPath) + return readJsonFile(manifestPath) +} + +export const listXPrinterArchiveManifests = () => { + const archiveDir = resolveInsideDownloads('printer-driver', 'xprinter', 'archive') + if (!fs.existsSync(archiveDir)) { + return [] as DriverArchiveManifest[] + } + + const manifests = fs + .readdirSync(archiveDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(archiveDir, entry.name, 'manifest.json')) + .filter((manifestPath) => fs.existsSync(manifestPath)) + .map((manifestPath) => readJsonFile(manifestPath)) + .sort((left, right) => right.published_at.localeCompare(left.published_at)) + + return manifests +} + +export const resolveXPrinterArchiveBinary = (version: string) => { + const manifestDir = resolveInsideDownloads('printer-driver', 'xprinter', 'archive', version) + const manifestPath = path.join(manifestDir, 'manifest.json') + ensureManifestExists(manifestPath) + + const manifest = readJsonFile(manifestPath) + const filePath = resolveBinaryPath(manifestDir, manifest.filename, manifest.relative_path) + + return { + manifest, + filePath + } +} diff --git a/backend/src/services/printService.ts b/backend/src/services/printService.ts new file mode 100644 index 0000000..631c5ff --- /dev/null +++ b/backend/src/services/printService.ts @@ -0,0 +1,1372 @@ +import { createHash, randomBytes } from 'crypto' +import type { Prisma, PrintJob, PrintTerminal } from '@prisma/client' +import prisma from '../utils/prisma' + +export interface PrintLabelProfileInput { + paper_size?: string | null + width_mm?: number | null + height_mm?: number | null + orientation?: string | null + scale_mode?: string | null +} + +export interface NormalizedPrintLabelProfile { + paper_size: string + width_mm: number + height_mm: number + orientation: 'landscape' | 'portrait' + scale_mode: 'fit' | 'fill' | 'stretch' +} + +export interface PrintTerminalReadinessInput { + status?: string | null + last_seen_at?: string | Date | null + selected_printer_name?: string | null + label_profile?: PrintLabelProfileInput | null +} + +export interface PrintTerminalReadinessResult { + is_ready: boolean + reason: + | 'ready' + | 'terminal_offline' + | 'terminal_disabled' + | 'missing_printer' + | 'invalid_label_profile' +} + +export interface ClaimablePrintJobInput { + id: number + status: string + target_terminal_id: number + created_at: string +} + +export interface AssetLabelPayloadItem { + id: number + equipment_code: string + equipment_name: string + equipment_model: string | null + equipment_type: string + created_at: string + purchase_date: string | null + department: { + name: string + } | null + storageLocation: { + id: number + name: string + code: string | null + } | null +} + +export interface AssetLabelJobPayload { + job_type: 'asset_label' + generated_at: string + equipment_ids: number[] + items: AssetLabelPayloadItem[] +} + +export interface PairPrintTerminalInput { + code: string + name: string + platform: string + machine_name: string +} + +export interface PrintHeartbeatInput { + name?: string | null + platform?: string | null + machine_name?: string | null + selected_printer_name?: string | null + printer_snapshot?: unknown + label_profile?: PrintLabelProfileInput | null +} + +export interface PrintJobStatusUpdateInput { + status: 'printing' | 'completed' | 'failed' + error_message?: string | null + retryable?: boolean +} + +export interface PrintAgentTestPrintInput { + selected_printer_name?: string | null + printer_snapshot?: unknown + label_profile?: PrintLabelProfileInput | null + success?: boolean + error_message?: string | null +} + +export const PRINT_JOB_TYPE = { + assetLabel: 'asset_label' +} as const + +export const PRINT_JOB_STATUS = { + queued: 'queued', + claimed: 'claimed', + printing: 'printing', + completed: 'completed', + failed: 'failed', + cancelled: 'cancelled' +} as const + +export const PRINT_TERMINAL_STATUS = { + online: 'online', + offline: 'offline', + disabled: 'disabled' +} as const + +const DEFAULT_LABEL_PROFILE: NormalizedPrintLabelProfile = { + paper_size: '60x40mm', + width_mm: 60, + height_mm: 40, + orientation: 'landscape', + scale_mode: 'fit' +} + +const PRINT_TERMINAL_STALE_MS = 90_000 +const PRINT_PAIR_CODE_TTL_MINUTES = 10 +const MAX_PRINT_JOB_LIST_PAGE_SIZE = 50 +const MAX_RETRY_ATTEMPTS = 3 +const PAIR_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' + +type ParsedTerminalData = { + labelProfile: PrintLabelProfileInput | null + printerSnapshot: unknown + effectiveStatus: string + readiness: PrintTerminalReadinessResult +} + +type TerminalListRecord = PrintTerminal & { + recentFailedJob?: { + id: number + error_message: string | null + created_at: Date + } | null +} + +type PrintJobListRecord = PrintJob & { + targetTerminal: { + id: number + name: string + status: string + selected_printer_name: string | null + last_seen_at: Date | null + label_profile_json: string | null + printer_snapshot_json: string | null + } + claimedByTerminal: { + id: number + name: string + } | null + requester: { + id: number + username: string + name: string + } +} + +export class PrintServiceError extends Error { + statusCode: number + + constructor(message: string, statusCode: number = 422) { + super(message) + this.name = 'PrintServiceError' + this.statusCode = statusCode + } +} + +const normalizePositiveNumber = (value: number | null | undefined, fallback: number) => { + if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) { + return fallback + } + return value +} + +const normalizeOrientation = (value: string | null | undefined) => { + return value === 'portrait' ? 'portrait' : DEFAULT_LABEL_PROFILE.orientation +} + +const normalizeScaleMode = (value: string | null | undefined) => { + if (value === 'fill' || value === 'stretch') { + return value + } + return DEFAULT_LABEL_PROFILE.scale_mode +} + +export const normalizeLabelProfile = ( + profile?: PrintLabelProfileInput | null +): NormalizedPrintLabelProfile => { + if (!profile) { + return { ...DEFAULT_LABEL_PROFILE } + } + + const width_mm = normalizePositiveNumber(profile.width_mm, DEFAULT_LABEL_PROFILE.width_mm) + const height_mm = normalizePositiveNumber(profile.height_mm, DEFAULT_LABEL_PROFILE.height_mm) + + return { + paper_size: profile.paper_size?.trim() || DEFAULT_LABEL_PROFILE.paper_size, + width_mm, + height_mm, + orientation: normalizeOrientation(profile.orientation), + scale_mode: normalizeScaleMode(profile.scale_mode) + } +} + +const isCompleteLabelProfile = (profile: NormalizedPrintLabelProfile) => { + return ( + profile.paper_size.trim().length > 0 && + profile.width_mm > 0 && + profile.height_mm > 0 && + Boolean(profile.orientation) && + Boolean(profile.scale_mode) + ) +} + +export const resolveTerminalReadiness = ( + terminal: PrintTerminalReadinessInput +): PrintTerminalReadinessResult => { + if (terminal.status === PRINT_TERMINAL_STATUS.disabled) { + return { + is_ready: false, + reason: 'terminal_disabled' + } + } + + if (terminal.status !== PRINT_TERMINAL_STATUS.online) { + return { + is_ready: false, + reason: 'terminal_offline' + } + } + + if (!terminal.selected_printer_name?.trim()) { + return { + is_ready: false, + reason: 'missing_printer' + } + } + + if (!terminal.label_profile) { + return { + is_ready: false, + reason: 'invalid_label_profile' + } + } + + const normalizedProfile = normalizeLabelProfile(terminal.label_profile) + if (!isCompleteLabelProfile(normalizedProfile)) { + return { + is_ready: false, + reason: 'invalid_label_profile' + } + } + + return { + is_ready: true, + reason: 'ready' + } +} + +export const pickNextClaimablePrintJob = ( + jobs: ClaimablePrintJobInput[], + terminalId: number +): ClaimablePrintJobInput | null => { + const candidates = jobs + .filter((job) => job.status === PRINT_JOB_STATUS.queued && job.target_terminal_id === terminalId) + .sort((left, right) => { + const timeDiff = + new Date(left.created_at).getTime() - new Date(right.created_at).getTime() + if (timeDiff !== 0) { + return timeDiff + } + return left.id - right.id + }) + + return candidates[0] ?? null +} + +const isPrismaUniqueConstraintError = (error: unknown) => { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: string }).code === 'P2002' + ) +} + +const parseJsonSafely = (rawValue: string | null | undefined): T | null => { + if (!rawValue) { + return null + } + + try { + return JSON.parse(rawValue) as T + } catch { + return null + } +} + +const stringifyJson = (value: unknown) => JSON.stringify(value ?? null) + +const sanitizeText = (value: unknown) => { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +const generatePairCodeValue = () => { + let code = '' + for (let index = 0; index < 8; index += 1) { + const randomIndex = randomBytes(1)[0] % PAIR_CODE_ALPHABET.length + code += PAIR_CODE_ALPHABET[randomIndex] + } + return code +} + +const generateTerminalToken = () => randomBytes(32).toString('hex') + +const hashTerminalToken = (token: string) => + createHash('sha256').update(token).digest('hex') + +const sanitizeEquipmentIds = (value: unknown) => { + if (!Array.isArray(value)) { + throw new PrintServiceError('请选择要打印的资产', 422) + } + + const uniqueIds = Array.from( + new Set( + value + .map((item) => Number(item)) + .filter((item) => Number.isInteger(item) && item > 0) + ) + ) + + if (uniqueIds.length === 0) { + throw new PrintServiceError('请选择要打印的资产', 422) + } + + return uniqueIds +} + +export const resolveEffectiveTerminalStatus = ( + terminal: Pick, + now: Date = new Date() +) => { + if (terminal.status === PRINT_TERMINAL_STATUS.disabled) { + return PRINT_TERMINAL_STATUS.disabled + } + + if (terminal.status === PRINT_TERMINAL_STATUS.offline) { + return PRINT_TERMINAL_STATUS.offline + } + + const lastSeenAtValue = terminal.last_seen_at + if (!lastSeenAtValue) { + return PRINT_TERMINAL_STATUS.offline + } + + const lastSeenAt = + lastSeenAtValue instanceof Date ? lastSeenAtValue : new Date(lastSeenAtValue) + if (Number.isNaN(lastSeenAt.getTime())) { + return PRINT_TERMINAL_STATUS.offline + } + + const elapsed = now.getTime() - lastSeenAt.getTime() + if (elapsed > PRINT_TERMINAL_STALE_MS) { + return PRINT_TERMINAL_STATUS.offline + } + + return PRINT_TERMINAL_STATUS.online +} + +const getEffectiveTerminalStatus = (status: string, lastSeenAt: Date | null) => { + if (status === PRINT_TERMINAL_STATUS.disabled) { + return PRINT_TERMINAL_STATUS.disabled + } + + if (!lastSeenAt) { + return PRINT_TERMINAL_STATUS.offline + } + + const elapsed = Date.now() - lastSeenAt.getTime() + if (elapsed > PRINT_TERMINAL_STALE_MS) { + return PRINT_TERMINAL_STATUS.offline + } + + return PRINT_TERMINAL_STATUS.online +} + +const getReadinessMessage = (reason: PrintTerminalReadinessResult['reason']) => { + switch (reason) { + case 'ready': + return '已就绪,可自动打印' + case 'terminal_disabled': + return '打印终端已停用' + case 'terminal_offline': + return '默认打印电脑离线' + case 'missing_printer': + return '标签打印机未连接或未选择' + case 'invalid_label_profile': + return '60x40mm 配置未完成' + default: + return '打印终端尚未就绪' + } +} + +const parseTerminalData = (terminal: { + status: string + last_seen_at: Date | null + selected_printer_name: string | null + label_profile_json: string | null + printer_snapshot_json: string | null +}): ParsedTerminalData => { + const labelProfile = parseJsonSafely(terminal.label_profile_json) + const printerSnapshot = parseJsonSafely(terminal.printer_snapshot_json) + const effectiveStatus = resolveEffectiveTerminalStatus({ + status: terminal.status, + last_seen_at: terminal.last_seen_at + }) + const readiness = resolveTerminalReadiness({ + status: effectiveStatus, + selected_printer_name: terminal.selected_printer_name, + label_profile: labelProfile + }) + + return { + labelProfile, + printerSnapshot, + effectiveStatus, + readiness + } +} + +const mapTerminalRecord = (terminal: TerminalListRecord) => { + const parsed = parseTerminalData(terminal) + + return { + id: terminal.id, + name: terminal.name, + platform: terminal.platform, + machine_name: terminal.machine_name, + status: parsed.effectiveStatus, + is_default: terminal.is_default, + last_seen_at: terminal.last_seen_at, + selected_printer_name: terminal.selected_printer_name, + printer_snapshot: parsed.printerSnapshot, + label_profile: parsed.labelProfile ? normalizeLabelProfile(parsed.labelProfile) : null, + readiness: { + ...parsed.readiness, + message: getReadinessMessage(parsed.readiness.reason) + }, + current_error_message: terminal.current_error_message, + paired_at: terminal.paired_at, + updated_at: terminal.updated_at, + recent_failed_job: terminal.recentFailedJob + ? { + id: terminal.recentFailedJob.id, + error_message: terminal.recentFailedJob.error_message, + created_at: terminal.recentFailedJob.created_at + } + : null + } +} + +const parseAssetLabelJobPayload = (payloadJson: string): AssetLabelJobPayload | null => { + const payload = parseJsonSafely(payloadJson) + if (!payload || payload.job_type !== PRINT_JOB_TYPE.assetLabel) { + return null + } + return payload +} + +const buildPrintJobSummary = (payload: AssetLabelJobPayload | null) => { + if (!payload) { + return { + equipment_count: 0, + equipment_codes: [] as string[] + } + } + + return { + equipment_count: payload.items.length, + equipment_codes: payload.items.slice(0, 5).map((item) => item.equipment_code) + } +} + +const mapPrintJobRecord = (job: PrintJobListRecord) => { + const payload = parseAssetLabelJobPayload(job.payload_json) + const targetParsed = parseTerminalData(job.targetTerminal) + + return { + id: job.id, + job_type: job.job_type, + status: job.status, + attempt_count: job.attempt_count, + error_message: job.error_message, + claimed_at: job.claimed_at, + started_at: job.started_at, + completed_at: job.completed_at, + created_at: job.created_at, + updated_at: job.updated_at, + payload, + summary: buildPrintJobSummary(payload), + target_terminal: { + id: job.targetTerminal.id, + name: job.targetTerminal.name, + status: targetParsed.effectiveStatus, + selected_printer_name: job.targetTerminal.selected_printer_name, + readiness: { + ...targetParsed.readiness, + message: getReadinessMessage(targetParsed.readiness.reason) + } + }, + claimed_by_terminal: job.claimedByTerminal, + requested_by: { + id: job.requester.id, + username: job.requester.username, + name: job.requester.name + } + } +} + +export const markStalePrintTerminalsOffline = async () => { + const staleBefore = new Date(Date.now() - PRINT_TERMINAL_STALE_MS) + await prisma.printTerminal.updateMany({ + where: { + status: PRINT_TERMINAL_STATUS.online, + last_seen_at: { + lt: staleBefore + } + }, + data: { + status: PRINT_TERMINAL_STATUS.offline + } + }) +} + +const getDefaultTargetTerminal = async () => { + await markStalePrintTerminalsOffline() + + const terminal = await prisma.printTerminal.findFirst({ + where: { + is_default: true + }, + orderBy: { + id: 'asc' + } + }) + + if (!terminal) { + throw new PrintServiceError('尚未配置默认打印电脑', 422) + } + + if (terminal.status === PRINT_TERMINAL_STATUS.disabled) { + throw new PrintServiceError('默认打印电脑已停用,请先启用或切换默认终端', 422) + } + + return terminal +} + +const buildAssetLabelPayload = async (equipmentIds: number[]): Promise => { + const equipmentList = await prisma.equipment.findMany({ + where: { + id: { + in: equipmentIds + } + }, + select: { + id: true, + equipment_code: true, + equipment_name: true, + equipment_model: true, + equipment_type: true, + created_at: true, + purchase_date: true, + department: { + select: { + name: true + } + }, + storageLocation: { + select: { + id: true, + name: true, + code: true + } + } + } + }) + + if (equipmentList.length !== equipmentIds.length) { + const existingIds = new Set(equipmentList.map((equipment) => equipment.id)) + const missingIds = equipmentIds.filter((equipmentId) => !existingIds.has(equipmentId)) + throw new PrintServiceError( + `存在找不到的资产记录:${missingIds.join('、')}`, + 404 + ) + } + + const equipmentMap = new Map(equipmentList.map((equipment) => [equipment.id, equipment])) + const orderedItems = equipmentIds.map((equipmentId) => { + const equipment = equipmentMap.get(equipmentId) + if (!equipment) { + throw new PrintServiceError(`资产 ${equipmentId} 不存在`, 404) + } + + return { + id: equipment.id, + equipment_code: equipment.equipment_code, + equipment_name: equipment.equipment_name, + equipment_model: equipment.equipment_model, + equipment_type: equipment.equipment_type, + created_at: equipment.created_at.toISOString(), + purchase_date: equipment.purchase_date ? equipment.purchase_date.toISOString() : null, + department: equipment.department + ? { + name: equipment.department.name + } + : null, + storageLocation: equipment.storageLocation + ? { + id: equipment.storageLocation.id, + name: equipment.storageLocation.name, + code: equipment.storageLocation.code + } + : null + } + }) + + return { + job_type: PRINT_JOB_TYPE.assetLabel, + generated_at: new Date().toISOString(), + equipment_ids: equipmentIds, + items: orderedItems + } +} + +export const createPrintTerminalPairCode = async (createdBy: number) => { + const expiresAt = new Date(Date.now() + PRINT_PAIR_CODE_TTL_MINUTES * 60 * 1000) + + for (let attempt = 0; attempt < 5; attempt += 1) { + const code = generatePairCodeValue() + + try { + const pairCode = await prisma.printTerminalPairCode.create({ + data: { + code, + expires_at: expiresAt, + created_by: createdBy + } + }) + + return { + id: pairCode.id, + code: pairCode.code, + expires_at: pairCode.expires_at, + created_at: pairCode.created_at + } + } catch (error) { + if (!isPrismaUniqueConstraintError(error)) { + throw error + } + } + } + + throw new PrintServiceError('生成配对码失败,请稍后重试', 500) +} + +export const listPrintTerminals = async () => { + await markStalePrintTerminalsOffline() + + const [terminals, failedJobs] = await Promise.all([ + prisma.printTerminal.findMany({ + orderBy: [{ is_default: 'desc' }, { updated_at: 'desc' }] + }), + prisma.printJob.findMany({ + where: { + status: PRINT_JOB_STATUS.failed + }, + orderBy: { + created_at: 'desc' + }, + select: { + id: true, + target_terminal_id: true, + error_message: true, + created_at: true + }, + take: 100 + }) + ]) + + const latestFailedJobMap = new Map() + for (const job of failedJobs) { + if (!latestFailedJobMap.has(job.target_terminal_id)) { + latestFailedJobMap.set(job.target_terminal_id, { + id: job.id, + error_message: job.error_message, + created_at: job.created_at + }) + } + } + + return terminals.map((terminal) => + mapTerminalRecord({ + ...terminal, + recentFailedJob: latestFailedJobMap.get(terminal.id) || null + }) + ) +} + +export const setDefaultPrintTerminal = async (terminalId: number) => { + const existing = await prisma.printTerminal.findUnique({ + where: { id: terminalId } + }) + + if (!existing) { + throw new PrintServiceError('打印终端不存在', 404) + } + + if (existing.status === PRINT_TERMINAL_STATUS.disabled) { + throw new PrintServiceError('已停用的终端不能设为默认打印电脑', 422) + } + + await prisma.$transaction(async (tx) => { + await tx.printTerminal.updateMany({ + where: { + is_default: true + }, + data: { + is_default: false + } + }) + + await tx.printTerminal.update({ + where: { id: terminalId }, + data: { + is_default: true + } + }) + }) + + const refreshed = await prisma.printTerminal.findUnique({ + where: { id: terminalId } + }) + + if (!refreshed) { + throw new PrintServiceError('打印终端不存在', 404) + } + + return mapTerminalRecord({ + ...refreshed, + recentFailedJob: null + }) +} + +export const updatePrintTerminalAdminStatus = async ( + terminalId: number, + nextStatus: 'offline' | 'disabled' +) => { + const existing = await prisma.printTerminal.findUnique({ + where: { id: terminalId } + }) + + if (!existing) { + throw new PrintServiceError('打印终端不存在', 404) + } + + if (nextStatus === PRINT_TERMINAL_STATUS.disabled && existing.is_default) { + throw new PrintServiceError('默认打印电脑不能直接停用,请先切换默认终端', 422) + } + + const updated = await prisma.printTerminal.update({ + where: { id: terminalId }, + data: { + status: nextStatus, + last_seen_at: existing.last_seen_at + } + }) + + return mapTerminalRecord({ + ...updated, + recentFailedJob: null + }) +} + +export const createAssetLabelPrintJob = async (equipmentIdsInput: unknown, requestedBy: number) => { + const equipmentIds = sanitizeEquipmentIds(equipmentIdsInput) + const [targetTerminal, payload] = await Promise.all([ + getDefaultTargetTerminal(), + buildAssetLabelPayload(equipmentIds) + ]) + + const job = await prisma.printJob.create({ + data: { + job_type: PRINT_JOB_TYPE.assetLabel, + status: PRINT_JOB_STATUS.queued, + target_terminal_id: targetTerminal.id, + requested_by: requestedBy, + payload_json: stringifyJson(payload) + }, + include: { + targetTerminal: { + select: { + id: true, + name: true, + status: true, + selected_printer_name: true, + last_seen_at: true, + label_profile_json: true, + printer_snapshot_json: true + } + }, + claimedByTerminal: { + select: { + id: true, + name: true + } + }, + requester: { + select: { + id: true, + username: true, + name: true + } + } + } + }) + + const parsedTarget = parseTerminalData(targetTerminal) + + return { + job: mapPrintJobRecord(job), + queue_notice: + parsedTarget.readiness.reason === 'ready' + ? '打印任务已入队,等待默认打印电脑领取' + : parsedTarget.readiness.reason === 'terminal_offline' + ? '已排队,等待打印电脑上线' + : `已排队,但当前默认打印电脑暂未就绪:${getReadinessMessage( + parsedTarget.readiness.reason + )}` + } +} + +export const listPrintJobs = async ( + page: number = 1, + pageSize: number = 10, + status?: string | null +) => { + const safePage = Math.max(1, Number(page) || 1) + const safePageSize = Math.min( + MAX_PRINT_JOB_LIST_PAGE_SIZE, + Math.max(1, Number(pageSize) || 10) + ) + const skip = (safePage - 1) * safePageSize + const sanitizedStatus = sanitizeText(status) + const where = sanitizedStatus + ? { + status: sanitizedStatus + } + : undefined + + const [total, jobs] = await Promise.all([ + prisma.printJob.count({ where }), + prisma.printJob.findMany({ + where, + orderBy: [{ created_at: 'desc' }, { id: 'desc' }], + skip, + take: safePageSize, + include: { + targetTerminal: { + select: { + id: true, + name: true, + status: true, + selected_printer_name: true, + last_seen_at: true, + label_profile_json: true, + printer_snapshot_json: true + } + }, + claimedByTerminal: { + select: { + id: true, + name: true + } + }, + requester: { + select: { + id: true, + username: true, + name: true + } + } + } + }) + ]) + + return { + list: jobs.map((job) => mapPrintJobRecord(job)), + pagination: { + page: safePage, + pageSize: safePageSize, + total, + totalPages: Math.max(1, Math.ceil(total / safePageSize)) + } + } +} + +export const cancelQueuedPrintJob = async (jobId: number) => { + const existing = await prisma.printJob.findUnique({ + where: { id: jobId } + }) + + if (!existing) { + throw new PrintServiceError('打印任务不存在', 404) + } + + if (existing.status !== PRINT_JOB_STATUS.queued) { + throw new PrintServiceError('只有排队中的任务可以取消', 422) + } + + await prisma.printJob.update({ + where: { id: jobId }, + data: { + status: PRINT_JOB_STATUS.cancelled + } + }) +} + +export const pairPrintTerminalAgent = async (input: PairPrintTerminalInput) => { + const code = sanitizeText(input.code) + const name = sanitizeText(input.name) + const platform = sanitizeText(input.platform) + const machineName = sanitizeText(input.machine_name) + + if (!code) { + throw new PrintServiceError('配对码不能为空', 422) + } + + if (!name || !platform || !machineName) { + throw new PrintServiceError('终端名称、平台和机器名不能为空', 422) + } + + const now = new Date() + const terminalToken = generateTerminalToken() + const terminalTokenHash = hashTerminalToken(terminalToken) + + const result = await prisma.$transaction(async (tx) => { + const pairCode = await tx.printTerminalPairCode.findFirst({ + where: { + code, + used_at: null, + expires_at: { + gt: now + } + } + }) + + if (!pairCode) { + throw new PrintServiceError('配对码无效、已使用或已过期', 422) + } + + const claimResult = await tx.printTerminalPairCode.updateMany({ + where: { + id: pairCode.id, + used_at: null + }, + data: { + used_at: now + } + }) + + if (claimResult.count === 0) { + throw new PrintServiceError('配对码已被其他终端使用,请重新生成', 409) + } + + const defaultTerminalCount = await tx.printTerminal.count({ + where: { + is_default: true, + status: { + not: PRINT_TERMINAL_STATUS.disabled + } + } + }) + + const terminal = await tx.printTerminal.create({ + data: { + name, + platform, + machine_name: machineName, + status: PRINT_TERMINAL_STATUS.offline, + is_default: defaultTerminalCount === 0, + terminal_token_hash: terminalTokenHash, + paired_at: now + } + }) + + await tx.printTerminalPairCode.update({ + where: { + id: pairCode.id + }, + data: { + used_by_terminal_id: terminal.id + } + }) + + return terminal + }) + + return { + terminal_id: result.id, + device_token: terminalToken, + paired_at: result.paired_at, + is_default: result.is_default + } +} + +export const findPrintTerminalByToken = async (token: string) => { + const normalizedToken = sanitizeText(token) + if (!normalizedToken) { + return null + } + + const terminal = await prisma.printTerminal.findUnique({ + where: { + terminal_token_hash: hashTerminalToken(normalizedToken) + } + }) + + return terminal +} + +export const getPrintTerminalById = async (terminalId: number) => { + const terminal = await prisma.printTerminal.findUnique({ + where: { + id: terminalId + } + }) + + if (!terminal) { + throw new PrintServiceError('打印终端不存在', 404) + } + + return terminal +} + +export const heartbeatPrintTerminalAgent = async ( + terminalId: number, + input: PrintHeartbeatInput +) => { + const existing = await getPrintTerminalById(terminalId) + if (existing.status === PRINT_TERMINAL_STATUS.disabled) { + throw new PrintServiceError('打印终端已停用', 403) + } + + const nextLabelProfile = input.label_profile ? normalizeLabelProfile(input.label_profile) : null + const updated = await prisma.printTerminal.update({ + where: { + id: terminalId + }, + data: { + name: sanitizeText(input.name) || existing.name, + platform: sanitizeText(input.platform) || existing.platform, + machine_name: sanitizeText(input.machine_name) || existing.machine_name, + status: PRINT_TERMINAL_STATUS.online, + last_seen_at: new Date(), + selected_printer_name: + sanitizeText(input.selected_printer_name) || existing.selected_printer_name, + printer_snapshot_json: + input.printer_snapshot !== undefined + ? stringifyJson(input.printer_snapshot) + : existing.printer_snapshot_json, + label_profile_json: + input.label_profile !== undefined + ? stringifyJson(nextLabelProfile) + : existing.label_profile_json + } + }) + + return mapTerminalRecord({ + ...updated, + recentFailedJob: null + }) +} + +const buildTargetTerminalSelection = { + id: true, + name: true, + status: true, + selected_printer_name: true, + last_seen_at: true, + label_profile_json: true, + printer_snapshot_json: true +} as const + +const buildClaimedTerminalSelection = { + id: true, + name: true +} as const + +const buildRequesterSelection = { + id: true, + username: true, + name: true +} as const + +export const claimNextPrintJob = async (terminalId: number) => { + const terminal = await getPrintTerminalById(terminalId) + const terminalView = mapTerminalRecord({ + ...terminal, + recentFailedJob: null + }) + + if (!terminalView.readiness.is_ready) { + return { + terminal: terminalView, + job: null, + message: getReadinessMessage(terminalView.readiness.reason) + } + } + + const claimedJob = await prisma.$transaction(async (tx) => { + const nextJob = await tx.printJob.findFirst({ + where: { + target_terminal_id: terminalId, + status: PRINT_JOB_STATUS.queued + }, + orderBy: [{ created_at: 'asc' }, { id: 'asc' }] + }) + + if (!nextJob) { + return null + } + + const claimResult = await tx.printJob.updateMany({ + where: { + id: nextJob.id, + target_terminal_id: terminalId, + status: PRINT_JOB_STATUS.queued + }, + data: { + status: PRINT_JOB_STATUS.claimed, + claimed_by_terminal_id: terminalId, + claimed_at: new Date(), + attempt_count: { + increment: 1 + } + } + }) + + if (claimResult.count === 0) { + return null + } + + return tx.printJob.findUnique({ + where: { + id: nextJob.id + }, + include: { + targetTerminal: { + select: buildTargetTerminalSelection + }, + claimedByTerminal: { + select: buildClaimedTerminalSelection + }, + requester: { + select: buildRequesterSelection + } + } + }) + }) + + if (!claimedJob) { + return { + terminal: terminalView, + job: null, + message: '当前没有待打印任务' + } + } + + return { + terminal: terminalView, + job: mapPrintJobRecord(claimedJob), + message: '领取成功' + } +} + +export const updateClaimedPrintJobStatus = async ( + terminalId: number, + jobId: number, + input: PrintJobStatusUpdateInput +) => { + const existing = await prisma.printJob.findUnique({ + where: { + id: jobId + }, + include: { + targetTerminal: { + select: buildTargetTerminalSelection + }, + claimedByTerminal: { + select: buildClaimedTerminalSelection + }, + requester: { + select: buildRequesterSelection + } + } + }) + + if (!existing) { + throw new PrintServiceError('打印任务不存在', 404) + } + + if (existing.target_terminal_id !== terminalId) { + throw new PrintServiceError('该任务不属于当前打印终端', 403) + } + + if (existing.claimed_by_terminal_id && existing.claimed_by_terminal_id !== terminalId) { + throw new PrintServiceError('该任务已被其他终端领取', 409) + } + + const now = new Date() + const errorMessage = sanitizeText(input.error_message) + + let data: Prisma.PrintJobUncheckedUpdateInput + let message = '状态已更新' + + if (input.status === PRINT_JOB_STATUS.printing) { + if ( + !([PRINT_JOB_STATUS.claimed, PRINT_JOB_STATUS.printing] as string[]).includes(existing.status) + ) { + throw new PrintServiceError('当前任务状态不能更新为打印中', 409) + } + + data = { + status: PRINT_JOB_STATUS.printing, + started_at: existing.started_at || now, + error_message: null + } + message = '任务已标记为打印中' + } else if (input.status === PRINT_JOB_STATUS.completed) { + if ( + !([ + PRINT_JOB_STATUS.claimed, + PRINT_JOB_STATUS.printing, + PRINT_JOB_STATUS.completed + ] as string[]).includes(existing.status) + ) { + throw new PrintServiceError('当前任务状态不能更新为已完成', 409) + } + + data = { + status: PRINT_JOB_STATUS.completed, + started_at: existing.started_at || now, + completed_at: now, + error_message: null + } + message = '任务已完成' + } else { + if ( + !([PRINT_JOB_STATUS.claimed, PRINT_JOB_STATUS.printing] as string[]).includes(existing.status) + ) { + throw new PrintServiceError('当前任务状态不能更新为失败', 409) + } + + const shouldRequeue = Boolean(input.retryable) && existing.attempt_count < MAX_RETRY_ATTEMPTS + if (shouldRequeue) { + data = { + status: PRINT_JOB_STATUS.queued, + claimed_by_terminal_id: null, + claimed_at: null, + started_at: null, + error_message: errorMessage || '打印失败,已重新排队' + } + message = '打印失败,任务已重新排队' + } else { + data = { + status: PRINT_JOB_STATUS.failed, + started_at: existing.started_at || now, + error_message: errorMessage || '打印失败' + } + message = '任务已标记为失败' + } + } + + const updated = await prisma.printJob.update({ + where: { + id: jobId + }, + data, + include: { + targetTerminal: { + select: buildTargetTerminalSelection + }, + claimedByTerminal: { + select: buildClaimedTerminalSelection + }, + requester: { + select: buildRequesterSelection + } + } + }) + + return { + job: mapPrintJobRecord(updated), + message + } +} + +export const reportPrintAgentTestPrint = async ( + terminalId: number, + input: PrintAgentTestPrintInput +) => { + const existing = await getPrintTerminalById(terminalId) + if (existing.status === PRINT_TERMINAL_STATUS.disabled) { + throw new PrintServiceError('打印终端已停用', 403) + } + + const nextLabelProfile = input.label_profile + ? normalizeLabelProfile(input.label_profile) + : parseJsonSafely(existing.label_profile_json) + + const updated = await prisma.printTerminal.update({ + where: { + id: terminalId + }, + data: { + status: PRINT_TERMINAL_STATUS.online, + last_seen_at: new Date(), + selected_printer_name: + sanitizeText(input.selected_printer_name) || existing.selected_printer_name, + printer_snapshot_json: + input.printer_snapshot !== undefined + ? stringifyJson(input.printer_snapshot) + : existing.printer_snapshot_json, + label_profile_json: + input.label_profile !== undefined + ? stringifyJson(nextLabelProfile) + : existing.label_profile_json + } + }) + + const terminal = mapTerminalRecord({ + ...updated, + recentFailedJob: null + }) + + return { + terminal, + success: input.success !== false, + message: + input.success === false + ? sanitizeText(input.error_message) || '测试打印失败' + : '测试打印结果已记录' + } +} + +export const isPrintServiceError = (error: unknown): error is PrintServiceError => + error instanceof PrintServiceError diff --git a/docker-compose.yml b/docker-compose.yml index 703af93..09e7fa9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,12 +50,14 @@ services: HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-5} HR_SYNC_EXCLUDED_DEPARTMENTS: ${HR_SYNC_EXCLUDED_DEPARTMENTS:-供应商,快递公司,外部交流} HR_SYNC_EXCLUDED_NAME_KEYWORDS: ${HR_SYNC_EXCLUDED_NAME_KEYWORDS:-} + DOWNLOADS_DIR: /app/downloads depends_on: mysql: condition: service_healthy volumes: - backend_uploads:/app/uploads - ./frontend/public/branding:/app/branding:ro + - ./downloads:/app/downloads:ro # 前端服务 frontend: diff --git a/downloads/print-agent/latest/manifest.json b/downloads/print-agent/latest/manifest.json new file mode 100644 index 0000000..635bc59 --- /dev/null +++ b/downloads/print-agent/latest/manifest.json @@ -0,0 +1,9 @@ +{ + "version": "0.2.0", + "published_at": "2026-03-25T17:45:51+08:00", + "filename": "print-agent-setup.exe", + "notes": "打印助手最新稳定版,当前为 .NET 8 原生 Windows 托盘安装包,支持正式安装、卸载、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。", + "source_type": "server_release", + "relative_path": "downloads/print-agent/latest/print-agent-setup.exe", + "relativePath": "downloads/print-agent/latest/print-agent-setup.exe" +} diff --git a/downloads/print-agent/releases/0.1.0/manifest.json b/downloads/print-agent/releases/0.1.0/manifest.json new file mode 100644 index 0000000..54308f0 --- /dev/null +++ b/downloads/print-agent/releases/0.1.0/manifest.json @@ -0,0 +1,9 @@ +{ + "version": "0.1.0", + "published_at": "2026-03-25T15:47:56+08:00", + "filename": "print-agent-setup-0.1.0.exe", + "notes": "打印助手 v0.1.0 发布包,含 Windows 托盘常驻、配对接入、心跳上报、任务轮询、驱动帮助、版本检查与资产标签静默打印,并修正 /api 地址兼容与启动后立即接单。", + "source_type": "server_release", + "relative_path": "downloads/print-agent/releases/0.1.0/print-agent-setup-0.1.0.exe", + "relativePath": "downloads/print-agent/releases/0.1.0/print-agent-setup-0.1.0.exe" +} diff --git a/downloads/print-agent/releases/0.2.0/manifest.json b/downloads/print-agent/releases/0.2.0/manifest.json new file mode 100644 index 0000000..4bdbcc2 --- /dev/null +++ b/downloads/print-agent/releases/0.2.0/manifest.json @@ -0,0 +1,9 @@ +{ + "version": "0.2.0", + "published_at": "2026-03-25T17:45:51+08:00", + "filename": "print-agent-setup-0.2.0.exe", + "notes": "打印助手 v0.2.0,已切换为 .NET 8 原生 Windows 托盘版,带正式安装器、控制面板卸载入口、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。", + "source_type": "server_release", + "relative_path": "downloads/print-agent/releases/0.2.0/print-agent-setup-0.2.0.exe", + "relativePath": "downloads/print-agent/releases/0.2.0/print-agent-setup-0.2.0.exe" +} diff --git a/downloads/printer-driver/xprinter/archive/2026.01.28.01/manifest.json b/downloads/printer-driver/xprinter/archive/2026.01.28.01/manifest.json new file mode 100644 index 0000000..8ed1109 --- /dev/null +++ b/downloads/printer-driver/xprinter/archive/2026.01.28.01/manifest.json @@ -0,0 +1,11 @@ +{ + "version": "2026.01.28.01", + "published_at": "2026-03-25T12:00:00.000Z", + "filename": "xprinter-driver-2026.01.28.01.exe", + "notes": "公司归档版芯烨 Windows 一键安装驱动,来源于交付电脑本地安装包。", + "source_type": "company_archive", + "source_path": "D:\\Users\\54972\\Downloads\\芯烨windows一键安装版驱动2026.01.28.01.exe", + "sourcePath": "D:\\Users\\54972\\Downloads\\芯烨windows一键安装版驱动2026.01.28.01.exe", + "relative_path": "downloads/printer-driver/xprinter/archive/2026.01.28.01/xprinter-driver-2026.01.28.01.exe", + "relativePath": "downloads/printer-driver/xprinter/archive/2026.01.28.01/xprinter-driver-2026.01.28.01.exe" +} diff --git a/downloads/printer-driver/xprinter/latest/manifest.json b/downloads/printer-driver/xprinter/latest/manifest.json new file mode 100644 index 0000000..d67f071 --- /dev/null +++ b/downloads/printer-driver/xprinter/latest/manifest.json @@ -0,0 +1,9 @@ +{ + "version": "latest", + "title": "芯烨 Windows 平台一键安装版驱动", + "source_type": "official_latest", + "source_url": "https://www.x-printer.cn/1/windows%E5%B9%B3%E5%8F%B0%E4%B8%80%E9%94%AE%E5%AE%89%E8%A3%85%E7%89%88/", + "sourceUrl": "https://www.x-printer.cn/1/windows%E5%B9%B3%E5%8F%B0%E4%B8%80%E9%94%AE%E5%AE%89%E8%A3%85%E7%89%88/", + "checked_at": "2026-03-25T12:00:00.000Z", + "notes": "芯烨官方最新版下载入口,页面内容会随官方更新。" +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 4f4e982..48f5024 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -45,6 +45,16 @@ server { proxy_set_header X-Real-IP $remote_addr; } + location /downloads { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + } + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control "public, immutable"; @@ -97,6 +107,16 @@ server { proxy_set_header X-Real-IP $remote_addr; } + location /downloads { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + } + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; add_header Cache-Control "public, immutable"; diff --git a/frontend/scripts/test-barcode-layout.ts b/frontend/scripts/test-barcode-layout.ts new file mode 100644 index 0000000..996b79c --- /dev/null +++ b/frontend/scripts/test-barcode-layout.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict' +import { + buildIndustrialCardDisplay, + fitMultilineText, + fitSingleLineText, + splitCodeForDisplay +} from '../src/components/barcodeLayout.ts' + +const measureText = (text: string, fontSize: number) => Array.from(text).length * fontSize + +const assertLineFits = (text: string, fontSize: number, maxWidth: number, label: string) => { + assert.ok( + measureText(text, fontSize) <= maxWidth, + `${label} 超出宽度预算: "${text}" @ ${fontSize}px > ${maxWidth}` + ) +} + +const titleFit = fitMultilineText( + { + text: '设计中心高配图形工作站 DESIGN CENTER GRAPHICS WORKSTATION ULTRA', + maxWidth: 180, + maxLines: 2, + maxFontSize: 28, + minFontSize: 16 + }, + measureText +) + +assert.ok(titleFit.lines.length <= 2, '资产名称最多只能占 2 行') +assert.ok(titleFit.lines.length >= 1, '资产名称至少应渲染 1 行') +assert.ok(titleFit.fontSize >= 16, '资产名称字号不能低于最小值') +for (const line of titleFit.lines) { + assertLineFits(line, titleFit.fontSize, 180, '资产名称') +} + +const modelFit = fitSingleLineText( + { + text: '型号: SUPER-LONG-INDUSTRIAL-WORKSTATION-MODEL-4090D-EXTREME', + maxWidth: 160, + maxFontSize: 18, + minFontSize: 12, + allowTruncate: true + }, + measureText +) + +assert.ok(modelFit.text.endsWith('...'), '超长型号应以省略号截断') +assert.ok(modelFit.fontSize >= 12, '型号字号不能低于最小值') +assertLineFits(modelFit.text, modelFit.fontSize, 160, '型号') + +const codeFit = fitSingleLineText( + { + text: 'JS-DESIGN-2026-ULTRA-09', + maxWidth: 300, + maxFontSize: 24, + minFontSize: 12, + allowTruncate: false + }, + measureText +) + +assert.equal(codeFit.text, 'JS-DESIGN-2026-ULTRA-09', '资产编码应优先完整显示') +assert.ok(codeFit.fontSize < 24, '编码过长时应先缩小字号') +assertLineFits(codeFit.text, codeFit.fontSize, 300, '资产编码') + +const splitCode = splitCodeForDisplay('JS-DESIGN-2026-ULTRA-09') + +assert.equal(splitCode.length, 2, '长编码应允许拆成 2 行显示') +assert.ok(splitCode[0].endsWith('-'), '第一行编码应尽量在分隔符处断开') +assert.equal(splitCode.join(''), 'JS-DESIGN-2026-ULTRA-09', '拆分后编码内容必须完整保留') + +const industrialCard = buildIndustrialCardDisplay({ + companyName: '常州简尚家具有限公司', + equipmentCode: 'JS-PC-2040', + equipmentType: '显示器', + purchaseDate: '2026-03-24T12:38:58.000Z', + organizationName: '设计中心' +}) + +assert.equal(industrialCard.companyName, '常州简尚家具有限公司', '工业卡片版必须保留公司名') +assert.equal(industrialCard.typeText, '显示器', '工业卡片版必须显示资产类别') +assert.equal(industrialCard.purchaseDateText, '2026-03-24', '工业卡片版必须显示格式化后的采购日期') +assert.equal(industrialCard.organizationText, '设计中心', '工业卡片版必须显示所属组织') +assert.deepEqual(industrialCard.codeLines, ['JS-PC-2040'], '较短编码应保持单行显示') + +const blankDateCard = buildIndustrialCardDisplay({ + companyName: '常州简尚家具有限公司', + equipmentCode: 'JS-PC-2041', + equipmentType: '显示器', + purchaseDate: '', + organizationName: '设计中心' +}) + +assert.equal(blankDateCard.purchaseDateText, '', '采购日期为空时必须保持留空') + +console.log('barcode layout tests: ok') diff --git a/frontend/src/api/print.ts b/frontend/src/api/print.ts new file mode 100644 index 0000000..a9ebd90 --- /dev/null +++ b/frontend/src/api/print.ts @@ -0,0 +1,418 @@ +import axios from 'axios' +import api from './index' +import type { + ApiResponse, + CreateAssetLabelPrintJobPayload, + CreateAssetLabelPrintJobResult, + PrintAgentReleaseInfo, + PrintDriverLatestInfo, + PrinterDriverRelease, + PrintJob, + PrintJobListData, + PrintJobListQuery, + PrintJobPayload, + PrintLabelProfile, + PrintTerminal, + PrintTerminalReadiness, + PrintTerminalPairCode, + PrintTerminalPrinter, + PrintTerminalStatus, + UpdatePrintTerminalStatusPayload +} from '@/types' + +const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api' +const downloadBaseUrl = apiBaseUrl.replace(/\/api\/?$/, '') + +const getAuthHeaders = () => { + const token = localStorage.getItem('token') + return token ? { Authorization: `Bearer ${token}` } : undefined +} + +const buildDownloadUrl = (path: string) => `${downloadBaseUrl}${path}` + +const toRecord = (value: unknown): Record => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record + } + return {} +} + +const toNullableString = (value: unknown) => { + if (typeof value === 'string') { + const trimmed = value.trim() + return trimmed ? trimmed : null + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value) + } + return null +} + +const toNumber = (value: unknown, fallback = 0) => { + const numberValue = Number(value) + return Number.isFinite(numberValue) ? numberValue : fallback +} + +const toBoolean = (value: unknown) => { + if (typeof value === 'boolean') return value + if (typeof value === 'number') return value !== 0 + if (typeof value === 'string') { + return ['1', 'true', 'yes', 'y'].includes(value.toLowerCase()) + } + return false +} + +const parseJsonObject = >(value: unknown): T | null => { + if (!value) return null + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as T) : null + } catch { + return null + } + } + return value && typeof value === 'object' && !Array.isArray(value) ? (value as T) : null +} + +const parseJsonArray = (value: unknown): T[] => { + if (Array.isArray(value)) { + return value as T[] + } + if (typeof value === 'string') { + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? (parsed as T[]) : [] + } catch { + return [] + } + } + return [] +} + +const unwrapPayload = (value: unknown): T => { + const record = toRecord(value) + if (Object.prototype.hasOwnProperty.call(record, 'code') && Object.prototype.hasOwnProperty.call(record, 'data')) { + return record.data as T + } + return value as T +} + +const normalizePrinterSnapshot = (value: unknown): PrintTerminalPrinter[] => + parseJsonArray(value).map((item) => { + const record = toRecord(item) + return { + name: toNullableString(record.name) || '未命名打印机', + driver_name: toNullableString(record.driver_name), + port_name: toNullableString(record.port_name), + is_default: toBoolean(record.is_default), + is_available: toBoolean(record.is_available) + } + }) + +const normalizeLabelProfile = (value: unknown): PrintLabelProfile | null => { + const record = parseJsonObject>(value) + if (!record) return null + + return { + paper_size: toNullableString(record.paper_size), + width_mm: + typeof record.width_mm === 'undefined' || record.width_mm === null ? null : toNumber(record.width_mm), + height_mm: + typeof record.height_mm === 'undefined' || record.height_mm === null ? null : toNumber(record.height_mm), + orientation: toNullableString(record.orientation), + scale_mode: toNullableString(record.scale_mode) || toNullableString(record.scaling), + scaling: toNullableString(record.scaling) || toNullableString(record.scale_mode), + configured: typeof record.configured === 'undefined' ? null : toBoolean(record.configured), + is_ready: typeof record.is_ready === 'undefined' ? null : toBoolean(record.is_ready), + status: toNullableString(record.status), + profile_status: toNullableString(record.profile_status), + message: toNullableString(record.message) + } +} + +const normalizeReadiness = (value: unknown): PrintTerminalReadiness | null => { + const record = parseJsonObject>(value) + if (!record) return null + + return { + is_ready: toBoolean(record.is_ready), + reason: toNullableString(record.reason), + message: toNullableString(record.message) + } +} + +const normalizePrintTerminal = (value: unknown): PrintTerminal => { + const record = toRecord(value) + const printerSnapshot = normalizePrinterSnapshot(record.printer_snapshot ?? record.printer_snapshot_json) + const labelProfile = normalizeLabelProfile(record.label_profile ?? record.label_profile_json) + const readiness = normalizeReadiness(record.readiness) + const recentFailedJob = parseJsonObject>(record.recent_failed_job) + + return { + id: toNumber(record.id), + name: toNullableString(record.name) || toNullableString(record.machine_name) || `终端 ${toNumber(record.id)}`, + platform: toNullableString(record.platform), + machine_name: toNullableString(record.machine_name), + status: toNullableString(record.status) || 'offline', + is_default: toBoolean(record.is_default), + last_seen_at: toNullableString(record.last_seen_at), + selected_printer_name: toNullableString(record.selected_printer_name), + printer_snapshot_json: (record.printer_snapshot_json ?? record.printer_snapshot) || null, + printer_snapshot: printerSnapshot, + label_profile_json: (record.label_profile_json ?? record.label_profile) || null, + label_profile: labelProfile, + readiness, + paired_at: toNullableString(record.paired_at), + created_at: toNullableString(record.created_at), + updated_at: toNullableString(record.updated_at), + latest_error_message: + toNullableString(record.latest_error_message) || + toNullableString(record.recent_error_message) || + toNullableString(recentFailedJob?.error_message), + current_error_message: toNullableString(record.current_error_message), + recent_failed_job: recentFailedJob + ? { + id: toNumber(recentFailedJob.id), + error_message: toNullableString(recentFailedJob.error_message), + created_at: toNullableString(recentFailedJob.created_at) + } + : null + } +} + +const normalizePrintPayload = (value: unknown): PrintJobPayload | null => { + const record = parseJsonObject(value) + return record || null +} + +const normalizePrintJob = (value: unknown): PrintJob => { + const record = toRecord(value) + const payload = normalizePrintPayload(record.payload ?? record.payload_json) + + return { + id: toNumber(record.id), + job_type: (toNullableString(record.job_type) || 'asset_label') as PrintJob['job_type'], + status: (toNullableString(record.status) || 'queued') as PrintJob['status'], + target_terminal_id: + typeof record.target_terminal_id === 'undefined' ? null : toNumber(record.target_terminal_id, 0), + target_terminal_name: + toNullableString(record.target_terminal_name) || + toNullableString(record.target_terminal?.name), + claimed_by_terminal_id: + typeof record.claimed_by_terminal_id === 'undefined' + ? null + : toNumber(record.claimed_by_terminal_id, 0), + claimed_by_terminal_name: + toNullableString(record.claimed_by_terminal_name) || + toNullableString(record.claimed_by_terminal?.name), + requested_by: + typeof record.requested_by === 'undefined' || record.requested_by === null + ? null + : (record.requested_by as number | string), + requested_by_name: + toNullableString(record.requested_by_name) || + toNullableString(record.requested_user_name) || + toNullableString(record.requested_by_user?.name) || + toNullableString(record.requested_by?.name), + payload_json: (record.payload_json ?? record.payload) || null, + payload, + error_message: toNullableString(record.error_message), + attempt_count: toNumber(record.attempt_count, 0), + claimed_at: toNullableString(record.claimed_at), + started_at: toNullableString(record.started_at), + completed_at: toNullableString(record.completed_at), + created_at: toNullableString(record.created_at), + updated_at: toNullableString(record.updated_at) + } +} + +const normalizeCollection = ( + value: unknown, + collectionKeys: string[], + mapper: (item: unknown) => T +): T[] => { + if (Array.isArray(value)) { + return value.map(mapper) + } + + const record = toRecord(value) + const collection = collectionKeys + .map((key) => record[key]) + .find((candidate) => Array.isArray(candidate)) + + return Array.isArray(collection) ? collection.map(mapper) : [] +} + +const normalizePrintJobsData = (value: unknown): PrintJobListData => { + const record = toRecord(value) + const list = normalizeCollection(value, ['list', 'items', 'jobs'], normalizePrintJob) + + return { + list, + total: toNumber(record.total, list.length), + page: toNumber(record.page, 1), + pageSize: toNumber(record.pageSize ?? record.page_size, list.length || 10) + } +} + +const normalizeReleaseNotes = (value: unknown): string | string[] | null => { + if (Array.isArray(value)) { + return value.map((item) => String(item)) + } + return toNullableString(value) +} + +const normalizePrintAgentRelease = (value: unknown): PrintAgentReleaseInfo => { + const record = toRecord(value) + return { + version: toNullableString(record.version) || '未命名版本', + published_at: toNullableString(record.published_at), + filename: toNullableString(record.filename), + notes: normalizeReleaseNotes(record.notes), + download_url: toNullableString(record.download_url) + } +} + +const normalizeDriverLatestInfo = (value: unknown): PrintDriverLatestInfo | null => { + const record = toRecord(value) + const officialUrl = + toNullableString(record.official_url) || + toNullableString(record.source_url) || + toNullableString(record.url) + + if (!officialUrl) { + return null + } + + return { + title: toNullableString(record.title) || '芯烨官方最新版驱动', + official_url: officialUrl, + checked_at: toNullableString(record.checked_at) || toNullableString(record.last_checked_at), + notes: normalizeReleaseNotes(record.notes), + source_type: toNullableString(record.source_type) || 'official_latest' + } +} + +const normalizeDriverRelease = (value: unknown): PrinterDriverRelease => { + const record = toRecord(value) + return { + version: toNullableString(record.version) || '未命名版本', + published_at: toNullableString(record.published_at), + filename: toNullableString(record.filename), + notes: normalizeReleaseNotes(record.notes), + download_url: toNullableString(record.download_url), + source_type: toNullableString(record.source_type), + source_url: toNullableString(record.source_url), + source_path: toNullableString(record.source_path) + } +} + +const extractFileName = (headerValue?: string | null) => { + if (!headerValue) return null + + const utf8Match = headerValue.match(/filename\*=UTF-8''([^;]+)/i) + if (utf8Match?.[1]) { + return decodeURIComponent(utf8Match[1]) + } + + const plainMatch = headerValue.match(/filename="?([^";]+)"?/i) + return plainMatch?.[1] || null +} + +export const getPrintTerminals = async () => { + const response = await api.get>('/print-terminals') + return normalizeCollection(response.data.data, ['list', 'items', 'terminals'], normalizePrintTerminal) +} + +export const createAssetLabelPrintJob = async (equipmentIds: number[]): Promise => { + const payload: CreateAssetLabelPrintJobPayload = { equipment_ids: equipmentIds } + const response = await api.post>('/print-jobs/asset-labels', payload) + return { + job: normalizePrintJob(response.data.data), + queue_notice: toNullableString(response.data.message) + } +} + +export const getPrintJobs = async (params?: PrintJobListQuery) => { + const response = await api.get>('/print-jobs', { params }) + return normalizePrintJobsData(response.data.data) +} + +export const cancelQueuedPrintJob = async (id: number) => { + const response = await api.patch>(`/print-jobs/${id}/cancel`) + return normalizePrintJob(response.data.data) +} + +export const setDefaultPrintTerminal = async (id: number) => { + const response = await api.patch>(`/print-terminals/${id}/default`) + return normalizePrintTerminal(response.data.data) +} + +export const updatePrintTerminalStatus = async (id: number, status: PrintTerminalStatus) => { + const payload: UpdatePrintTerminalStatusPayload = { status } + const response = await api.patch>(`/print-terminals/${id}/status`, payload) + return normalizePrintTerminal(response.data.data) +} + +export const createPrintTerminalPairCode = async () => { + const response = await api.post>('/print-terminals/pair-codes') + const record = toRecord(response.data.data) + return { + code: toNullableString(record.code) || '', + expires_at: toNullableString(record.expires_at) || '', + created_at: toNullableString(record.created_at), + used_at: toNullableString(record.used_at), + used_by_terminal_id: + typeof record.used_by_terminal_id === 'undefined' + ? null + : toNumber(record.used_by_terminal_id) + } as PrintTerminalPairCode +} + +export const getPrintAgentReleases = async () => { + const response = await axios.get(buildDownloadUrl('/downloads/print-agent/releases'), { + withCredentials: true, + headers: getAuthHeaders() + }) + const payload = unwrapPayload(response.data) + return normalizeCollection(payload, ['list', 'items', 'releases'], normalizePrintAgentRelease) +} + +export const getLatestXPrinterDriver = async () => { + const response = await axios.get(buildDownloadUrl('/downloads/printer-driver/xprinter/latest'), { + withCredentials: true, + headers: getAuthHeaders() + }) + return normalizeDriverLatestInfo(unwrapPayload(response.data)) +} + +export const getArchivedXPrinterDrivers = async () => { + const response = await axios.get(buildDownloadUrl('/downloads/printer-driver/xprinter/archive'), { + withCredentials: true, + headers: getAuthHeaders() + }) + const payload = unwrapPayload(response.data) + return normalizeCollection(payload, ['list', 'items', 'releases'], normalizeDriverRelease) +} + +export const buildPrintAgentLatestDownloadUrl = () => { + return buildDownloadUrl('/downloads/print-agent/latest') +} + +export const buildArchivedXPrinterDriverDownloadUrl = (version: string) => { + return buildDownloadUrl(`/downloads/printer-driver/xprinter/archive/${encodeURIComponent(version)}`) +} + +export const downloadPrintResource = async (url: string) => { + const response = await axios.get(url, { + responseType: 'blob', + withCredentials: true, + headers: getAuthHeaders() + }) + + return { + blob: response.data as Blob, + fileName: extractFileName(response.headers['content-disposition']) || url.split('/').pop() || 'download' + } +} diff --git a/frontend/src/components/BarcodePrint.vue b/frontend/src/components/BarcodePrint.vue index 804f2fe..dc78c0b 100644 --- a/frontend/src/components/BarcodePrint.vue +++ b/frontend/src/components/BarcodePrint.vue @@ -12,6 +12,40 @@
+
+
+
+

办公室打印状态

+

{{ remoteStatusDescription }}

+
+
+ {{ remoteStatusText }} + 默认打印电脑 +
+
+ +
+
+

终端名称

+

{{ defaultTerminal?.name || '未配置' }}

+
+
+

当前打印机

+

{{ remotePrinterName }}

+
+
+

标签配置

+

{{ remoteProfileText }}

+
+
+

最近在线

+

{{ formatNullableDateTime(defaultTerminal?.last_seen_at) }}

+
+
+ +

{{ terminalError }}

+
+
{{ loading ? '生成中...' : '打印二维码' }} + @@ -54,7 +91,15 @@ import { computed, onMounted, ref } from 'vue' import QRCode from 'qrcode' import { getEquipmentBatchForBarcode } from '@/api/barcode' -import { useSiteStore } from '@/stores/site' +import { createAssetLabelPrintJob, getPrintTerminals } from '@/api/print' +import type { PrintTerminal } from '@/types' +import { formatDateTime } from '@/utils/format' +import { + buildIndustrialCardDisplay, + fitMultilineText, + fitSingleLineText, + type MeasureTextFn +} from './barcodeLayout' interface EquipmentBarcode { id: number @@ -86,33 +131,139 @@ const LABEL_PRESET = { heightMm: 40, previewWidthPx: 360, previewHeightPx: 240, - canvasWidthPx: 709, // 300 DPI - canvasHeightPx: 472, // 300 DPI - qrSizePx: 260, // 二维码尺寸 - paddingPx: 14, + canvasWidthPx: 709, + canvasHeightPx: 472, + outerInsetPx: 10, + headerHeightPx: 82, + qrPanelWidthPx: 324, + qrCodeSizePx: 286, companyName: '常州简尚家具有限公司' } as const -const siteStore = useSiteStore() const equipmentList = ref([]) const previewImages = ref>({}) const loading = ref(false) +const terminalLoading = ref(false) +const remoteSubmitting = ref(false) +const defaultTerminal = ref(null) +const terminalError = ref(null) + +const DISPLAY_FONT = '"Arial Black", "Bahnschrift", "Segoe UI", sans-serif' +const BODY_FONT = '"Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif' +const COMPACT_FONT = '"Bahnschrift SemiBold", "Arial Black", "Segoe UI", sans-serif' const previewCardStyle = computed(() => ({ width: '100%', maxWidth: `${LABEL_PRESET.previewWidthPx}px`, aspectRatio: `${LABEL_PRESET.previewWidthPx} / ${LABEL_PRESET.previewHeightPx}`, - background: '#ffffff', - borderRadius: '16px', - overflow: 'hidden', - boxShadow: '0 1px 4px rgba(0,0,0,0.1)' + background: '#FFFFFF', + borderRadius: '0', + overflow: 'hidden' })) -const formatDate = (value?: string) => { - if (!value) return '' - return value.includes('T') ? value.split('T')[0] : value +const formatNullableDateTime = (value?: string | null) => { + if (!value) return '未上报' + try { + return formatDateTime(value) + } catch { + return value + } } +const getProfileText = (terminal?: PrintTerminal | null) => { + if (!terminal) { + return '未完成 60x40 配置' + } + + if (terminal.readiness?.message) { + return terminal.readiness.message + } + + const profile = terminal.label_profile + if (!profile) { + return '未完成 60x40 配置' + } + + if (profile.message) { + return profile.message + } + + if (profile.is_ready === true || profile.configured === true) { + return profile.paper_size ? `${profile.paper_size} 已就绪` : '标签配置已完成' + } + + if (profile.paper_size) { + return `${profile.paper_size} 待确认` + } + + return '未完成 60x40 配置' +} + +const remoteStatusBadgeClass = computed(() => { + if (terminalLoading.value) return 'badge-neutral' + if (terminalError.value) return 'badge-warning' + if (!defaultTerminal.value) return 'badge-neutral' + if (defaultTerminal.value.status === 'disabled') return 'badge-neutral' + if (defaultTerminal.value.status === 'online' && defaultTerminal.value.readiness?.is_ready) return 'badge-success' + return 'badge-warning' +}) + +const remoteStatusText = computed(() => { + if (terminalLoading.value) return '读取中' + if (terminalError.value) return '状态未同步' + if (!defaultTerminal.value) return '未配置' + if (defaultTerminal.value.status === 'disabled') return '终端已停用' + if (defaultTerminal.value.status === 'online' && defaultTerminal.value.readiness?.is_ready) return '可直接打印' + if (defaultTerminal.value.status === 'online') return '待配置' + return '离线排队' +}) + +const remotePrinterName = computed(() => { + return ( + defaultTerminal.value?.selected_printer_name || + defaultTerminal.value?.printer_snapshot?.[0]?.name || + '未选择打印机' + ) +}) + +const remoteProfileText = computed(() => { + return getProfileText(defaultTerminal.value) +}) + +const remoteStatusDescription = computed(() => { + if (terminalLoading.value) { + return '正在读取默认打印电脑状态...' + } + + if (terminalError.value) { + return '暂时无法读取办公室打印状态,建议先刷新系统设置里的打印终端。' + } + + if (!defaultTerminal.value) { + return '当前还没有设置默认打印电脑,请先到系统设置里的“打印终端”完成配对。' + } + + if (defaultTerminal.value.status === 'online' && defaultTerminal.value.readiness?.is_ready) { + return `默认打印电脑 ${defaultTerminal.value.name} 在线,发送后会直接交给 ${remotePrinterName.value}。` + } + + if (defaultTerminal.value.status === 'disabled') { + return '默认打印电脑已停用,当前不能接收远程打印任务。' + } + + if (defaultTerminal.value.status === 'online') { + return `默认打印电脑 ${defaultTerminal.value.name} 已在线,但暂未就绪:${defaultTerminal.value.readiness?.message || '请先完成打印机或 60x40 配置'}。现在发送会先排队。` + } + + return `默认打印电脑 ${defaultTerminal.value.name} 当前离线,现在发送会先排队,等它重新上线后自动打印。` +}) + +const remoteActionDisabled = computed(() => { + if (loading.value || terminalLoading.value || remoteSubmitting.value) return true + if (!defaultTerminal.value) return true + return defaultTerminal.value.status === 'disabled' +}) + const loadImage = (src: string) => new Promise((resolve, reject) => { const image = new Image() @@ -121,6 +272,107 @@ const loadImage = (src: string) => image.src = src }) +const setCanvasFont = ( + ctx: CanvasRenderingContext2D, + fontSize: number, + weight: string, + family: string +) => { + ctx.font = `${weight} ${fontSize}px ${family}` +} + +const createMeasureText = ( + ctx: CanvasRenderingContext2D, + weight: string, + family: string +): MeasureTextFn => (text, fontSize) => { + setCanvasFont(ctx, fontSize, weight, family) + return ctx.measureText(text).width +} + +const drawTextLine = ( + ctx: CanvasRenderingContext2D, + { + text, + x, + y, + fontSize, + weight = '700', + family = BODY_FONT, + color = '#000000', + align = 'left', + baseline = 'top' + }: { + text: string + x: number + y: number + fontSize: number + weight?: string + family?: string + color?: string + align?: CanvasTextAlign + baseline?: CanvasTextBaseline + } +) => { + setCanvasFont(ctx, fontSize, weight, family) + ctx.fillStyle = color + ctx.textAlign = align + ctx.textBaseline = baseline + ctx.fillText(text, x, y) +} + +const drawRule = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height = 2, + color = '#000000' +) => { + ctx.fillStyle = color + ctx.fillRect(x, y, width, height) +} + +const drawLabelCell = ( + ctx: CanvasRenderingContext2D, + label: string, + x: number, + y: number, + width: number, + height: number, + fontSize = 14 +) => { + ctx.fillStyle = '#000000' + ctx.fillRect(x, y, width, height) + drawTextLine(ctx, { + text: label, + x: x + width / 2, + y: y + height / 2 + 1, + fontSize, + weight: '900', + family: COMPACT_FONT, + color: '#FFFFFF', + align: 'center', + baseline: 'middle' + }) +} + +const fitCodeLines = ( + lines: string[], + maxWidth: number, + maxFontSize: number, + minFontSize: number, + measureText: MeasureTextFn +) => { + for (let fontSize = maxFontSize; fontSize >= minFontSize; fontSize -= 1) { + if (lines.every((line) => measureText(line, fontSize) <= maxWidth)) { + return fontSize + } + } + + return minFontSize +} + const renderCardToCanvas = async (equipment: EquipmentBarcode) => { const canvas = document.createElement('canvas') canvas.width = LABEL_PRESET.canvasWidthPx @@ -131,178 +383,250 @@ const renderCardToCanvas = async (equipment: EquipmentBarcode) => { throw new Error('无法创建画布') } - const { canvasWidthPx, canvasHeightPx, qrSizePx } = LABEL_PRESET + const { + canvasWidthPx, + canvasHeightPx, + outerInsetPx, + headerHeightPx, + qrPanelWidthPx, + qrCodeSizePx + } = LABEL_PRESET - // 纯白背景 - ctx.fillStyle = '#ffffff' + const display = buildIndustrialCardDisplay({ + companyName: LABEL_PRESET.companyName, + equipmentCode: equipment.equipment_code || '-', + equipmentType: equipment.equipment_type, + purchaseDate: equipment.purchase_date, + organizationName: equipment.department?.name + }) + + ctx.fillStyle = '#FFFFFF' ctx.fillRect(0, 0, canvasWidthPx, canvasHeightPx) + ctx.strokeStyle = '#000000' + ctx.lineWidth = 8 + ctx.strokeRect(4, 4, canvasWidthPx - 8, canvasHeightPx - 8) - // ═══════════════════════════════════════════════════════ - // 60x40mm 标签设计 - 信息丰富 + 大字体 (300 DPI) - // ═══════════════════════════════════════════════════════ + const contentX = outerInsetPx + const contentY = outerInsetPx + const contentWidth = canvasWidthPx - outerInsetPx * 2 - const leftSectionWidth = Math.floor(canvasWidthPx * 0.52) // 52% 给二维码 - const rightSectionX = leftSectionWidth - const rightSectionWidth = canvasWidthPx - leftSectionWidth - - // 左侧纯黑背景 ctx.fillStyle = '#000000' - ctx.fillRect(0, 0, leftSectionWidth, canvasHeightPx) + ctx.fillRect(contentX, contentY, contentWidth, headerHeightPx) - // 斜切装饰 - 右上角白色三角形 - ctx.fillStyle = '#ffffff' - ctx.beginPath() - ctx.moveTo(leftSectionWidth - 40, 0) - ctx.lineTo(leftSectionWidth, 0) - ctx.lineTo(leftSectionWidth, 40) - ctx.closePath() - ctx.fill() + const whiteBodyMeasure = createMeasureText(ctx, '700', BODY_FONT) + const companyFit = fitSingleLineText( + { + text: display.companyName, + maxWidth: contentWidth - 110, + maxFontSize: 32, + minFontSize: 22, + allowTruncate: true + }, + whiteBodyMeasure + ) + drawTextLine(ctx, { + text: companyFit.text, + x: contentX + 18, + y: contentY + Math.round((headerHeightPx - companyFit.fontSize) / 2) - 1, + fontSize: companyFit.fontSize, + weight: '900', + family: BODY_FONT, + color: '#FFFFFF' + }) - // 二维码位置 - 左侧居中 - const qrX = Math.floor((leftSectionWidth - qrSizePx) / 2) + 4 - const qrY = Math.floor((canvasHeightPx - qrSizePx) / 2) + ctx.fillStyle = '#FFFFFF' + ctx.fillRect(contentX + contentWidth - 52, contentY + 16, 24, 24) + ctx.fillRect(contentX + contentWidth - 20, contentY + 16, 5, 24) - // 生成二维码 - const qrDataUrl = await QRCode.toDataURL(equipment.equipment_code, { - width: qrSizePx, - margin: 0, + const mainY = contentY + headerHeightPx + 10 + const mainHeight = canvasHeightPx - mainY - outerInsetPx + const qrPanelX = contentX + const qrPanelY = mainY + const qrPanelHeight = mainHeight + const qrPanelInnerSize = qrPanelWidthPx - 20 + const qrFrameX = qrPanelX + 10 + const qrFrameY = qrPanelY + Math.round((qrPanelHeight - qrPanelInnerSize) / 2) + + ctx.fillStyle = '#FFFFFF' + ctx.fillRect(qrPanelX, qrPanelY, qrPanelWidthPx, qrPanelHeight) + ctx.strokeStyle = '#000000' + ctx.lineWidth = 5 + ctx.strokeRect(qrPanelX, qrPanelY, qrPanelWidthPx, qrPanelHeight) + ctx.lineWidth = 4 + ctx.strokeRect(qrFrameX, qrFrameY, qrPanelInnerSize, qrPanelInnerSize) + + const qrDataUrl = await QRCode.toDataURL(display.codeLines.join(''), { + width: qrCodeSizePx, + margin: 1, color: { dark: '#000000', - light: '#ffffff' + light: '#FFFFFF' }, errorCorrectionLevel: 'H' }) const qrImage = await loadImage(qrDataUrl) + const qrImageX = qrFrameX + Math.floor((qrPanelInnerSize - qrCodeSizePx) / 2) + const qrImageY = qrFrameY + Math.floor((qrPanelInnerSize - qrCodeSizePx) / 2) - // 二维码白色背景块 - ctx.fillStyle = '#ffffff' - ctx.fillRect(qrX - 6, qrY - 6, qrSizePx + 12, qrSizePx + 12) + ctx.imageSmoothingEnabled = false + ctx.drawImage(qrImage, qrImageX, qrImageY, qrCodeSizePx, qrCodeSizePx) + ctx.imageSmoothingEnabled = true - // 绘制二维码 - ctx.drawImage(qrImage, qrX, qrY, qrSizePx, qrSizePx) + const leftCaptionMeasure = createMeasureText(ctx, '900', COMPACT_FONT) + const leftCaptionFit = fitSingleLineText( + { + text: 'SCAN', + maxWidth: qrPanelWidthPx - 28, + maxFontSize: 18, + minFontSize: 14, + allowTruncate: true + }, + leftCaptionMeasure + ) + drawTextLine(ctx, { + text: leftCaptionFit.text, + x: qrPanelX + 16, + y: qrPanelY + qrPanelHeight - 32, + fontSize: leftCaptionFit.fontSize, + weight: '900', + family: COMPACT_FONT, + color: '#000000' + }) - // 二维码外框 - 粗黑线 - ctx.strokeStyle = '#000000' - ctx.lineWidth = 3 - ctx.strokeRect(qrX - 6, qrY - 6, qrSizePx + 12, qrSizePx + 12) + const rightX = qrPanelX + qrPanelWidthPx + 10 + const rightWidth = canvasWidthPx - rightX - outerInsetPx + const blackCompactMeasure = createMeasureText(ctx, '900', COMPACT_FONT) + const headerFit = fitSingleLineText( + { + text: display.typeText, + maxWidth: Math.floor(rightWidth * 0.86), + maxFontSize: 40, + minFontSize: 30, + allowTruncate: true + }, + blackCompactMeasure + ) + const typeBadgeWidth = Math.ceil(blackCompactMeasure(headerFit.text, headerFit.fontSize)) + 40 + const typeBadgeHeight = headerFit.fontSize + 22 - // 四角定位块装饰 - const cornerSize = 14 ctx.fillStyle = '#000000' - ctx.fillRect(qrX - 10, qrY - 10, cornerSize, cornerSize) - ctx.fillRect(qrX + qrSizePx - 4, qrY - 10, cornerSize, cornerSize) - ctx.fillRect(qrX - 10, qrY + qrSizePx - 4, cornerSize, cornerSize) - ctx.fillRect(qrX + qrSizePx - 4, qrY + qrSizePx - 4, cornerSize, cornerSize) + ctx.fillRect(rightX, mainY, typeBadgeWidth, typeBadgeHeight) + drawTextLine(ctx, { + text: headerFit.text, + x: rightX + typeBadgeWidth / 2, + y: mainY + typeBadgeHeight / 2 + 1, + fontSize: headerFit.fontSize, + weight: '900', + family: COMPACT_FONT, + color: '#FFFFFF', + align: 'center', + baseline: 'middle' + }) - // 公司名称竖排 - 左侧边缘 - ctx.save() - ctx.translate(10, canvasHeightPx / 2) - ctx.rotate(-Math.PI / 2) - ctx.fillStyle = '#000000' - ctx.textAlign = 'center' - ctx.textBaseline = 'middle' - ctx.font = 'bold 12px "Microsoft YaHei", "PingFang SC", Arial, sans-serif' - ctx.fillText('常州简尚家具有限公司', 0, 0) - ctx.restore() + drawRule( + ctx, + rightX + typeBadgeWidth + 12, + mainY + Math.floor(typeBadgeHeight / 2), + Math.max(0, rightWidth - typeBadgeWidth - 12), + 3 + ) - // ═══════════════════════════════════════════════════════ - // 右侧信息区域 - 舒适布局,适当留白,行间距加大 - // ═══════════════════════════════════════════════════════ + const codeMeasure = createMeasureText(ctx, '900', DISPLAY_FONT) + const codeLines = display.codeLines + const codeFontSize = fitCodeLines( + codeLines, + rightWidth, + codeLines.length > 1 ? 46 : 54, + 30, + codeMeasure + ) + const codeY = mainY + typeBadgeHeight + 14 + const codeLineHeight = Math.round(codeFontSize * 0.96) + codeLines.forEach((line, index) => { + drawTextLine(ctx, { + text: line, + x: rightX, + y: codeY + index * codeLineHeight, + fontSize: codeFontSize, + weight: '900', + family: DISPLAY_FONT, + color: '#000000' + }) + }) - const infoX = rightSectionX + 12 - const infoY = 18 + const codeBottomY = codeY + codeLines.length * codeLineHeight + drawRule(ctx, rightX, codeBottomY + 10, rightWidth, 4) - // 完整公司名称 - 两行分散布局 - ctx.fillStyle = '#000000' - ctx.textAlign = 'left' - ctx.textBaseline = 'top' - ctx.font = 'bold 29px "Microsoft YaHei", "PingFang SC", Arial, sans-serif' - ctx.fillText('常州简尚', infoX, infoY) - ctx.font = 'bold 21px "Microsoft YaHei", "PingFang SC", Arial, sans-serif' - ctx.fillText('家具有限公司', infoX, infoY + 34) + const purchaseDateLabelY = codeBottomY + 18 + drawLabelCell(ctx, '采购日期', rightX, purchaseDateLabelY, 142, 40, 16) - // 粗分隔线 - ctx.strokeStyle = '#000000' - ctx.lineWidth = 3 - ctx.beginPath() - ctx.moveTo(infoX, infoY + 68) - ctx.lineTo(canvasWidthPx - 12, infoY + 68) - ctx.stroke() + let purchaseDateBottomY = purchaseDateLabelY + 40 + if (display.purchaseDateText) { + const purchaseDateMeasure = createMeasureText(ctx, '800', BODY_FONT) + const purchaseDateFit = fitSingleLineText( + { + text: display.purchaseDateText, + maxWidth: rightWidth, + maxFontSize: 28, + minFontSize: 22, + allowTruncate: true + }, + purchaseDateMeasure + ) - // 资产名称 - 最大最突出 - ctx.fillStyle = '#000000' - ctx.font = 'bold 41px "Microsoft YaHei", "PingFang SC", Arial, sans-serif' - const nameText = equipment.equipment_name || '-' - let displayName = nameText - const maxNameWidth = rightSectionWidth - 24 - if (ctx.measureText(nameText).width > maxNameWidth) { - while (ctx.measureText(displayName + '...').width > maxNameWidth && displayName.length > 0) { - displayName = displayName.slice(0, -1) - } - displayName += '...' - } - ctx.fillText(displayName, infoX, infoY + 82) + const purchaseDateTextY = purchaseDateLabelY + 50 + drawTextLine(ctx, { + text: purchaseDateFit.text, + x: rightX, + y: purchaseDateTextY, + fontSize: purchaseDateFit.fontSize, + weight: '800', + family: BODY_FONT, + color: '#000000' + }) - // 型号 (如果有) - 放大确保打印清晰 - let currentY = infoY + 128 - if (equipment.equipment_model) { - ctx.fillStyle = '#000000' - ctx.font = 'bold 21px "Microsoft YaHei", "PingFang SC", Arial, sans-serif' - ctx.fillText(`型号: ${equipment.equipment_model}`, infoX, currentY) - currentY += 32 + purchaseDateBottomY = purchaseDateTextY + purchaseDateFit.fontSize } - // 编码 - 更大字体 + const organizationLabelY = purchaseDateBottomY + 12 + drawLabelCell(ctx, '所属组织', rightX, organizationLabelY, 160, 40, 16) + + const organizationMeasure = createMeasureText(ctx, '700', BODY_FONT) + const organizationFit = fitMultilineText( + { + text: display.organizationText, + maxWidth: rightWidth, + maxLines: 2, + maxFontSize: 24, + minFontSize: 18 + }, + organizationMeasure + ) + + const organizationTextY = organizationLabelY + 50 + const organizationLineHeight = Math.round(organizationFit.fontSize * 1.08) + organizationFit.lines.forEach((line, index) => { + drawTextLine(ctx, { + text: line, + x: rightX, + y: organizationTextY + index * organizationLineHeight, + fontSize: organizationFit.fontSize, + weight: '800', + family: BODY_FONT, + color: '#000000' + }) + }) + const organizationTextBottomY = + organizationTextY + Math.max(organizationFit.lines.length, 1) * organizationLineHeight + + const bottomAccentY = Math.min( + qrPanelY + qrPanelHeight - 18, + organizationTextBottomY + 14 + ) + drawRule(ctx, rightX, bottomAccentY, rightWidth - 44, 3) ctx.fillStyle = '#000000' - ctx.font = 'bold 30px "Microsoft YaHei", "PingFang SC", Arial, sans-serif' - ctx.fillText(equipment.equipment_code || '-', infoX, currentY) - currentY += 38 - - // 部门 + 存放位置 - 放大 - ctx.fillStyle = '#000000' - ctx.font = 'bold 19px "Microsoft YaHei", "PingFang SC", Arial, sans-serif' - const deptText = equipment.department?.name || '未分配部门' - const locText = equipment.storageLocation?.name - if (locText) { - ctx.fillText(`${deptText} | ${locText}`, infoX, currentY) - } else { - ctx.fillText(deptText, infoX, currentY) - } - currentY += 30 - - // 购买日期 - 放大 - const purchaseDate = formatDate(equipment.purchase_date) - const createdDate = formatDate(equipment.created_at) - if (purchaseDate || createdDate) { - ctx.fillStyle = '#000000' - ctx.font = 'bold 17px "Microsoft YaHei", "PingFang SC", Arial, sans-serif' - const dateLabel = purchaseDate ? '购买' : '入库' - const dateValue = purchaseDate || createdDate - ctx.fillText(`${dateLabel}: ${dateValue}`, infoX, currentY) - } - - // 底部粗黑条 - 类型 - const barHeight = 30 - const barY = canvasHeightPx - barHeight - 12 - ctx.fillStyle = '#000000' - ctx.fillRect(infoX, barY, rightSectionWidth - 24, barHeight) - - // 底部条上的类型文字 - ctx.fillStyle = '#ffffff' - ctx.textAlign = 'left' - ctx.textBaseline = 'middle' - ctx.font = 'bold 15px "Microsoft YaHei", "PingFang SC", Arial, sans-serif' - const typeText = (equipment.equipment_type || 'ASSET').toUpperCase() - ctx.fillText(typeText, infoX + 10, barY + barHeight / 2 + 1) - - // 右侧边缘装饰线 - ctx.strokeStyle = '#000000' - ctx.lineWidth = 5 - ctx.beginPath() - ctx.moveTo(canvasWidthPx - 6, 12) - ctx.lineTo(canvasWidthPx - 6, canvasHeightPx - 12) - ctx.stroke() + ctx.fillRect(rightX + rightWidth - 30, bottomAccentY - 10, 30, 22) return canvas } @@ -344,11 +668,25 @@ const ensureImagesGenerated = async (): Promise => { return getCachedImages() ?? syncPreviewImages() } +const fetchDefaultTerminal = async () => { + terminalLoading.value = true + terminalError.value = null + + try { + const terminals = await getPrintTerminals() + defaultTerminal.value = terminals.find((item) => item.is_default) || null + } catch (error: any) { + console.error('获取打印终端失败:', error) + terminalError.value = error?.message || '获取打印终端失败' + defaultTerminal.value = null + } finally { + terminalLoading.value = false + } +} + const fetchEquipment = async () => { loading.value = true try { - await siteStore.fetchSiteConfig() - const response = await getEquipmentBatchForBarcode(props.equipmentIds) const data = response.data || response @@ -363,6 +701,42 @@ const fetchEquipment = async () => { } } +const handleRemotePrint = async () => { + if (equipmentList.value.length === 0) { + alert('没有可发送的标签') + return + } + + if (terminalLoading.value) { + alert('正在读取默认打印电脑状态,请稍后再试') + return + } + + if (!defaultTerminal.value) { + alert('当前还没有配置默认打印电脑,请先到系统设置里的“打印终端”完成配对') + return + } + + if (defaultTerminal.value.status === 'disabled') { + alert('默认打印电脑已停用,暂时不能接收远程打印任务') + return + } + + remoteSubmitting.value = true + try { + const result = await createAssetLabelPrintJob(props.equipmentIds) + + alert(result.queue_notice || `标签已发送到 ${defaultTerminal.value.name}`) + + await fetchDefaultTerminal() + } catch (error: any) { + console.error('发送远程打印任务失败:', error) + alert(error?.message || '发送到办公室打印失败') + } finally { + remoteSubmitting.value = false + } +} + const handlePrint = async () => { if (equipmentList.value.length === 0) { alert('没有可打印的二维码') @@ -470,14 +844,15 @@ const handleDownload = async () => { onMounted(() => { fetchEquipment() + fetchDefaultTerminal() }) diff --git a/frontend/src/components/barcodeLayout.ts b/frontend/src/components/barcodeLayout.ts new file mode 100644 index 0000000..033f392 --- /dev/null +++ b/frontend/src/components/barcodeLayout.ts @@ -0,0 +1,292 @@ +export interface MeasureTextFn { + (text: string, fontSize: number): number +} + +export interface FitSingleLineOptions { + text: string + maxWidth: number + maxFontSize: number + minFontSize: number + allowTruncate?: boolean + ellipsis?: string +} + +export interface FitMultilineOptions { + text: string + maxWidth: number + maxLines: number + maxFontSize: number + minFontSize: number + ellipsis?: string +} + +export interface FittedSingleLineText { + text: string + fontSize: number + truncated: boolean +} + +export interface FittedMultilineText { + lines: string[] + fontSize: number + truncated: boolean +} + +export interface IndustrialCardDisplayInput { + companyName: string + equipmentCode: string + equipmentType?: string | null + purchaseDate?: string | null + organizationName?: string | null +} + +export interface IndustrialCardDisplayModel { + companyName: string + codeLines: string[] + typeText: string + purchaseDateText: string + organizationText: string +} + +const DEFAULT_ELLIPSIS = '...' + +const normalizeText = (text: string) => { + const normalized = text.replace(/\s+/g, ' ').trim() + return normalized || '-' +} + +const formatDisplayDate = (value?: string | null) => { + const raw = String(value == null ? '' : value).trim() + if (!raw) { + return '' + } + + const normalized = raw.replace(/\s+/g, ' ') + const matchedDate = + normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/) || + normalized.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})/) + + if (matchedDate) { + const [, year, month, day] = matchedDate + return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}` + } + + return normalized +} + +const truncateToWidth = ( + text: string, + maxWidth: number, + fontSize: number, + measureText: MeasureTextFn, + ellipsis = DEFAULT_ELLIPSIS +) => { + if (measureText(text, fontSize) <= maxWidth) { + return text + } + + if (measureText(ellipsis, fontSize) > maxWidth) { + return ellipsis + } + + let visible = text + while (visible.length > 0) { + const candidate = `${visible.trimEnd()}${ellipsis}` + if (measureText(candidate, fontSize) <= maxWidth) { + return candidate + } + visible = visible.slice(0, -1) + } + + return ellipsis +} + +const wrapTextWithoutTruncation = ( + text: string, + maxWidth: number, + maxLines: number, + fontSize: number, + measureText: MeasureTextFn +) => { + const normalized = normalizeText(text) + const lines: string[] = [] + let cursor = 0 + + while (cursor < normalized.length && lines.length < maxLines) { + while (normalized[cursor] === ' ') { + cursor += 1 + } + + let line = '' + + while (cursor < normalized.length) { + const nextCharacter = normalized[cursor] + const candidate = `${line}${nextCharacter}` + + if (line && measureText(candidate, fontSize) > maxWidth) { + break + } + + line = candidate + cursor += 1 + + if (measureText(line, fontSize) > maxWidth) { + line = line.slice(0, -1) + cursor -= 1 + break + } + } + + const normalizedLine = line.trim() + if (normalizedLine) { + lines.push(normalizedLine) + continue + } + + if (cursor < normalized.length) { + lines.push(normalized[cursor]) + cursor += 1 + } + } + + return { + lines, + overflow: cursor < normalized.length + } +} + +export const splitCodeForDisplay = (code: string) => { + const normalized = normalizeText(code) + + if (normalized.length <= 14) { + return [normalized] + } + + const separatorIndexes = Array.from(normalized.matchAll(/[-/]/g)).map((match) => match.index ?? -1) + const middleIndex = normalized.length / 2 + + if (separatorIndexes.length > 0) { + let bestIndex = separatorIndexes[0] + let bestDistance = Math.abs(bestIndex - middleIndex) + + for (const index of separatorIndexes) { + const distance = Math.abs(index - middleIndex) + if (distance < bestDistance) { + bestIndex = index + bestDistance = distance + } + } + + const first = normalized.slice(0, bestIndex + 1).trim() + const second = normalized.slice(bestIndex + 1).trim() + + if (first && second) { + return [first, second] + } + } + + const midpoint = Math.ceil(normalized.length / 2) + return [normalized.slice(0, midpoint), normalized.slice(midpoint)] +} + +export const buildIndustrialCardDisplay = ( + input: IndustrialCardDisplayInput +): IndustrialCardDisplayModel => ({ + companyName: normalizeText(input.companyName), + codeLines: splitCodeForDisplay(input.equipmentCode), + typeText: normalizeText(input.equipmentType || '未分类资产'), + purchaseDateText: formatDisplayDate(input.purchaseDate), + organizationText: normalizeText(input.organizationName || '未分配组织') +}) + +export const fitSingleLineText = ( + options: FitSingleLineOptions, + measureText: MeasureTextFn +): FittedSingleLineText => { + const { + maxWidth, + maxFontSize, + minFontSize, + allowTruncate = false, + ellipsis = DEFAULT_ELLIPSIS + } = options + + const text = normalizeText(options.text) + + for (let fontSize = maxFontSize; fontSize >= minFontSize; fontSize -= 1) { + if (measureText(text, fontSize) <= maxWidth) { + return { + text, + fontSize, + truncated: false + } + } + } + + if (!allowTruncate) { + return { + text, + fontSize: minFontSize, + truncated: false + } + } + + const truncatedText = truncateToWidth(text, maxWidth, minFontSize, measureText, ellipsis) + + return { + text: truncatedText, + fontSize: minFontSize, + truncated: truncatedText !== text + } +} + +export const fitMultilineText = ( + options: FitMultilineOptions, + measureText: MeasureTextFn +): FittedMultilineText => { + const { + text, + maxWidth, + maxLines, + maxFontSize, + minFontSize, + ellipsis = DEFAULT_ELLIPSIS + } = options + + for (let fontSize = maxFontSize; fontSize >= minFontSize; fontSize -= 1) { + const wrapped = wrapTextWithoutTruncation(text, maxWidth, maxLines, fontSize, measureText) + + if (!wrapped.overflow) { + return { + lines: wrapped.lines, + fontSize, + truncated: false + } + } + } + + const wrappedAtMin = wrapTextWithoutTruncation(text, maxWidth, maxLines, minFontSize, measureText) + const lines = [...wrappedAtMin.lines] + const normalizedText = normalizeText(text) + + if (wrappedAtMin.overflow) { + const consumedText = lines.join('') + const remainingText = normalizedText.slice(consumedText.length) + const lastLineIndex = Math.max(0, Math.min(lines.length, maxLines) - 1) + const lastLineSource = `${lines[lastLineIndex] || ''}${remainingText}` + + lines[lastLineIndex] = truncateToWidth( + lastLineSource, + maxWidth, + minFontSize, + measureText, + ellipsis + ) + } + + return { + lines: lines.slice(0, maxLines), + fontSize: minFontSize, + truncated: wrappedAtMin.overflow + } +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4524939..dcb757c 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -701,3 +701,153 @@ export interface NotificationItem { related_sync_job_id?: number | null metadata?: NotificationMetadata | null } + +export type PrintTerminalStatus = 'online' | 'offline' | 'disabled' +export type PrintJobType = 'asset_label' +export type PrintJobStatus = + | 'queued' + | 'claimed' + | 'printing' + | 'completed' + | 'failed' + | 'cancelled' + +export interface PrintTerminalPrinter { + name: string + driver_name?: string | null + port_name?: string | null + is_default?: boolean + is_available?: boolean +} + +export interface PrintLabelProfile { + paper_size?: string | null + width_mm?: number | null + height_mm?: number | null + orientation?: string | null + scale_mode?: string | null + scaling?: string | null + configured?: boolean | null + is_ready?: boolean | null + status?: string | null + profile_status?: string | null + message?: string | null +} + +export interface PrintTerminalReadiness { + is_ready: boolean + reason?: string | null + message?: string | null +} + +export interface PrintTerminalRecentFailedJob { + id: number + error_message?: string | null + created_at?: string | null +} + +export interface PrintTerminal { + id: number + name: string + platform?: string | null + machine_name?: string | null + status: PrintTerminalStatus | string + is_default: boolean + last_seen_at?: string | null + selected_printer_name?: string | null + printer_snapshot_json?: string | PrintTerminalPrinter[] | null + printer_snapshot?: PrintTerminalPrinter[] + label_profile_json?: string | PrintLabelProfile | null + label_profile?: PrintLabelProfile | null + readiness?: PrintTerminalReadiness | null + paired_at?: string | null + created_at?: string | null + updated_at?: string | null + latest_error_message?: string | null + current_error_message?: string | null + recent_failed_job?: PrintTerminalRecentFailedJob | null +} + +export interface PrintJobPayload { + equipment_ids?: number[] + equipment_codes?: string[] + copies?: number + [key: string]: unknown +} + +export interface PrintJob { + id: number + job_type: PrintJobType | string + status: PrintJobStatus | string + target_terminal_id?: number | null + target_terminal_name?: string | null + claimed_by_terminal_id?: number | null + claimed_by_terminal_name?: string | null + requested_by?: number | string | null + requested_by_name?: string | null + payload_json?: string | PrintJobPayload | null + payload?: PrintJobPayload | null + error_message?: string | null + attempt_count?: number + claimed_at?: string | null + started_at?: string | null + completed_at?: string | null + created_at?: string | null + updated_at?: string | null +} + +export interface PrintJobListQuery { + page?: number + pageSize?: number + status?: PrintJobStatus | '' +} + +export interface PrintJobListData extends PaginatedResponse {} + +export interface CreateAssetLabelPrintJobPayload { + equipment_ids: number[] +} + +export interface CreateAssetLabelPrintJobResult { + job: PrintJob + queue_notice?: string | null +} + +export interface UpdatePrintTerminalStatusPayload { + status: PrintTerminalStatus +} + +export interface PrintTerminalPairCode { + code: string + expires_at: string + created_at?: string | null + used_at?: string | null + used_by_terminal_id?: number | null +} + +export interface PrintAgentReleaseInfo { + version: string + published_at?: string | null + filename?: string | null + notes?: string | string[] | null + download_url?: string | null +} + +export interface PrintDriverLatestInfo { + title: string + official_url: string + checked_at?: string | null + notes?: string | string[] | null + source_type?: string | null +} + +export interface PrinterDriverRelease { + version: string + published_at?: string | null + filename?: string | null + notes?: string | string[] | null + download_url?: string | null + source_type?: string | null + source_url?: string | null + source_path?: string | null +} diff --git a/frontend/src/views/settings/Settings.vue b/frontend/src/views/settings/Settings.vue index 49f0993..3396587 100644 --- a/frontend/src/views/settings/Settings.vue +++ b/frontend/src/views/settings/Settings.vue @@ -45,6 +45,8 @@ + +
@@ -63,6 +65,8 @@ import DepartmentManagement from './components/DepartmentManagement.vue' import MaintenanceManagement from './components/MaintenanceManagement.vue' import DisposalManagement from './components/DisposalManagement.vue' import OperationLogList from './components/OperationLogList.vue' +import PrintTerminalManagement from './components/PrintTerminalManagement.vue' +import PrintJobManagement from './components/PrintJobManagement.vue' type TabKey = | 'site' @@ -74,6 +78,8 @@ type TabKey = | 'departments' | 'maintenance' | 'disposal' + | 'printTerminals' + | 'printJobs' | 'logs' const tabs: Array<{ key: TabKey; label: string }> = [ @@ -86,6 +92,8 @@ const tabs: Array<{ key: TabKey; label: string }> = [ { key: 'departments', label: '组织架构' }, { key: 'maintenance', label: '维修管理' }, { key: 'disposal', label: '报废管理' }, + { key: 'printTerminals', label: '打印终端' }, + { key: 'printJobs', label: '打印任务' }, { key: 'logs', label: '操作日志' } ] diff --git a/frontend/src/views/settings/components/PrintJobManagement.vue b/frontend/src/views/settings/components/PrintJobManagement.vue new file mode 100644 index 0000000..d8ed771 --- /dev/null +++ b/frontend/src/views/settings/components/PrintJobManagement.vue @@ -0,0 +1,318 @@ + + + diff --git a/frontend/src/views/settings/components/PrintTerminalManagement.vue b/frontend/src/views/settings/components/PrintTerminalManagement.vue new file mode 100644 index 0000000..be47ef6 --- /dev/null +++ b/frontend/src/views/settings/components/PrintTerminalManagement.vue @@ -0,0 +1,635 @@ + + + + + diff --git a/print-agent-dotnet.sln b/print-agent-dotnet.sln new file mode 100644 index 0000000..1541f28 --- /dev/null +++ b/print-agent-dotnet.sln @@ -0,0 +1,27 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "print-agent-dotnet", "print-agent-dotnet", "{104ABBBD-0B0E-4A5D-AA4C-0EEC9C7B9C0E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FixedAssetPrintAgent", "print-agent-dotnet\FixedAssetPrintAgent\FixedAssetPrintAgent.csproj", "{A6C9F521-1C71-4817-AE56-1A531FE8CAD1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A6C9F521-1C71-4817-AE56-1A531FE8CAD1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6C9F521-1C71-4817-AE56-1A531FE8CAD1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6C9F521-1C71-4817-AE56-1A531FE8CAD1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6C9F521-1C71-4817-AE56-1A531FE8CAD1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {A6C9F521-1C71-4817-AE56-1A531FE8CAD1} = {104ABBBD-0B0E-4A5D-AA4C-0EEC9C7B9C0E} + EndGlobalSection +EndGlobal diff --git a/print-agent-dotnet/FixedAssetPrintAgent/FixedAssetPrintAgent.csproj b/print-agent-dotnet/FixedAssetPrintAgent/FixedAssetPrintAgent.csproj new file mode 100644 index 0000000..4360c99 --- /dev/null +++ b/print-agent-dotnet/FixedAssetPrintAgent/FixedAssetPrintAgent.csproj @@ -0,0 +1,25 @@ + + + + WinExe + net8.0-windows + enable + true + enable + 0.2.0 + 0.2.0.0 + 0.2.0.0 + 常州简尚家具有限公司 + 固定资产打印助手 + none + false + true + true + true + + + + + + + diff --git a/print-agent-dotnet/FixedAssetPrintAgent/LabelPrintService.cs b/print-agent-dotnet/FixedAssetPrintAgent/LabelPrintService.cs new file mode 100644 index 0000000..2e8138c --- /dev/null +++ b/print-agent-dotnet/FixedAssetPrintAgent/LabelPrintService.cs @@ -0,0 +1,365 @@ +using System.Drawing.Drawing2D; +using System.Drawing.Printing; +using System.Drawing.Text; +using QRCoder; + +namespace FixedAssetPrintAgent; + +internal sealed class LabelPrintService +{ + private const string CompanyName = "常州简尚家具有限公司"; + private const int CanvasWidth = 480; + private const int CanvasHeight = 320; + private const int PaperWidthHundredthsOfInch = 236; + private const int PaperHeightHundredthsOfInch = 157; + + public List ListPrinters() + { + var defaultPrinter = string.Empty; + try + { + var settings = new PrinterSettings(); + defaultPrinter = settings.PrinterName; + } + catch + { + defaultPrinter = string.Empty; + } + + return PrinterSettings.InstalledPrinters + .Cast() + .Select(name => new PrinterInfo + { + Name = name, + IsDefault = string.Equals(name, defaultPrinter, StringComparison.OrdinalIgnoreCase), + IsAvailable = true + }) + .OrderByDescending(item => item.IsDefault) + .ThenBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + public Task PrintAssetLabelsAsync(string printerName, IReadOnlyList items) + { + return Task.Run(() => + { + var pages = items.Select(RenderAssetLabel).ToList(); + try + { + PrintBitmaps(printerName, pages, "固定资产标签打印"); + return pages.Count; + } + finally + { + foreach (var page in pages) + { + page.Dispose(); + } + } + }); + } + + public Task PrintTestLabelAsync(string printerName) + { + return Task.Run(() => + { + using var bitmap = RenderAssetLabel(new AssetLabelItem + { + EquipmentCode = "TEST-60X40", + EquipmentName = "测试标签", + EquipmentType = "测试标签", + PurchaseDate = DateTimeOffset.Now.ToString("yyyy-MM-dd"), + Department = new DepartmentInfo + { + Name = $"测试打印 · {printerName}" + } + }); + + PrintBitmaps(printerName, [bitmap], "固定资产测试标签"); + }); + } + + private static Bitmap RenderAssetLabel(AssetLabelItem item) + { + var bitmap = new Bitmap(CanvasWidth, CanvasHeight); + bitmap.SetResolution(203, 203); + + using var graphics = Graphics.FromImage(bitmap); + graphics.Clear(Color.White); + graphics.SmoothingMode = SmoothingMode.HighQuality; + graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; + graphics.TextRenderingHint = TextRenderingHint.SingleBitPerPixelGridFit; + + using var outerPen = new Pen(Color.Black, 4); + graphics.DrawRectangle(outerPen, 2, 2, CanvasWidth - 4, CanvasHeight - 4); + + DrawHeader(graphics); + DrawQrSection(graphics, item); + DrawInfoSection(graphics, item); + + return bitmap; + } + + private static void DrawHeader(Graphics graphics) + { + var headerRect = new Rectangle(0, 0, CanvasWidth, 54); + using var fillBrush = new SolidBrush(Color.Black); + graphics.FillRectangle(fillBrush, headerRect); + + using var companyFont = new Font("Microsoft YaHei", 22, FontStyle.Bold, GraphicsUnit.Pixel); + using var whiteBrush = new SolidBrush(Color.White); + using var companyFormat = new StringFormat + { + Alignment = StringAlignment.Near, + LineAlignment = StringAlignment.Center, + Trimming = StringTrimming.EllipsisCharacter, + FormatFlags = StringFormatFlags.NoWrap + }; + graphics.DrawString(CompanyName, companyFont, whiteBrush, new RectangleF(18, 0, 350, 54), companyFormat); + + graphics.FillRectangle(whiteBrush, new Rectangle(414, 16, 18, 18)); + graphics.FillRectangle(whiteBrush, new Rectangle(440, 16, 5, 18)); + } + + private static void DrawQrSection(Graphics graphics, AssetLabelItem item) + { + var panelRect = new Rectangle(14, 66, 184, 184); + using var framePen = new Pen(Color.Black, 2); + graphics.DrawRectangle(framePen, panelRect); + + var innerRect = Rectangle.Inflate(panelRect, -10, -10); + graphics.DrawRectangle(framePen, innerRect); + + using var qrBitmap = BuildQrBitmap(item.EquipmentCode); + var qrRect = Rectangle.Inflate(innerRect, -10, -10); + graphics.DrawImage(qrBitmap, qrRect); + } + + private static void DrawInfoSection(Graphics graphics, AssetLabelItem item) + { + var startX = 212; + var contentWidth = 252; + var typeText = NormalizeText(item.EquipmentType, "未分类资产"); + var codeLines = SplitCodeForDisplay(item.EquipmentCode); + var purchaseDate = FormatLabelDate(item.PurchaseDate); + var organization = NormalizeText(item.Department?.Name, "未分配组织"); + + using var blackBrush = new SolidBrush(Color.Black); + using var whiteBrush = new SolidBrush(Color.White); + using var typeFont = new Font("Bahnschrift", 18, FontStyle.Bold, GraphicsUnit.Pixel); + using var codeFont = new Font("Bahnschrift", 34, FontStyle.Bold, GraphicsUnit.Pixel); + using var captionFont = new Font("Bahnschrift", 13, FontStyle.Bold, GraphicsUnit.Pixel); + using var valueFont = new Font("Microsoft YaHei", 18, FontStyle.Bold, GraphicsUnit.Pixel); + using var orgFont = new Font("Microsoft YaHei", 17, FontStyle.Bold, GraphicsUnit.Pixel); + using var noWrapFormat = new StringFormat + { + Alignment = StringAlignment.Near, + LineAlignment = StringAlignment.Center, + Trimming = StringTrimming.EllipsisCharacter, + FormatFlags = StringFormatFlags.NoWrap + }; + + var typeWidth = Math.Min(contentWidth, Math.Max(92, (int)Math.Ceiling(graphics.MeasureString(typeText, typeFont).Width) + 24)); + var typeRect = new Rectangle(startX, 70, typeWidth, 34); + graphics.FillRectangle(blackBrush, typeRect); + graphics.DrawString(typeText, typeFont, whiteBrush, typeRect, noWrapFormat); + + var codeY = 114; + foreach (var line in codeLines.Take(2)) + { + graphics.DrawString(line, codeFont, blackBrush, new RectangleF(startX, codeY, contentWidth, 38), noWrapFormat); + codeY += 34; + } + + graphics.FillRectangle(blackBrush, new Rectangle(startX, 184, contentWidth - 4, 3)); + + DrawCaptionWithValue( + graphics, + startX, + 198, + "采购日期", + string.IsNullOrWhiteSpace(purchaseDate) ? "-" : purchaseDate, + captionFont, + valueFont); + + DrawCaptionWithMultilineValue( + graphics, + startX, + 242, + "所属组织", + organization, + captionFont, + orgFont, + contentWidth, + 44); + + graphics.FillRectangle(blackBrush, new Rectangle(startX, 296, 180, 3)); + graphics.FillRectangle(blackBrush, new Rectangle(startX + 188, 287, 26, 12)); + } + + private static void DrawCaptionWithValue( + Graphics graphics, + int x, + int y, + string caption, + string value, + Font captionFont, + Font valueFont) + { + using var blackBrush = new SolidBrush(Color.Black); + using var whiteBrush = new SolidBrush(Color.White); + using var captionFormat = new StringFormat + { + Alignment = StringAlignment.Center, + LineAlignment = StringAlignment.Center, + FormatFlags = StringFormatFlags.NoWrap + }; + using var valueFormat = new StringFormat + { + Alignment = StringAlignment.Near, + LineAlignment = StringAlignment.Center, + Trimming = StringTrimming.EllipsisCharacter, + FormatFlags = StringFormatFlags.NoWrap + }; + + var captionRect = new Rectangle(x, y, 82, 24); + graphics.FillRectangle(blackBrush, captionRect); + graphics.DrawString(caption, captionFont, whiteBrush, captionRect, captionFormat); + graphics.DrawString(value, valueFont, blackBrush, new RectangleF(x, y + 24, 210, 28), valueFormat); + } + + private static void DrawCaptionWithMultilineValue( + Graphics graphics, + int x, + int y, + string caption, + string value, + Font captionFont, + Font valueFont, + int maxWidth, + int maxHeight) + { + using var blackBrush = new SolidBrush(Color.Black); + using var whiteBrush = new SolidBrush(Color.White); + using var captionFormat = new StringFormat + { + Alignment = StringAlignment.Center, + LineAlignment = StringAlignment.Center, + FormatFlags = StringFormatFlags.NoWrap + }; + using var valueFormat = new StringFormat + { + Alignment = StringAlignment.Near, + LineAlignment = StringAlignment.Near, + Trimming = StringTrimming.EllipsisCharacter + }; + + var captionRect = new Rectangle(x, y, 82, 24); + graphics.FillRectangle(blackBrush, captionRect); + graphics.DrawString(caption, captionFont, whiteBrush, captionRect, captionFormat); + graphics.DrawString(value, valueFont, blackBrush, new RectangleF(x, y + 24, maxWidth, maxHeight), valueFormat); + } + + private static Bitmap BuildQrBitmap(string text) + { + using var generator = new QRCodeGenerator(); + using var data = generator.CreateQrCode(NormalizeText(text, "UNKNOWN"), QRCodeGenerator.ECCLevel.Q); + using var qrCode = new QRCode(data); + return qrCode.GetGraphic(20, Color.Black, Color.White, true); + } + + private static void PrintBitmaps(string printerName, IReadOnlyList pages, string documentName) + { + if (string.IsNullOrWhiteSpace(printerName)) + { + throw new InvalidOperationException("尚未选择标签打印机,请先在助手里保存打印机。"); + } + + using var document = new PrintDocument(); + document.DocumentName = documentName; + document.PrinterSettings.PrinterName = printerName; + + if (!document.PrinterSettings.IsValid) + { + throw new InvalidOperationException($"找不到打印机:{printerName}"); + } + + document.DefaultPageSettings.Margins = new Margins(0, 0, 0, 0); + document.DefaultPageSettings.PaperSize = new PaperSize("AssetLabel60x40", PaperWidthHundredthsOfInch, PaperHeightHundredthsOfInch); + document.DefaultPageSettings.Landscape = false; + document.OriginAtMargins = false; + document.PrintController = new StandardPrintController(); + + var pageIndex = 0; + document.PrintPage += (_, eventArgs) => + { + var page = pages[pageIndex]; + var graphics = eventArgs.Graphics; + if (graphics is null) + { + throw new InvalidOperationException("当前打印驱动没有返回可用的绘图上下文。"); + } + + graphics.PageUnit = GraphicsUnit.Display; + graphics.Clear(Color.White); + graphics.DrawImage(page, eventArgs.PageBounds); + pageIndex += 1; + eventArgs.HasMorePages = pageIndex < pages.Count; + }; + + document.Print(); + } + + private static string NormalizeText(string? value, string fallback) + { + var normalized = string.IsNullOrWhiteSpace(value) + ? string.Empty + : string.Join(" ", value.Split(['\r', '\n', '\t', ' '], StringSplitOptions.RemoveEmptyEntries)); + return string.IsNullOrWhiteSpace(normalized) ? fallback : normalized; + } + + private static string FormatLabelDate(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + var normalized = value.Trim().Replace('/', '-'); + return normalized.Length >= 10 ? normalized[..10] : normalized; + } + + private static List SplitCodeForDisplay(string? value) + { + var normalized = NormalizeText(value, "-"); + if (normalized.Length <= 14) + { + return [normalized]; + } + + var separatorIndexes = normalized + .Select((character, index) => (character, index)) + .Where(item => item.character is '-' or '/') + .Select(item => item.index) + .ToList(); + + if (separatorIndexes.Count > 0) + { + var middleIndex = normalized.Length / 2.0; + var bestIndex = separatorIndexes + .OrderBy(index => Math.Abs(index - middleIndex)) + .First(); + + var first = normalized[..(bestIndex + 1)].Trim(); + var second = normalized[(bestIndex + 1)..].Trim(); + if (!string.IsNullOrWhiteSpace(first) && !string.IsNullOrWhiteSpace(second)) + { + return [first, second]; + } + } + + var midpoint = (int)Math.Ceiling(normalized.Length / 2.0); + return [normalized[..midpoint], normalized[midpoint..]]; + } +} diff --git a/print-agent-dotnet/FixedAssetPrintAgent/MainForm.cs b/print-agent-dotnet/FixedAssetPrintAgent/MainForm.cs new file mode 100644 index 0000000..175feb0 --- /dev/null +++ b/print-agent-dotnet/FixedAssetPrintAgent/MainForm.cs @@ -0,0 +1,511 @@ +using System.Diagnostics; + +namespace FixedAssetPrintAgent; + +internal sealed class MainForm : Form +{ + private readonly PrintAgentRuntime _runtime; + private readonly TextBox _serverUrlTextBox = CreateTextBox(); + private readonly TextBox _terminalNameTextBox = CreateTextBox(); + private readonly TextBox _pairCodeTextBox = CreateTextBox(); + private readonly ComboBox _printerComboBox = new() { Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDownList }; + private readonly CheckBox _runAtStartupCheckBox = new() { AutoSize = true, Text = "开机自动启动打印助手" }; + + private readonly Label _pairingStatusValue = CreateValueLabel(); + private readonly Label _selectedPrinterValue = CreateValueLabel(); + private readonly Label _lastHeartbeatValue = CreateValueLabel(); + private readonly Label _lastClaimValue = CreateValueLabel(); + private readonly Label _lastJobValue = CreateValueLabel(); + private readonly Label _versionValue = CreateValueLabel(); + + private readonly Button _pairButton = CreateActionButton("完成配对"); + private readonly Button _saveButton = CreateActionButton("保存配置"); + private readonly Button _refreshPrintersButton = CreateSecondaryButton("刷新打印机"); + private readonly Button _testPrintButton = CreateActionButton("测试打印"); + private readonly Button _heartbeatButton = CreateSecondaryButton("立即心跳"); + private readonly Button _claimButton = CreateSecondaryButton("立即轮询"); + private readonly Button _refreshDownloadsButton = CreateSecondaryButton("刷新下载信息"); + private readonly Button _downloadLatestAgentButton = CreateSecondaryButton("打开最新助手下载"); + private readonly Button _officialDriverButton = CreateSecondaryButton("打开官方驱动页"); + private readonly Button _archiveDriverButton = CreateSecondaryButton("下载归档版驱动"); + + private readonly RichTextBox _logBox = new() + { + Dock = DockStyle.Fill, + ReadOnly = true, + BorderStyle = BorderStyle.None, + BackColor = Color.FromArgb(18, 18, 18), + ForeColor = Color.Gainsboro, + Font = new Font("Consolas", 10.5F) + }; + + private string? _latestAgentDownloadUrl; + private string? _officialDriverUrl; + private string? _latestArchiveDownloadUrl; + + public MainForm(PrintAgentRuntime runtime) + { + _runtime = runtime; + _runtime.StateChanged += HandleRuntimeStateChanged; + + Text = "固定资产打印助手 (.NET 8)"; + StartPosition = FormStartPosition.CenterScreen; + MinimumSize = new Size(980, 760); + Size = new Size(1080, 820); + BackColor = Color.FromArgb(32, 32, 32); + ForeColor = Color.White; + + BuildLayout(); + WireEvents(); + } + + private void BuildLayout() + { + var root = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 4, + Padding = new Padding(12), + AutoScroll = true + }; + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + root.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + + root.Controls.Add(CreateBannerPanel(), 0, 0); + root.Controls.Add(CreateStatusGroup(), 0, 1); + root.Controls.Add(CreateConfigurationGroup(), 0, 2); + root.Controls.Add(CreateBottomSection(), 0, 3); + + Controls.Add(root); + } + + private Control CreateBannerPanel() + { + var panel = new Panel + { + Dock = DockStyle.Top, + Height = 92, + Padding = new Padding(16), + BackColor = Color.FromArgb(22, 22, 22) + }; + + var title = new Label + { + AutoSize = true, + Text = "固定资产打印助手", + Font = new Font("Microsoft YaHei", 18, FontStyle.Bold), + ForeColor = Color.White + }; + + var desc = new Label + { + AutoSize = true, + MaximumSize = new Size(900, 0), + Top = 42, + Text = "这台电脑会在后台常驻,负责接收固定资产系统发来的标签打印任务,绕过浏览器打印弹窗。", + Font = new Font("Microsoft YaHei", 9.5F), + ForeColor = Color.Gainsboro + }; + + panel.Controls.Add(title); + panel.Controls.Add(desc); + return panel; + } + + private Control CreateStatusGroup() + { + var group = CreateGroupBox("运行状态"); + var table = CreateTwoColumnTable(); + AddStatusRow(table, "配对状态", _pairingStatusValue, 0); + AddStatusRow(table, "当前打印机", _selectedPrinterValue, 1); + AddStatusRow(table, "最后心跳", _lastHeartbeatValue, 2); + AddStatusRow(table, "最后轮询", _lastClaimValue, 3); + AddStatusRow(table, "最近结果", _lastJobValue, 4); + AddStatusRow(table, "版本检查", _versionValue, 5); + group.Controls.Add(table); + return group; + } + + private Control CreateConfigurationGroup() + { + var group = CreateGroupBox("接入与打印配置"); + var layout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 4, + RowCount = 5, + Padding = new Padding(12), + AutoSize = true + }; + layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 108)); + layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + layout.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 108)); + layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 50)); + + layout.Controls.Add(CreateFieldLabel("服务器地址"), 0, 0); + layout.Controls.Add(_serverUrlTextBox, 1, 0); + layout.SetColumnSpan(_serverUrlTextBox, 3); + + layout.Controls.Add(CreateFieldLabel("终端名称"), 0, 1); + layout.Controls.Add(_terminalNameTextBox, 1, 1); + layout.Controls.Add(CreateFieldLabel("一次性配对码"), 2, 1); + layout.Controls.Add(_pairCodeTextBox, 3, 1); + + layout.Controls.Add(CreateFieldLabel("标签打印机"), 0, 2); + layout.Controls.Add(_printerComboBox, 1, 2); + layout.Controls.Add(_refreshPrintersButton, 2, 2); + layout.Controls.Add(_saveButton, 3, 2); + + var optionPanel = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.LeftToRight, + AutoSize = true, + WrapContents = true + }; + optionPanel.Controls.Add(_runAtStartupCheckBox); + layout.Controls.Add(optionPanel, 0, 3); + layout.SetColumnSpan(optionPanel, 2); + + var pairActionPanel = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.LeftToRight, + AutoSize = true + }; + pairActionPanel.Controls.Add(_pairButton); + layout.Controls.Add(pairActionPanel, 2, 3); + layout.SetColumnSpan(pairActionPanel, 2); + + var runtimeActionPanel = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + FlowDirection = FlowDirection.LeftToRight, + AutoSize = true, + WrapContents = true + }; + runtimeActionPanel.Controls.Add(_heartbeatButton); + runtimeActionPanel.Controls.Add(_claimButton); + runtimeActionPanel.Controls.Add(_testPrintButton); + runtimeActionPanel.Controls.Add(_refreshDownloadsButton); + layout.Controls.Add(runtimeActionPanel, 0, 4); + layout.SetColumnSpan(runtimeActionPanel, 4); + + group.Controls.Add(layout); + return group; + } + + private Control CreateBottomSection() + { + var splitter = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 1, + AutoSize = true + }; + splitter.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 42)); + splitter.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 58)); + + splitter.Controls.Add(CreateDownloadGroup(), 0, 0); + splitter.Controls.Add(CreateLogGroup(), 1, 0); + return splitter; + } + + private Control CreateDownloadGroup() + { + var group = CreateGroupBox("下载与驱动帮助"); + var layout = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 1, + RowCount = 4, + Padding = new Padding(12), + AutoSize = true + }; + + layout.Controls.Add(CreateDescriptionLabel("打印助手安装包、芯烨官方驱动入口和公司归档驱动都从固定资产服务器读取。"), 0, 0); + layout.Controls.Add(_downloadLatestAgentButton, 0, 1); + layout.Controls.Add(_officialDriverButton, 0, 2); + layout.Controls.Add(_archiveDriverButton, 0, 3); + group.Controls.Add(layout); + return group; + } + + private Control CreateLogGroup() + { + var group = CreateGroupBox("运行日志"); + group.Controls.Add(_logBox); + return group; + } + + private void WireEvents() + { + _pairButton.Click += async (_, _) => + await RunButtonActionAsync(_pairButton, async () => + { + await _runtime.PairAsync( + _serverUrlTextBox.Text, + _terminalNameTextBox.Text, + _pairCodeTextBox.Text); + _pairCodeTextBox.Clear(); + }); + + _saveButton.Click += async (_, _) => + await RunButtonActionAsync(_saveButton, () => _runtime.SaveSettingsAsync( + _serverUrlTextBox.Text, + _terminalNameTextBox.Text, + _printerComboBox.SelectedItem?.ToString() ?? string.Empty, + _runAtStartupCheckBox.Checked)); + + _refreshPrintersButton.Click += async (_, _) => + await RunButtonActionAsync(_refreshPrintersButton, () => _runtime.RefreshPrintersAsync()); + + _testPrintButton.Click += async (_, _) => + await RunButtonActionAsync(_testPrintButton, () => _runtime.TestPrintAsync()); + + _heartbeatButton.Click += async (_, _) => + await RunButtonActionAsync(_heartbeatButton, () => _runtime.HeartbeatNowAsync()); + + _claimButton.Click += async (_, _) => + await RunButtonActionAsync(_claimButton, () => _runtime.ClaimNowAsync()); + + _refreshDownloadsButton.Click += async (_, _) => + await RunButtonActionAsync(_refreshDownloadsButton, () => _runtime.RefreshRemoteResourcesAsync()); + + _downloadLatestAgentButton.Click += (_, _) => OpenUrl(_latestAgentDownloadUrl); + _officialDriverButton.Click += (_, _) => OpenUrl(_officialDriverUrl); + _archiveDriverButton.Click += (_, _) => OpenUrl(_latestArchiveDownloadUrl); + } + + private void HandleRuntimeStateChanged(object? sender, PrintAgentRuntimeState state) + { + if (IsDisposed) + { + return; + } + + if (InvokeRequired) + { + BeginInvoke(new Action(() => ApplyState(state))); + return; + } + + ApplyState(state); + } + + private void ApplyState(PrintAgentRuntimeState state) + { + if (!_serverUrlTextBox.Focused) + { + _serverUrlTextBox.Text = state.Configuration.ServerUrl; + } + + if (!_terminalNameTextBox.Focused) + { + _terminalNameTextBox.Text = state.Configuration.TerminalName; + } + + _runAtStartupCheckBox.Checked = state.Configuration.RunAtStartup; + UpdatePrinterItems(state); + + _pairingStatusValue.Text = state.IsPaired + ? $"已配对,终端编号 {state.Configuration.TerminalId}" + : "尚未配对"; + _selectedPrinterValue.Text = string.IsNullOrWhiteSpace(state.Configuration.SelectedPrinterName) + ? "尚未选择" + : state.Configuration.SelectedPrinterName; + _lastHeartbeatValue.Text = FormatNullable(state.LastHeartbeatAt); + _lastClaimValue.Text = FormatNullable(state.LastClaimAt); + _lastJobValue.Text = !string.IsNullOrWhiteSpace(state.LastError) + ? state.LastError + : FormatNullable(state.LastJobResult); + _versionValue.Text = FormatNullable(state.UpdateStatusMessage); + + _latestAgentDownloadUrl = state.Releases.FirstOrDefault()?.DownloadUrl; + _officialDriverUrl = state.LatestDriver?.OfficialUrl ?? state.LatestDriver?.SourceUrl; + _latestArchiveDownloadUrl = state.ArchivedDrivers.FirstOrDefault()?.DownloadUrl; + + _downloadLatestAgentButton.Enabled = !string.IsNullOrWhiteSpace(_latestAgentDownloadUrl); + _officialDriverButton.Enabled = !string.IsNullOrWhiteSpace(_officialDriverUrl); + _archiveDriverButton.Enabled = !string.IsNullOrWhiteSpace(_latestArchiveDownloadUrl); + + _logBox.Text = string.Join(Environment.NewLine, state.LogLines); + _logBox.SelectionStart = 0; + _logBox.SelectionLength = 0; + } + + private void UpdatePrinterItems(PrintAgentRuntimeState state) + { + var selectedPrinterName = state.Configuration.SelectedPrinterName; + var items = state.Printers.Select(item => item.Name).ToArray(); + + _printerComboBox.BeginUpdate(); + _printerComboBox.Items.Clear(); + _printerComboBox.Items.AddRange(items); + + if (!string.IsNullOrWhiteSpace(selectedPrinterName) && + items.Any(item => string.Equals(item, selectedPrinterName, StringComparison.OrdinalIgnoreCase))) + { + _printerComboBox.SelectedItem = items.First(item => + string.Equals(item, selectedPrinterName, StringComparison.OrdinalIgnoreCase)); + } + else + { + _printerComboBox.SelectedIndex = -1; + } + + _printerComboBox.EndUpdate(); + } + + private async Task RunButtonActionAsync(Button button, Func action) + { + try + { + UseWaitCursor = true; + button.Enabled = false; + await action(); + } + catch (Exception ex) + { + MessageBox.Show(this, ex.Message, "打印助手", MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + finally + { + UseWaitCursor = false; + button.Enabled = true; + } + } + + private static void OpenUrl(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + MessageBox.Show("当前没有可打开的链接。", "打印助手", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + + Process.Start(new ProcessStartInfo + { + FileName = url, + UseShellExecute = true + }); + } + + private static GroupBox CreateGroupBox(string title) + { + return new GroupBox + { + Text = title, + Dock = DockStyle.Top, + AutoSize = true, + ForeColor = Color.White, + Padding = new Padding(10) + }; + } + + private static TableLayoutPanel CreateTwoColumnTable() + { + var table = new TableLayoutPanel + { + Dock = DockStyle.Fill, + ColumnCount = 2, + RowCount = 6, + Padding = new Padding(12), + AutoSize = true + }; + table.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 110)); + table.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + return table; + } + + private static void AddStatusRow(TableLayoutPanel table, string labelText, Control valueControl, int rowIndex) + { + table.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + table.Controls.Add(CreateFieldLabel(labelText), 0, rowIndex); + table.Controls.Add(valueControl, 1, rowIndex); + } + + private static Label CreateFieldLabel(string text) + { + return new Label + { + AutoSize = true, + Text = text, + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + Font = new Font("Microsoft YaHei", 9F, FontStyle.Bold), + ForeColor = Color.Gainsboro + }; + } + + private static Label CreateValueLabel() + { + return new Label + { + AutoSize = true, + MaximumSize = new Size(760, 0), + Dock = DockStyle.Fill, + TextAlign = ContentAlignment.MiddleLeft, + Font = new Font("Microsoft YaHei", 9F), + ForeColor = Color.White + }; + } + + private static TextBox CreateTextBox() + { + return new TextBox + { + Dock = DockStyle.Fill, + BorderStyle = BorderStyle.FixedSingle + }; + } + + private static Button CreateActionButton(string text) + { + return new Button + { + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Text = text, + BackColor = Color.FromArgb(16, 163, 127), + ForeColor = Color.White, + FlatStyle = FlatStyle.Flat, + Padding = new Padding(12, 6, 12, 6) + }; + } + + private static Button CreateSecondaryButton(string text) + { + return new Button + { + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + Text = text, + BackColor = Color.FromArgb(46, 46, 46), + ForeColor = Color.White, + FlatStyle = FlatStyle.Flat, + Padding = new Padding(12, 6, 12, 6) + }; + } + + private static Label CreateDescriptionLabel(string text) + { + return new Label + { + AutoSize = true, + MaximumSize = new Size(420, 0), + Text = text, + ForeColor = Color.Gainsboro, + Font = new Font("Microsoft YaHei", 9F) + }; + } + + private static string FormatNullable(string? value) + { + return string.IsNullOrWhiteSpace(value) ? "未记录" : value; + } +} diff --git a/print-agent-dotnet/FixedAssetPrintAgent/PrintAgentApplicationContext.cs b/print-agent-dotnet/FixedAssetPrintAgent/PrintAgentApplicationContext.cs new file mode 100644 index 0000000..0504b12 --- /dev/null +++ b/print-agent-dotnet/FixedAssetPrintAgent/PrintAgentApplicationContext.cs @@ -0,0 +1,120 @@ +namespace FixedAssetPrintAgent; + +internal sealed class PrintAgentApplicationContext : ApplicationContext +{ + private readonly NotifyIcon _notifyIcon; + private readonly PrintAgentRuntime _runtime; + private readonly MainForm _mainForm; + private bool _exitRequested; + + public PrintAgentApplicationContext() + { + var storageRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "FixedAssetPrintAgent"); + + _runtime = new PrintAgentRuntime(storageRoot, Application.ProductVersion, Application.ExecutablePath); + _runtime.StateChanged += HandleStateChanged; + + _mainForm = new MainForm(_runtime); + _mainForm.FormClosing += HandleMainFormClosing; + + _notifyIcon = new NotifyIcon + { + Icon = SystemIcons.Application, + Visible = true, + Text = "固定资产打印助手" + }; + _notifyIcon.DoubleClick += (_, _) => ShowMainWindow(); + _notifyIcon.ContextMenuStrip = BuildContextMenu(); + + _ = InitializeAsync(); + } + + private async Task InitializeAsync() + { + try + { + await _runtime.InitializeAsync(); + + if (!_runtime.CurrentState.IsPaired) + { + ShowMainWindow(); + } + } + catch (Exception ex) + { + MessageBox.Show( + $"打印助手初始化失败:{ex.Message}", + "打印助手", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + ShowMainWindow(); + } + } + + private ContextMenuStrip BuildContextMenu() + { + var menu = new ContextMenuStrip(); + menu.Items.Add("打开打印助手", null, (_, _) => ShowMainWindow()); + menu.Items.Add("立即发送心跳", null, async (_, _) => await _runtime.HeartbeatNowAsync()); + menu.Items.Add("立即轮询任务", null, async (_, _) => await _runtime.ClaimNowAsync()); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("退出", null, (_, _) => ExitApplication()); + return menu; + } + + private void HandleStateChanged(object? sender, PrintAgentRuntimeState state) + { + var summary = state.IsPaired + ? string.IsNullOrWhiteSpace(state.Configuration.SelectedPrinterName) + ? "待配置打印机" + : "已配对" + : "未配对"; + + var text = $"固定资产打印助手 - {summary}"; + _notifyIcon.Text = text.Length > 63 ? text[..63] : text; + } + + private void HandleMainFormClosing(object? sender, FormClosingEventArgs e) + { + if (_exitRequested) + { + return; + } + + e.Cancel = true; + _mainForm.Hide(); + } + + private void ShowMainWindow() + { + if (_mainForm.Visible) + { + _mainForm.WindowState = FormWindowState.Normal; + _mainForm.Activate(); + return; + } + + _mainForm.Show(); + _mainForm.WindowState = FormWindowState.Normal; + _mainForm.BringToFront(); + _mainForm.Activate(); + } + + private void ExitApplication() + { + _exitRequested = true; + _notifyIcon.Visible = false; + _mainForm.Close(); + ExitThread(); + } + + protected override void ExitThreadCore() + { + _notifyIcon.Visible = false; + _notifyIcon.Dispose(); + _runtime.Dispose(); + base.ExitThreadCore(); + } +} diff --git a/print-agent-dotnet/FixedAssetPrintAgent/PrintAgentModels.cs b/print-agent-dotnet/FixedAssetPrintAgent/PrintAgentModels.cs new file mode 100644 index 0000000..d6ca9f9 --- /dev/null +++ b/print-agent-dotnet/FixedAssetPrintAgent/PrintAgentModels.cs @@ -0,0 +1,286 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FixedAssetPrintAgent; + +internal sealed class AgentConfiguration +{ + public string ServerUrl { get; set; } = "https://www.jsgdzc.cn"; + public string TerminalName { get; set; } = Environment.MachineName; + public string MachineName { get; set; } = Environment.MachineName; + public string Platform { get; set; } = "windows"; + public string DeviceToken { get; set; } = string.Empty; + public int TerminalId { get; set; } + public string SelectedPrinterName { get; set; } = string.Empty; + public int HeartbeatIntervalSeconds { get; set; } = 30; + public int PollIntervalSeconds { get; set; } = 3; + public bool RunAtStartup { get; set; } = true; + public string PairedAt { get; set; } = string.Empty; + + public AgentConfiguration Clone() + { + return new AgentConfiguration + { + ServerUrl = ServerUrl, + TerminalName = TerminalName, + MachineName = MachineName, + Platform = Platform, + DeviceToken = DeviceToken, + TerminalId = TerminalId, + SelectedPrinterName = SelectedPrinterName, + HeartbeatIntervalSeconds = HeartbeatIntervalSeconds, + PollIntervalSeconds = PollIntervalSeconds, + RunAtStartup = RunAtStartup, + PairedAt = PairedAt + }; + } +} + +internal sealed class PrinterInfo +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("driver_name")] + public string? DriverName { get; set; } + + [JsonPropertyName("port_name")] + public string? PortName { get; set; } + + [JsonPropertyName("is_default")] + public bool IsDefault { get; set; } + + [JsonPropertyName("is_available")] + public bool IsAvailable { get; set; } = true; +} + +internal sealed class PrintLabelProfile +{ + [JsonPropertyName("paper_size")] + public string PaperSize { get; set; } = "60x40mm"; + + [JsonPropertyName("width_mm")] + public int WidthMillimeters { get; set; } = 60; + + [JsonPropertyName("height_mm")] + public int HeightMillimeters { get; set; } = 40; + + [JsonPropertyName("orientation")] + public string Orientation { get; set; } = "landscape"; + + [JsonPropertyName("scale_mode")] + public string ScaleMode { get; set; } = "fit"; +} + +internal sealed class DepartmentInfo +{ + [JsonPropertyName("name")] + public string? Name { get; set; } +} + +internal sealed class AssetLabelItem +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("equipment_code")] + public string EquipmentCode { get; set; } = string.Empty; + + [JsonPropertyName("equipment_name")] + public string EquipmentName { get; set; } = string.Empty; + + [JsonPropertyName("equipment_model")] + public string? EquipmentModel { get; set; } + + [JsonPropertyName("equipment_type")] + public string EquipmentType { get; set; } = string.Empty; + + [JsonPropertyName("purchase_date")] + public string? PurchaseDate { get; set; } + + [JsonPropertyName("department")] + public DepartmentInfo? Department { get; set; } +} + +internal sealed class AssetLabelPayload +{ + [JsonPropertyName("items")] + public List Items { get; set; } = []; +} + +internal sealed class PrintJobDto +{ + [JsonPropertyName("id")] + public int Id { get; set; } + + [JsonPropertyName("job_type")] + public string JobType { get; set; } = string.Empty; + + [JsonPropertyName("payload")] + public AssetLabelPayload? Payload { get; set; } + + [JsonPropertyName("payload_json")] + public JsonElement PayloadJson { get; set; } +} + +internal sealed class PrintJobClaimData +{ + [JsonPropertyName("job")] + public PrintJobDto? Job { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } +} + +internal sealed class PairResponse +{ + [JsonPropertyName("terminal_id")] + public int TerminalId { get; set; } + + [JsonPropertyName("device_token")] + public string DeviceToken { get; set; } = string.Empty; + + [JsonPropertyName("paired_at")] + public string? PairedAt { get; set; } + + [JsonPropertyName("is_default")] + public bool IsDefault { get; set; } +} + +internal sealed class PrintAgentReleaseCollection +{ + [JsonPropertyName("latest_version")] + public string? LatestVersion { get; set; } + + [JsonPropertyName("latest_download_url")] + public string? LatestDownloadUrl { get; set; } + + [JsonPropertyName("releases")] + public List Releases { get; set; } = []; +} + +internal sealed class PrintAgentReleaseInfo +{ + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + [JsonPropertyName("published_at")] + public string? PublishedAt { get; set; } + + [JsonPropertyName("filename")] + public string? FileName { get; set; } + + [JsonPropertyName("notes")] + public string? Notes { get; set; } + + [JsonPropertyName("download_url")] + public string? DownloadUrl { get; set; } +} + +internal sealed class DriverLatestInfo +{ + [JsonPropertyName("title")] + public string Title { get; set; } = string.Empty; + + [JsonPropertyName("official_url")] + public string OfficialUrl { get; set; } = string.Empty; + + [JsonPropertyName("source_url")] + public string? SourceUrl { get; set; } + + [JsonPropertyName("checked_at")] + public string? CheckedAt { get; set; } + + [JsonPropertyName("notes")] + public string? Notes { get; set; } +} + +internal sealed class DriverArchiveRelease +{ + [JsonPropertyName("version")] + public string Version { get; set; } = string.Empty; + + [JsonPropertyName("published_at")] + public string? PublishedAt { get; set; } + + [JsonPropertyName("filename")] + public string? FileName { get; set; } + + [JsonPropertyName("notes")] + public string? Notes { get; set; } + + [JsonPropertyName("download_url")] + public string? DownloadUrl { get; set; } +} + +internal sealed class PrintAgentRuntimeState +{ + public AgentConfiguration Configuration { get; set; } = new(); + public List Printers { get; set; } = []; + public List Releases { get; set; } = []; + public DriverLatestInfo? LatestDriver { get; set; } + public List ArchivedDrivers { get; set; } = []; + public string UpdateStatusMessage { get; set; } = "尚未检查版本"; + public string LastHeartbeatAt { get; set; } = string.Empty; + public string LastClaimAt { get; set; } = string.Empty; + public string LastJobResult { get; set; } = string.Empty; + public string LastError { get; set; } = string.Empty; + public List LogLines { get; set; } = []; + + public bool IsPaired => + Configuration.TerminalId > 0 && !string.IsNullOrWhiteSpace(Configuration.DeviceToken); + + public PrintAgentRuntimeState Clone() + { + return new PrintAgentRuntimeState + { + Configuration = Configuration.Clone(), + Printers = Printers + .Select(item => new PrinterInfo + { + Name = item.Name, + DriverName = item.DriverName, + PortName = item.PortName, + IsDefault = item.IsDefault, + IsAvailable = item.IsAvailable + }) + .ToList(), + Releases = Releases + .Select(item => new PrintAgentReleaseInfo + { + Version = item.Version, + PublishedAt = item.PublishedAt, + FileName = item.FileName, + Notes = item.Notes, + DownloadUrl = item.DownloadUrl + }) + .ToList(), + LatestDriver = LatestDriver is null + ? null + : new DriverLatestInfo + { + Title = LatestDriver.Title, + OfficialUrl = LatestDriver.OfficialUrl, + SourceUrl = LatestDriver.SourceUrl, + CheckedAt = LatestDriver.CheckedAt, + Notes = LatestDriver.Notes + }, + ArchivedDrivers = ArchivedDrivers + .Select(item => new DriverArchiveRelease + { + Version = item.Version, + PublishedAt = item.PublishedAt, + FileName = item.FileName, + Notes = item.Notes, + DownloadUrl = item.DownloadUrl + }) + .ToList(), + UpdateStatusMessage = UpdateStatusMessage, + LastHeartbeatAt = LastHeartbeatAt, + LastClaimAt = LastClaimAt, + LastJobResult = LastJobResult, + LastError = LastError, + LogLines = [.. LogLines] + }; + } +} diff --git a/print-agent-dotnet/FixedAssetPrintAgent/PrintAgentRuntime.cs b/print-agent-dotnet/FixedAssetPrintAgent/PrintAgentRuntime.cs new file mode 100644 index 0000000..bfd2694 --- /dev/null +++ b/print-agent-dotnet/FixedAssetPrintAgent/PrintAgentRuntime.cs @@ -0,0 +1,846 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Win32; + +namespace FixedAssetPrintAgent; + +internal sealed class PrintAgentRuntime : IDisposable +{ + private readonly AgentConfigurationStore _store; + private readonly PrintAgentApiClient _apiClient = new(); + private readonly LabelPrintService _labelPrintService = new(); + private readonly WindowsStartupManager _startupManager; + private readonly string _appVersion; + private readonly SemaphoreSlim _heartbeatSemaphore = new(1, 1); + private readonly SemaphoreSlim _pollSemaphore = new(1, 1); + private CancellationTokenSource? _runtimeCancellationTokenSource; + private Task? _heartbeatLoopTask; + private Task? _pollLoopTask; + private bool _disposed; + + public event EventHandler? StateChanged; + + public PrintAgentRuntimeState CurrentState { get; } = new(); + + public PrintAgentRuntime(string storageRoot, string appVersion, string executablePath) + { + _store = new AgentConfigurationStore(storageRoot); + _startupManager = new WindowsStartupManager(executablePath); + _appVersion = appVersion; + } + + public async Task InitializeAsync() + { + CurrentState.Configuration = NormalizeConfiguration(await _store.LoadAsync()); + await _startupManager.ApplyAsync(CurrentState.Configuration.RunAtStartup); + await RefreshPrintersAsync(); + await RefreshRemoteResourcesAsync(); + + if (CurrentState.IsPaired) + { + AppendLog($"已读取终端配置,当前终端编号 {CurrentState.Configuration.TerminalId}。"); + } + else + { + AppendLog("当前尚未完成配对,请先输入服务器地址、终端名称和配对码。"); + } + + EmitState(); + StartBackgroundLoops(); + + if (CurrentState.IsPaired) + { + await HeartbeatNowAsync(true); + await ClaimNowAsync(true); + } + } + + public async Task SaveSettingsAsync( + string serverUrl, + string terminalName, + string selectedPrinterName, + bool runAtStartup) + { + CurrentState.Configuration.ServerUrl = NormalizeServerUrl(serverUrl); + CurrentState.Configuration.TerminalName = NormalizeTerminalName(terminalName); + CurrentState.Configuration.SelectedPrinterName = selectedPrinterName.Trim(); + CurrentState.Configuration.RunAtStartup = runAtStartup; + CurrentState.Configuration = NormalizeConfiguration(CurrentState.Configuration); + + await _store.SaveAsync(CurrentState.Configuration); + await _startupManager.ApplyAsync(CurrentState.Configuration.RunAtStartup); + StartBackgroundLoops(); + AppendLog("本地配置已保存。"); + EmitState(); + } + + public async Task PairAsync(string serverUrl, string terminalName, string pairCode) + { + var normalizedServerUrl = NormalizeServerUrl(serverUrl); + var normalizedTerminalName = NormalizeTerminalName(terminalName); + var normalizedPairCode = pairCode.Trim().ToUpperInvariant(); + + if (string.IsNullOrWhiteSpace(normalizedPairCode)) + { + throw new InvalidOperationException("请输入一次性配对码。"); + } + + var response = await _apiClient.PairAsync( + normalizedServerUrl, + normalizedTerminalName, + CurrentState.Configuration.MachineName, + CurrentState.Configuration.Platform, + normalizedPairCode); + + CurrentState.Configuration.ServerUrl = normalizedServerUrl; + CurrentState.Configuration.TerminalName = normalizedTerminalName; + CurrentState.Configuration.TerminalId = response.TerminalId; + CurrentState.Configuration.DeviceToken = response.DeviceToken; + CurrentState.Configuration.PairedAt = response.PairedAt ?? DateTimeOffset.Now.ToString("O"); + CurrentState.Configuration = NormalizeConfiguration(CurrentState.Configuration); + + await _store.SaveAsync(CurrentState.Configuration); + StartBackgroundLoops(); + AppendLog(response.IsDefault + ? "配对成功,这台电脑已成为默认打印终端。" + : "配对成功,这台电脑已加入终端列表。"); + EmitState(); + + await HeartbeatNowAsync(true); + await RefreshRemoteResourcesAsync(); + } + + public Task RefreshPrintersAsync() + { + CurrentState.Printers = _labelPrintService.ListPrinters(); + EmitState(); + return Task.CompletedTask; + } + + public async Task RefreshRemoteResourcesAsync() + { + try + { + var releases = await _apiClient.GetReleasesAsync(CurrentState.Configuration.ServerUrl); + CurrentState.Releases = releases.Releases; + + var latestVersion = releases.LatestVersion ?? CurrentState.Releases.FirstOrDefault()?.Version ?? string.Empty; + CurrentState.UpdateStatusMessage = string.IsNullOrWhiteSpace(latestVersion) + ? "尚未读取到服务器版本" + : latestVersion == _appVersion + ? $"当前已是最新版本 {_appVersion}" + : $"发现新版本 {latestVersion},后台可下载安装"; + } + catch (Exception ex) + { + CurrentState.UpdateStatusMessage = "版本检查失败,请稍后重试"; + CurrentState.LastError = ex.Message; + AppendLog($"版本检查失败:{ex.Message}"); + } + + try + { + CurrentState.LatestDriver = await _apiClient.GetLatestDriverAsync(CurrentState.Configuration.ServerUrl); + CurrentState.ArchivedDrivers = await _apiClient.GetArchivedDriversAsync(CurrentState.Configuration.ServerUrl); + } + catch (Exception ex) + { + CurrentState.LastError = ex.Message; + AppendLog($"驱动资源读取失败:{ex.Message}"); + } + + EmitState(); + } + + public async Task HeartbeatNowAsync(bool suppressErrors = false) + { + if (!CurrentState.IsPaired) + { + if (!suppressErrors) + { + AppendLog("尚未完成配对,暂时不能发送心跳。"); + } + return; + } + + await _heartbeatSemaphore.WaitAsync(); + try + { + await RefreshPrintersAsync(); + + await _apiClient.SendHeartbeatAsync( + CurrentState.Configuration.ServerUrl, + CurrentState.Configuration.DeviceToken, + CurrentState.Configuration.TerminalId, + CurrentState.Configuration.TerminalName, + CurrentState.Configuration.MachineName, + CurrentState.Configuration.Platform, + CurrentState.Configuration.SelectedPrinterName, + CurrentState.Printers, + BuildLabelProfile()); + + CurrentState.LastHeartbeatAt = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss"); + CurrentState.LastError = string.Empty; + AppendLog("心跳已发送,服务器状态已刷新。"); + EmitState(); + } + catch (Exception ex) + { + CurrentState.LastError = ex.Message; + if (!suppressErrors) + { + AppendLog($"心跳失败:{ex.Message}"); + EmitState(); + } + } + finally + { + _heartbeatSemaphore.Release(); + } + } + + public async Task ClaimNowAsync(bool suppressErrors = false) + { + if (!CurrentState.IsPaired) + { + if (!suppressErrors) + { + AppendLog("尚未完成配对,暂时不能领取任务。"); + } + return; + } + + await _pollSemaphore.WaitAsync(); + try + { + var claimResult = await _apiClient.ClaimNextJobAsync( + CurrentState.Configuration.ServerUrl, + CurrentState.Configuration.DeviceToken, + CurrentState.Configuration.TerminalId); + + CurrentState.LastClaimAt = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss"); + + if (claimResult.Job is null) + { + if (!string.IsNullOrWhiteSpace(claimResult.Message) && !suppressErrors) + { + AppendLog(claimResult.Message); + EmitState(); + } + return; + } + + await ProcessPrintJobAsync(claimResult.Job); + } + catch (Exception ex) + { + CurrentState.LastError = ex.Message; + if (!suppressErrors) + { + AppendLog($"领取任务失败:{ex.Message}"); + EmitState(); + } + } + finally + { + _pollSemaphore.Release(); + } + } + + public async Task TestPrintAsync() + { + if (string.IsNullOrWhiteSpace(CurrentState.Configuration.SelectedPrinterName)) + { + throw new InvalidOperationException("请先选择标签打印机,再执行测试打印。"); + } + + await RefreshPrintersAsync(); + + try + { + await _labelPrintService.PrintTestLabelAsync(CurrentState.Configuration.SelectedPrinterName); + CurrentState.LastJobResult = $"测试标签已发送到 {CurrentState.Configuration.SelectedPrinterName}"; + AppendLog(CurrentState.LastJobResult); + + if (CurrentState.IsPaired) + { + await _apiClient.ReportTestPrintAsync( + CurrentState.Configuration.ServerUrl, + CurrentState.Configuration.DeviceToken, + CurrentState.Configuration.SelectedPrinterName, + CurrentState.Printers, + BuildLabelProfile(), + true, + null); + } + } + catch (Exception ex) + { + CurrentState.LastError = ex.Message; + CurrentState.LastJobResult = ex.Message; + AppendLog($"测试打印失败:{ex.Message}"); + + if (CurrentState.IsPaired) + { + await _apiClient.ReportTestPrintAsync( + CurrentState.Configuration.ServerUrl, + CurrentState.Configuration.DeviceToken, + CurrentState.Configuration.SelectedPrinterName, + CurrentState.Printers, + BuildLabelProfile(), + false, + ex.Message); + } + } + + EmitState(); + } + + private async Task ProcessPrintJobAsync(PrintJobDto job) + { + await _apiClient.ReportJobStatusAsync( + CurrentState.Configuration.ServerUrl, + CurrentState.Configuration.DeviceToken, + job.Id, + "printing", + null, + false); + + if (!string.Equals(job.JobType, "asset_label", StringComparison.OrdinalIgnoreCase)) + { + await _apiClient.ReportJobStatusAsync( + CurrentState.Configuration.ServerUrl, + CurrentState.Configuration.DeviceToken, + job.Id, + "completed", + null, + false); + + CurrentState.LastJobResult = $"任务 {job.Id} 不是资产标签,已标记完成。"; + AppendLog(CurrentState.LastJobResult); + EmitState(); + return; + } + + var payload = ResolvePayload(job); + if (payload?.Items is null || payload.Items.Count == 0) + { + const string message = "当前打印任务缺少可用的资产标签数据,已阻止实际出纸。"; + await _apiClient.ReportJobStatusAsync( + CurrentState.Configuration.ServerUrl, + CurrentState.Configuration.DeviceToken, + job.Id, + "failed", + message, + false); + + CurrentState.LastError = message; + CurrentState.LastJobResult = message; + AppendLog(message); + EmitState(); + return; + } + + try + { + var printedCount = await _labelPrintService.PrintAssetLabelsAsync( + CurrentState.Configuration.SelectedPrinterName, + payload.Items); + + await _apiClient.ReportJobStatusAsync( + CurrentState.Configuration.ServerUrl, + CurrentState.Configuration.DeviceToken, + job.Id, + "completed", + null, + false); + + CurrentState.LastError = string.Empty; + CurrentState.LastJobResult = $"任务 {job.Id} 已发送到 {CurrentState.Configuration.SelectedPrinterName},共 {printedCount} 张。"; + AppendLog(CurrentState.LastJobResult); + } + catch (Exception ex) + { + await _apiClient.ReportJobStatusAsync( + CurrentState.Configuration.ServerUrl, + CurrentState.Configuration.DeviceToken, + job.Id, + "failed", + ex.Message, + false); + + CurrentState.LastError = ex.Message; + CurrentState.LastJobResult = ex.Message; + AppendLog($"任务 {job.Id} 打印失败:{ex.Message}"); + } + + EmitState(); + } + + private AssetLabelPayload? ResolvePayload(PrintJobDto job) + { + if (job.Payload?.Items?.Count > 0) + { + return job.Payload; + } + + if (job.PayloadJson.ValueKind == JsonValueKind.Object) + { + return JsonSerializer.Deserialize( + job.PayloadJson.GetRawText(), + PrintAgentApiClient.SerializerOptions); + } + + if (job.PayloadJson.ValueKind == JsonValueKind.String) + { + var raw = job.PayloadJson.GetString(); + if (!string.IsNullOrWhiteSpace(raw)) + { + return JsonSerializer.Deserialize( + raw, + PrintAgentApiClient.SerializerOptions); + } + } + + return null; + } + + private void StartBackgroundLoops() + { + _runtimeCancellationTokenSource?.Cancel(); + _runtimeCancellationTokenSource?.Dispose(); + _runtimeCancellationTokenSource = new CancellationTokenSource(); + + var cancellationToken = _runtimeCancellationTokenSource.Token; + _heartbeatLoopTask = RunPeriodicLoopAsync( + TimeSpan.FromSeconds(CurrentState.Configuration.HeartbeatIntervalSeconds), + () => HeartbeatNowAsync(true), + cancellationToken); + _pollLoopTask = RunPeriodicLoopAsync( + TimeSpan.FromSeconds(CurrentState.Configuration.PollIntervalSeconds), + () => ClaimNowAsync(true), + cancellationToken); + } + + private async Task RunPeriodicLoopAsync( + TimeSpan interval, + Func action, + CancellationToken cancellationToken) + { + if (interval <= TimeSpan.Zero) + { + return; + } + + using var timer = new PeriodicTimer(interval); + try + { + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + await action(); + } + } + catch (OperationCanceledException) + { + } + } + + private static AgentConfiguration NormalizeConfiguration(AgentConfiguration configuration) + { + configuration.ServerUrl = NormalizeServerUrl(configuration.ServerUrl); + configuration.TerminalName = NormalizeTerminalName(configuration.TerminalName); + configuration.MachineName = string.IsNullOrWhiteSpace(configuration.MachineName) + ? Environment.MachineName + : configuration.MachineName.Trim(); + configuration.Platform = string.IsNullOrWhiteSpace(configuration.Platform) + ? "windows" + : configuration.Platform.Trim().ToLowerInvariant(); + configuration.HeartbeatIntervalSeconds = Math.Max(10, configuration.HeartbeatIntervalSeconds); + configuration.PollIntervalSeconds = Math.Max(3, configuration.PollIntervalSeconds); + return configuration; + } + + private static string NormalizeServerUrl(string? serverUrl) + { + var value = string.IsNullOrWhiteSpace(serverUrl) ? "https://www.jsgdzc.cn" : serverUrl.Trim(); + return value.TrimEnd('/'); + } + + private static string NormalizeTerminalName(string? terminalName) + { + return string.IsNullOrWhiteSpace(terminalName) ? Environment.MachineName : terminalName.Trim(); + } + + private static PrintLabelProfile BuildLabelProfile() + { + return new PrintLabelProfile(); + } + + private void AppendLog(string message) + { + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} {message}"; + CurrentState.LogLines.Insert(0, line); + + if (CurrentState.LogLines.Count > 80) + { + CurrentState.LogLines.RemoveRange(80, CurrentState.LogLines.Count - 80); + } + } + + private void EmitState() + { + StateChanged?.Invoke(this, CurrentState.Clone()); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _runtimeCancellationTokenSource?.Cancel(); + _runtimeCancellationTokenSource?.Dispose(); + _heartbeatSemaphore.Dispose(); + _pollSemaphore.Dispose(); + _apiClient.Dispose(); + } +} + +internal sealed class AgentConfigurationStore +{ + private readonly JsonSerializerOptions _serializerOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + private readonly string _configFilePath; + + public AgentConfigurationStore(string storageRoot) + { + Directory.CreateDirectory(storageRoot); + _configFilePath = Path.Combine(storageRoot, "config.json"); + } + + public async Task LoadAsync() + { + if (!File.Exists(_configFilePath)) + { + return new AgentConfiguration(); + } + + await using var stream = File.OpenRead(_configFilePath); + return await JsonSerializer.DeserializeAsync(stream, _serializerOptions) + ?? new AgentConfiguration(); + } + + public async Task SaveAsync(AgentConfiguration configuration) + { + Directory.CreateDirectory(Path.GetDirectoryName(_configFilePath)!); + await using var stream = File.Create(_configFilePath); + await JsonSerializer.SerializeAsync(stream, configuration, _serializerOptions); + } +} + +internal sealed class WindowsStartupManager +{ + private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run"; + private const string StartupValueName = "JsgdzcPrintAgent"; + private readonly string _executablePath; + + public WindowsStartupManager(string executablePath) + { + _executablePath = executablePath; + } + + public Task ApplyAsync(bool enabled) + { + using var key = Registry.CurrentUser.CreateSubKey(RunKeyPath); + if (enabled) + { + key?.SetValue(StartupValueName, $"\"{_executablePath}\""); + } + else + { + key?.DeleteValue(StartupValueName, false); + } + + return Task.CompletedTask; + } +} + +internal sealed class PrintAgentApiClient : IDisposable +{ + private readonly HttpClient _httpClient = new(); + internal static JsonSerializerOptions SerializerOptions { get; } = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + public Task PairAsync( + string serverUrl, + string terminalName, + string machineName, + string platform, + string pairCode) + { + return SendAsync( + serverUrl, + "/api/print-agent/pair", + HttpMethod.Post, + new + { + code = pairCode, + terminal_name = terminalName, + machine_name = machineName, + platform + }, + null, + false); + } + + public Task SendHeartbeatAsync( + string serverUrl, + string deviceToken, + int terminalId, + string terminalName, + string machineName, + string platform, + string selectedPrinterName, + List printerSnapshot, + PrintLabelProfile labelProfile) + { + return SendAsync( + serverUrl, + "/api/print-agent/heartbeat", + HttpMethod.Post, + new + { + terminal_id = terminalId, + terminal_name = terminalName, + machine_name = machineName, + platform, + selected_printer_name = selectedPrinterName, + printer_snapshot = printerSnapshot, + label_profile = labelProfile + }, + deviceToken, + true); + } + + public Task ClaimNextJobAsync(string serverUrl, string deviceToken, int terminalId) + { + return SendAsync( + serverUrl, + "/api/print-agent/jobs/claim", + HttpMethod.Post, + new + { + terminal_id = terminalId + }, + deviceToken, + true); + } + + public Task ReportJobStatusAsync( + string serverUrl, + string deviceToken, + int jobId, + string status, + string? errorMessage, + bool retryable) + { + return SendAsync( + serverUrl, + $"/api/print-agent/jobs/{jobId}/status", + HttpMethod.Post, + new + { + status, + error_message = errorMessage, + retryable + }, + deviceToken, + true); + } + + public Task ReportTestPrintAsync( + string serverUrl, + string deviceToken, + string selectedPrinterName, + List printerSnapshot, + PrintLabelProfile labelProfile, + bool success, + string? errorMessage) + { + return SendAsync( + serverUrl, + "/api/print-agent/test-print", + HttpMethod.Post, + new + { + selected_printer_name = selectedPrinterName, + printer_snapshot = printerSnapshot, + label_profile = labelProfile, + success, + error_message = errorMessage + }, + deviceToken, + true); + } + + public Task GetReleasesAsync(string serverUrl) + { + return SendAsync( + serverUrl, + "/downloads/print-agent/releases", + HttpMethod.Get, + null, + null, + false); + } + + public Task GetLatestDriverAsync(string serverUrl) + { + return SendAsync( + serverUrl, + "/downloads/printer-driver/xprinter/latest", + HttpMethod.Get, + null, + null, + false); + } + + public Task> GetArchivedDriversAsync(string serverUrl) + { + return SendAsync>( + serverUrl, + "/downloads/printer-driver/xprinter/archive", + HttpMethod.Get, + null, + null, + false); + } + + private async Task SendAsync( + string serverUrl, + string endpoint, + HttpMethod method, + object? body, + string? deviceToken, + bool authorized) + { + using var request = new HttpRequestMessage(method, BuildUrl(serverUrl, endpoint)); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + if (authorized) + { + if (string.IsNullOrWhiteSpace(deviceToken)) + { + throw new InvalidOperationException("打印终端尚未完成认证。"); + } + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", deviceToken); + } + + if (body is not null) + { + request.Content = new StringContent( + JsonSerializer.Serialize(body, SerializerOptions), + Encoding.UTF8, + "application/json"); + } + + using var response = await _httpClient.SendAsync(request); + var raw = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException(GetMessage(raw) ?? $"请求失败({(int)response.StatusCode})"); + } + + if (string.IsNullOrWhiteSpace(raw)) + { + return Activator.CreateInstance(); + } + + using var document = JsonDocument.Parse(raw); + var root = document.RootElement; + var dataElement = root; + + if (root.ValueKind == JsonValueKind.Object && + root.TryGetProperty("code", out _) && + root.TryGetProperty("data", out var wrapped)) + { + dataElement = wrapped; + } + + var result = JsonSerializer.Deserialize(dataElement.GetRawText(), SerializerOptions); + if (result is null) + { + throw new InvalidOperationException("服务器返回了空数据。"); + } + + return result; + } + + private static string BuildUrl(string serverUrl, string endpoint) + { + var baseUrl = serverUrl.Trim().TrimEnd('/'); + if (baseUrl.EndsWith("/api", StringComparison.OrdinalIgnoreCase)) + { + baseUrl = baseUrl[..^4]; + } + + return $"{baseUrl}{endpoint}"; + } + + private static string? GetMessage(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(raw); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (root.TryGetProperty("message", out var messageElement) && + messageElement.ValueKind == JsonValueKind.String) + { + return messageElement.GetString(); + } + + if (root.TryGetProperty("data", out var dataElement) && + dataElement.ValueKind == JsonValueKind.Object && + dataElement.TryGetProperty("message", out var nestedMessage) && + nestedMessage.ValueKind == JsonValueKind.String) + { + return nestedMessage.GetString(); + } + } + catch + { + } + + return null; + } + + public void Dispose() + { + _httpClient.Dispose(); + } +} diff --git a/print-agent-dotnet/FixedAssetPrintAgent/Program.cs b/print-agent-dotnet/FixedAssetPrintAgent/Program.cs new file mode 100644 index 0000000..a9c666f --- /dev/null +++ b/print-agent-dotnet/FixedAssetPrintAgent/Program.cs @@ -0,0 +1,17 @@ +namespace FixedAssetPrintAgent; + +static class Program +{ + [STAThread] + static void Main() + { + using var mutex = new Mutex(true, "Jsgdzc.FixedAssetPrintAgent", out var createdNew); + if (!createdNew) + { + return; + } + + ApplicationConfiguration.Initialize(); + Application.Run(new PrintAgentApplicationContext()); + } +} diff --git a/print-agent-dotnet/build-release.ps1 b/print-agent-dotnet/build-release.ps1 new file mode 100644 index 0000000..89b01c4 --- /dev/null +++ b/print-agent-dotnet/build-release.ps1 @@ -0,0 +1,93 @@ +param( + [string]$Version = '0.2.0', + [string]$PublishedAt = '', + [string]$DotnetPath = '', + [string]$IsccPath = '' +) + +$ErrorActionPreference = 'Stop' + +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Split-Path -Parent $scriptRoot + +if ([string]::IsNullOrWhiteSpace($DotnetPath)) { + $DotnetPath = Join-Path $repoRoot '.dotnet8\dotnet.exe' +} + +if (-not (Test-Path $DotnetPath)) { + throw "未找到 .NET 8 可执行文件:$DotnetPath" +} + +if ([string]::IsNullOrWhiteSpace($IsccPath)) { + $candidatePaths = @( + 'C:\Users\54972\AppData\Local\Programs\Inno Setup 6\ISCC.exe', + 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe', + 'C:\Program Files\Inno Setup 6\ISCC.exe' + ) + $IsccPath = $candidatePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 +} + +if (-not $IsccPath -or -not (Test-Path $IsccPath)) { + throw '未找到 Inno Setup 编译器 ISCC.exe' +} + +if ([string]::IsNullOrWhiteSpace($PublishedAt)) { + $PublishedAt = (Get-Date).ToString('yyyy-MM-ddTHH:mm:sszzz') +} + +$publishDir = Join-Path $scriptRoot 'dist\publish\win-x64' +$installerDir = Join-Path $scriptRoot 'dist\installer' +$releaseDir = Join-Path $repoRoot "downloads\print-agent\releases\$Version" +$latestDir = Join-Path $repoRoot 'downloads\print-agent\latest' + +New-Item -ItemType Directory -Force -Path $publishDir, $installerDir, $releaseDir, $latestDir | Out-Null + +& $DotnetPath publish ` + (Join-Path $scriptRoot 'FixedAssetPrintAgent\FixedAssetPrintAgent.csproj') ` + -c Release ` + -r win-x64 ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:EnableCompressionInSingleFile=true ` + -o $publishDir + +& $IsccPath (Join-Path $scriptRoot 'installer\FixedAssetPrintAgent.iss') + +$installerSource = Join-Path $installerDir "print-agent-setup-$Version.exe" +if (-not (Test-Path $installerSource)) { + throw "未生成安装包:$installerSource" +} + +$versionedInstaller = Join-Path $releaseDir "print-agent-setup-$Version.exe" +$latestInstaller = Join-Path $latestDir 'print-agent-setup.exe' + +Copy-Item -Force $installerSource $versionedInstaller +Copy-Item -Force $installerSource $latestInstaller + +$releaseManifest = [ordered]@{ + version = $Version + published_at = $PublishedAt + filename = "print-agent-setup-$Version.exe" + notes = '打印助手 v0.2.0,已切换为 .NET 8 原生 Windows 托盘版,带正式安装器、控制面板卸载入口、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。' + source_type = 'server_release' + relative_path = "downloads/print-agent/releases/$Version/print-agent-setup-$Version.exe" + relativePath = "downloads/print-agent/releases/$Version/print-agent-setup-$Version.exe" +} | ConvertTo-Json -Depth 5 + +$latestManifest = [ordered]@{ + version = $Version + published_at = $PublishedAt + filename = 'print-agent-setup.exe' + notes = '打印助手最新稳定版,当前为 .NET 8 原生 Windows 托盘安装包,支持正式安装、卸载、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。' + source_type = 'server_release' + relative_path = 'downloads/print-agent/latest/print-agent-setup.exe' + relativePath = 'downloads/print-agent/latest/print-agent-setup.exe' +} | ConvertTo-Json -Depth 5 + +Set-Content -Path (Join-Path $releaseDir 'manifest.json') -Value $releaseManifest -Encoding UTF8 +Set-Content -Path (Join-Path $latestDir 'manifest.json') -Value $latestManifest -Encoding UTF8 + +$installerSizeMB = [Math]::Round((Get-Item $installerSource).Length / 1MB, 2) +Write-Host "安装包已生成:$installerSource ($installerSizeMB MB)" +Write-Host "已同步到:$releaseDir" +Write-Host "已更新 latest 清单和下载包。" diff --git a/print-agent-dotnet/installer/FixedAssetPrintAgent.iss b/print-agent-dotnet/installer/FixedAssetPrintAgent.iss new file mode 100644 index 0000000..7c13f56 --- /dev/null +++ b/print-agent-dotnet/installer/FixedAssetPrintAgent.iss @@ -0,0 +1,43 @@ +#define MyAppName "固定资产打印助手" +#define MyAppVersion "0.2.0" +#define MyAppPublisher "常州简尚家具有限公司" +#define MyAppExeName "FixedAssetPrintAgent.exe" +#define MyAppId "com.jsgdzc.printagent.dotnet" +#define MyAppSourceDir "..\dist\publish\win-x64" +#define MyAppOutputDir "..\dist\installer" + +[Setup] +AppId={#MyAppId} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL=https://www.jsgdzc.cn +DefaultDirName={localappdata}\Programs\{#MyAppName} +DefaultGroupName={#MyAppName} +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +UninstallDisplayIcon={app}\{#MyAppExeName} +OutputDir={#MyAppOutputDir} +OutputBaseFilename=print-agent-setup-{#MyAppVersion} + +[Languages] +Name: "chinesesimp"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加图标:"; Flags: unchecked + +[Files] +Source: "{#MyAppSourceDir}\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}" +Name: "{group}\卸载 {#MyAppName}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{app}\{#MyAppExeName}"; Description: "立即启动 {#MyAppName}"; Flags: nowait postinstall skipifsilent