feat(打印): 新增远程标签打印与 .NET 打印助手
- 新增打印终端、配对码、打印任务相关表结构与接口\n- 增加后台打印终端/任务管理页和资产标签远程打印入口\n- 引入 .NET 8 Windows 打印助手源码及下载清单配置
This commit is contained in:
parent
9519c10b78
commit
c7a9324635
12
.gitignore
vendored
12
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
@ -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)
|
||||
|
||||
249
backend/src/controllers/printController.ts
Normal file
249
backend/src/controllers/printController.ts
Normal file
@ -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, '测试打印状态上报失败')
|
||||
}
|
||||
}
|
||||
125
backend/src/controllers/printDownloadController.ts
Normal file
125
backend/src/controllers/printDownloadController.ts
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
44
backend/src/middleware/printAgentAuth.ts
Normal file
44
backend/src/middleware/printAgentAuth.ts
Normal file
@ -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
|
||||
22
backend/src/routes/printAgent.ts
Normal file
22
backend/src/routes/printAgent.ts
Normal file
@ -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
|
||||
21
backend/src/routes/printDownloads.ts
Normal file
21
backend/src/routes/printDownloads.ts
Normal file
@ -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
|
||||
14
backend/src/routes/printJobs.ts
Normal file
14
backend/src/routes/printJobs.ts
Normal file
@ -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
|
||||
16
backend/src/routes/printTerminals.ts
Normal file
16
backend/src/routes/printTerminals.ts
Normal file
@ -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
|
||||
144
backend/src/services/__tests__/printService.test.ts
Normal file
144
backend/src/services/__tests__/printService.test.ts
Normal file
@ -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')
|
||||
})
|
||||
157
backend/src/services/printDownloadService.ts
Normal file
157
backend/src/services/printDownloadService.ts
Normal file
@ -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 = <T>(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<PrintAgentReleaseManifest>(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<PrintAgentReleaseManifest>(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<PrintAgentReleaseManifest>(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<DriverOfficialLatestManifest>(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<DriverArchiveManifest>(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<DriverArchiveManifest>(manifestPath)
|
||||
const filePath = resolveBinaryPath(manifestDir, manifest.filename, manifest.relative_path)
|
||||
|
||||
return {
|
||||
manifest,
|
||||
filePath
|
||||
}
|
||||
}
|
||||
1372
backend/src/services/printService.ts
Normal file
1372
backend/src/services/printService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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:
|
||||
|
||||
9
downloads/print-agent/latest/manifest.json
Normal file
9
downloads/print-agent/latest/manifest.json
Normal file
@ -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"
|
||||
}
|
||||
9
downloads/print-agent/releases/0.1.0/manifest.json
Normal file
9
downloads/print-agent/releases/0.1.0/manifest.json
Normal file
@ -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"
|
||||
}
|
||||
9
downloads/print-agent/releases/0.2.0/manifest.json
Normal file
9
downloads/print-agent/releases/0.2.0/manifest.json
Normal file
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
9
downloads/printer-driver/xprinter/latest/manifest.json
Normal file
9
downloads/printer-driver/xprinter/latest/manifest.json
Normal file
@ -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": "芯烨官方最新版下载入口,页面内容会随官方更新。"
|
||||
}
|
||||
@ -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";
|
||||
|
||||
96
frontend/scripts/test-barcode-layout.ts
Normal file
96
frontend/scripts/test-barcode-layout.ts
Normal file
@ -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')
|
||||
418
frontend/src/api/print.ts
Normal file
418
frontend/src/api/print.ts
Normal file
@ -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<string, any> => {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
return value as Record<string, any>
|
||||
}
|
||||
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 = <T extends Record<string, any>>(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 = <T>(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 = <T>(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<PrintTerminalPrinter>(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<Record<string, any>>(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<Record<string, any>>(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<string, any>>(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<PrintJobPayload>(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 = <T>(
|
||||
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<ApiResponse<unknown>>('/print-terminals')
|
||||
return normalizeCollection(response.data.data, ['list', 'items', 'terminals'], normalizePrintTerminal)
|
||||
}
|
||||
|
||||
export const createAssetLabelPrintJob = async (equipmentIds: number[]): Promise<CreateAssetLabelPrintJobResult> => {
|
||||
const payload: CreateAssetLabelPrintJobPayload = { equipment_ids: equipmentIds }
|
||||
const response = await api.post<ApiResponse<unknown>>('/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<ApiResponse<unknown>>('/print-jobs', { params })
|
||||
return normalizePrintJobsData(response.data.data)
|
||||
}
|
||||
|
||||
export const cancelQueuedPrintJob = async (id: number) => {
|
||||
const response = await api.patch<ApiResponse<unknown>>(`/print-jobs/${id}/cancel`)
|
||||
return normalizePrintJob(response.data.data)
|
||||
}
|
||||
|
||||
export const setDefaultPrintTerminal = async (id: number) => {
|
||||
const response = await api.patch<ApiResponse<unknown>>(`/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<ApiResponse<unknown>>(`/print-terminals/${id}/status`, payload)
|
||||
return normalizePrintTerminal(response.data.data)
|
||||
}
|
||||
|
||||
export const createPrintTerminalPairCode = async () => {
|
||||
const response = await api.post<ApiResponse<unknown>>('/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<unknown>(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<unknown>(response.data))
|
||||
}
|
||||
|
||||
export const getArchivedXPrinterDrivers = async () => {
|
||||
const response = await axios.get(buildDownloadUrl('/downloads/printer-driver/xprinter/archive'), {
|
||||
withCredentials: true,
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
const payload = unwrapPayload<unknown>(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'
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,40 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="panel space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-semibold text-text-primary">办公室打印状态</p>
|
||||
<p class="text-sm text-text-secondary">{{ remoteStatusDescription }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="badge" :class="remoteStatusBadgeClass">{{ remoteStatusText }}</span>
|
||||
<span v-if="defaultTerminal?.is_default" class="badge badge-neutral">默认打印电脑</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="panel">
|
||||
<p class="text-xs uppercase tracking-wider text-text-tertiary">终端名称</p>
|
||||
<p class="mt-2 text-sm font-medium text-text-primary">{{ defaultTerminal?.name || '未配置' }}</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<p class="text-xs uppercase tracking-wider text-text-tertiary">当前打印机</p>
|
||||
<p class="mt-2 text-sm font-medium text-text-primary">{{ remotePrinterName }}</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<p class="text-xs uppercase tracking-wider text-text-tertiary">标签配置</p>
|
||||
<p class="mt-2 text-sm font-medium text-text-primary">{{ remoteProfileText }}</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<p class="text-xs uppercase tracking-wider text-text-tertiary">最近在线</p>
|
||||
<p class="mt-2 text-sm font-medium text-text-primary">{{ formatNullableDateTime(defaultTerminal?.last_seen_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="terminalError" class="text-sm text-status-warning">{{ terminalError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div
|
||||
v-for="equipment in equipmentList"
|
||||
@ -42,6 +76,9 @@
|
||||
<button @click="handlePrint" class="btn-primary flex-1" :disabled="loading">
|
||||
{{ loading ? '生成中...' : '打印二维码' }}
|
||||
</button>
|
||||
<button @click="handleRemotePrint" class="btn-secondary flex-1" :disabled="remoteActionDisabled">
|
||||
{{ remoteSubmitting ? '发送中...' : '发送到办公室打印' }}
|
||||
</button>
|
||||
<button @click="handleDownload" class="btn-secondary flex-1" :disabled="loading">
|
||||
{{ loading ? '生成中...' : '下载图片' }}
|
||||
</button>
|
||||
@ -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<EquipmentBarcode[]>([])
|
||||
const previewImages = ref<Record<number, string>>({})
|
||||
const loading = ref(false)
|
||||
const terminalLoading = ref(false)
|
||||
const remoteSubmitting = ref(false)
|
||||
const defaultTerminal = ref<PrintTerminal | null>(null)
|
||||
const terminalError = ref<string | null>(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<HTMLImageElement>((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<string[]> => {
|
||||
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()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qrcode-card {
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border: 2px solid #000000;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
border-radius: 0;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@ -493,7 +868,7 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
color: #000000;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
292
frontend/src/components/barcodeLayout.ts
Normal file
292
frontend/src/components/barcodeLayout.ts
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
@ -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<PrintJob> {}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -45,6 +45,8 @@
|
||||
<DepartmentManagement v-else-if="activeTab === 'departments'" />
|
||||
<MaintenanceManagement v-else-if="activeTab === 'maintenance'" />
|
||||
<DisposalManagement v-else-if="activeTab === 'disposal'" />
|
||||
<PrintTerminalManagement v-else-if="activeTab === 'printTerminals'" />
|
||||
<PrintJobManagement v-else-if="activeTab === 'printJobs'" />
|
||||
<OperationLogList v-else-if="activeTab === 'logs'" />
|
||||
</div>
|
||||
</div>
|
||||
@ -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: '操作日志' }
|
||||
]
|
||||
|
||||
|
||||
318
frontend/src/views/settings/components/PrintJobManagement.vue
Normal file
318
frontend/src/views/settings/components/PrintJobManagement.vue
Normal file
@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<p class="page-kicker">Print Jobs</p>
|
||||
<h2 class="page-title">打印任务</h2>
|
||||
</div>
|
||||
<div class="page-meta-grid">
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">排队中</p>
|
||||
<p class="page-meta__value">{{ queuedCount }}</p>
|
||||
<p class="page-meta__subvalue">等待默认终端</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">处理中</p>
|
||||
<p class="page-meta__value">{{ processingCount }}</p>
|
||||
<p class="page-meta__subvalue">已被终端领取</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">已完成</p>
|
||||
<p class="page-meta__value">{{ completedCount }}</p>
|
||||
<p class="page-meta__subvalue">成功出纸</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">失败</p>
|
||||
<p class="page-meta__value">{{ failedCount }}</p>
|
||||
<p class="page-meta__subvalue">需人工查看原因</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="filter-bar">
|
||||
<div class="filter-bar__inputs">
|
||||
<div class="field-stack">
|
||||
<label class="field-label">任务状态</label>
|
||||
<select v-model="statusFilter" class="select" @change="handleSearch">
|
||||
<option value="">全部状态</option>
|
||||
<option value="queued">排队中</option>
|
||||
<option value="claimed">已领取</option>
|
||||
<option value="printing">打印中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">当前说明</label>
|
||||
<p class="text-sm leading-6 text-text-secondary">
|
||||
这里只展示最近远程打印任务。排队中的任务会等待默认打印电脑上线;当前前端先不做手动改派。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bar__actions">
|
||||
<button class="btn-secondary text-xs sm:text-sm" :disabled="loading" @click="fetchJobs">
|
||||
{{ loading ? '刷新中...' : '刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="list-header">
|
||||
<div>
|
||||
<h3 class="section-title">任务列表</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">用于查看是否已经入队、是否开始打印、以及失败时的人话错误原因。</p>
|
||||
</div>
|
||||
<span class="badge badge-neutral">最近 {{ jobs.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<div class="mobile-card-list md:hidden">
|
||||
<article v-for="job in jobs" :key="`mobile-${job.id}`" class="mobile-card">
|
||||
<div class="mobile-card__row">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-text-primary">{{ getJobTypeText(job.job_type) }}</p>
|
||||
<p class="mt-1 text-xs text-text-tertiary">{{ formatNullableDateTime(job.created_at) }}</p>
|
||||
</div>
|
||||
<span class="badge" :class="getJobStatusBadgeClass(job.status)">
|
||||
{{ getJobStatusText(job.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 mobile-card__meta">
|
||||
<div>
|
||||
<p class="mobile-card__label">目标终端</p>
|
||||
<p class="mobile-card__value">{{ getTerminalLabel(job) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mobile-card__label">标签数量</p>
|
||||
<p class="mobile-card__value">{{ getEquipmentCount(job) }} 张</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mobile-card__label">发起人</p>
|
||||
<p class="mobile-card__value">{{ job.requested_by_name || '系统' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mobile-card__label">尝试次数</p>
|
||||
<p class="mobile-card__value">{{ job.attempt_count || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-xl border border-border bg-bg-elevated px-4 py-3">
|
||||
<p class="mobile-card__label">任务结果</p>
|
||||
<p class="mt-2 text-sm leading-6 text-text-primary">{{ getJobResultText(job) }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="job.status === 'queued'" class="mt-4 flex justify-end">
|
||||
<button
|
||||
class="btn-secondary text-xs sm:text-sm"
|
||||
:disabled="busyJobId === job.id"
|
||||
@click="handleCancelJob(job)"
|
||||
>
|
||||
{{ busyJobId === job.id ? '取消中...' : '取消任务' }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p v-if="!loading && jobs.length === 0" class="rounded-xl border border-border bg-bg-elevated px-4 py-8 text-center text-sm text-text-tertiary">
|
||||
当前筛选条件下暂无打印任务。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:block">
|
||||
<div class="table-container">
|
||||
<table class="data-table min-w-[1080px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>创建时间</th>
|
||||
<th>任务类型</th>
|
||||
<th>状态</th>
|
||||
<th>目标终端</th>
|
||||
<th class="text-right">标签数量</th>
|
||||
<th>发起人</th>
|
||||
<th class="text-right">尝试次数</th>
|
||||
<th>任务结果</th>
|
||||
<th class="text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="job in jobs" :key="job.id">
|
||||
<td class="text-sm text-text-secondary">{{ formatNullableDateTime(job.created_at) }}</td>
|
||||
<td class="text-sm font-medium text-text-primary">{{ getJobTypeText(job.job_type) }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="getJobStatusBadgeClass(job.status)">
|
||||
{{ getJobStatusText(job.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-sm text-text-secondary">{{ getTerminalLabel(job) }}</td>
|
||||
<td class="text-right text-sm text-text-primary">{{ getEquipmentCount(job) }}</td>
|
||||
<td class="text-sm text-text-secondary">{{ job.requested_by_name || '系统' }}</td>
|
||||
<td class="text-right text-sm text-text-secondary">{{ job.attempt_count || 0 }}</td>
|
||||
<td class="max-w-[280px]">
|
||||
<p class="truncate text-sm text-text-secondary">{{ getJobResultText(job) }}</p>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<button
|
||||
v-if="job.status === 'queued'"
|
||||
class="table-action"
|
||||
:disabled="busyJobId === job.id"
|
||||
@click="handleCancelJob(job)"
|
||||
>
|
||||
{{ busyJobId === job.id ? '取消中' : '取消任务' }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-text-tertiary">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && jobs.length === 0">
|
||||
<td colspan="9" class="py-8 text-center text-text-tertiary">当前筛选条件下暂无打印任务。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="total > 0" class="pagination-bar">
|
||||
<p class="text-sm text-text-secondary">第 {{ page }} / {{ totalPages }} 页,共 {{ total }} 条任务</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn-secondary text-sm" :disabled="page <= 1 || loading" @click="handlePrevPage">上一页</button>
|
||||
<button class="btn-secondary text-sm" :disabled="page >= totalPages || loading" @click="handleNextPage">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { cancelQueuedPrintJob, getPrintJobs } from '@/api/print'
|
||||
import type { PrintJob, PrintJobStatus } from '@/types'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const jobs = ref<PrintJob[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = 12
|
||||
const loading = ref(false)
|
||||
const busyJobId = ref<number | null>(null)
|
||||
const statusFilter = ref<PrintJobStatus | ''>('')
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
|
||||
const queuedCount = computed(() => jobs.value.filter((job) => job.status === 'queued').length)
|
||||
const processingCount = computed(() => jobs.value.filter((job) => ['claimed', 'printing'].includes(job.status)).length)
|
||||
const completedCount = computed(() => jobs.value.filter((job) => job.status === 'completed').length)
|
||||
const failedCount = computed(() => jobs.value.filter((job) => job.status === 'failed').length)
|
||||
|
||||
const formatNullableDateTime = (value?: string | null) => {
|
||||
if (!value) return '未记录'
|
||||
try {
|
||||
return formatDateTime(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const getJobTypeText = (type?: string | null) => {
|
||||
if (type === 'asset_label') return '资产标签'
|
||||
return type || '未知任务'
|
||||
}
|
||||
|
||||
const getJobStatusText = (status?: string | null) => {
|
||||
if (status === 'queued') return '排队中'
|
||||
if (status === 'claimed') return '已领取'
|
||||
if (status === 'printing') return '打印中'
|
||||
if (status === 'completed') return '已完成'
|
||||
if (status === 'failed') return '失败'
|
||||
if (status === 'cancelled') return '已取消'
|
||||
return status || '未知'
|
||||
}
|
||||
|
||||
const getJobStatusBadgeClass = (status?: string | null) => {
|
||||
if (status === 'completed') return 'badge-success'
|
||||
if (status === 'failed') return 'badge-error'
|
||||
if (status === 'queued' || status === 'claimed' || status === 'printing') return 'badge-warning'
|
||||
return 'badge-neutral'
|
||||
}
|
||||
|
||||
const getTerminalLabel = (job: PrintJob) => {
|
||||
return job.target_terminal_name || (job.target_terminal_id ? `终端 ${job.target_terminal_id}` : '等待默认终端')
|
||||
}
|
||||
|
||||
const getEquipmentCount = (job: PrintJob) => {
|
||||
if (Array.isArray(job.payload?.equipment_ids)) {
|
||||
return job.payload.equipment_ids.length
|
||||
}
|
||||
if (Array.isArray(job.payload?.equipment_codes)) {
|
||||
return job.payload.equipment_codes.length
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const getJobResultText = (job: PrintJob) => {
|
||||
if (job.error_message) return job.error_message
|
||||
if (job.status === 'completed') {
|
||||
return job.completed_at ? `已完成,时间 ${formatNullableDateTime(job.completed_at)}` : '已完成'
|
||||
}
|
||||
if (job.status === 'printing') return '终端正在打印'
|
||||
if (job.status === 'claimed') return '终端已领取,等待开始打印'
|
||||
if (job.status === 'queued') return '等待默认打印电脑上线或接单'
|
||||
if (job.status === 'cancelled') return '任务已取消'
|
||||
return '状态待更新'
|
||||
}
|
||||
|
||||
const fetchJobs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getPrintJobs({
|
||||
page: page.value,
|
||||
pageSize,
|
||||
status: statusFilter.value
|
||||
})
|
||||
jobs.value = response.list || []
|
||||
total.value = response.total || jobs.value.length
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '获取打印任务失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
page.value = 1
|
||||
fetchJobs()
|
||||
}
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (page.value <= 1) return
|
||||
page.value -= 1
|
||||
fetchJobs()
|
||||
}
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (page.value >= totalPages.value) return
|
||||
page.value += 1
|
||||
fetchJobs()
|
||||
}
|
||||
|
||||
const handleCancelJob = async (job: PrintJob) => {
|
||||
if (job.status !== 'queued') return
|
||||
if (!window.confirm(`确定要取消任务 #${job.id} 吗?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
busyJobId.value = job.id
|
||||
try {
|
||||
await cancelQueuedPrintJob(job.id)
|
||||
await fetchJobs()
|
||||
alert('打印任务已取消')
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '取消打印任务失败')
|
||||
} finally {
|
||||
busyJobId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchJobs()
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,635 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<section class="page-header">
|
||||
<div class="flex-1">
|
||||
<p class="page-kicker">Print Settings</p>
|
||||
<h2 class="page-title">打印终端</h2>
|
||||
<p class="mt-2 text-sm text-text-secondary">{{ getDispatchHeadline() }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-4 lg:mt-0">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
:disabled="loading || resourceLoading"
|
||||
@click="loadAll"
|
||||
>
|
||||
{{ loading || resourceLoading ? '刷新中...' : '刷新状态' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 顶栏统计网格 -->
|
||||
<div class="page-meta-grid">
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">接入总数</p>
|
||||
<p class="page-meta__value">{{ terminals.length }}</p>
|
||||
<p class="page-meta__subvalue mt-1">办公室已接入电脑</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">在线终端</p>
|
||||
<p class="page-meta__value">{{ onlineCount }}</p>
|
||||
<p class="page-meta__subvalue mt-1">当前可通信</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">已就绪</p>
|
||||
<p class="page-meta__value">{{ readyCount }}</p>
|
||||
<p class="page-meta__subvalue mt-1">已完成 60x40mm 配置</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体两栏内容 -->
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
|
||||
<!-- 左侧主栏:默认派单、终端列表 -->
|
||||
<div class="xl:col-span-2 space-y-6">
|
||||
|
||||
<!-- 默认派单卡片 -->
|
||||
<section class="card">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">默认派单中控</h3>
|
||||
<span class="badge" :class="getDispatchChipClass()">{{ getDispatchChipText() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="panel mt-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-xs text-text-tertiary uppercase tracking-wider">当前默认打印电脑</p>
|
||||
<p class="text-lg font-semibold text-text-primary mt-1">{{ defaultTerminal?.name || '尚未设置' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mt-4 pt-4 border-t border-border">
|
||||
<div>
|
||||
<p class="text-xs text-text-tertiary">当前打印机</p>
|
||||
<p class="text-sm text-text-primary mt-1">{{ defaultTerminal ? getPrinterName(defaultTerminal) : '等待接入' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-text-tertiary">标签配置</p>
|
||||
<p class="text-sm text-text-primary mt-1">{{ defaultTerminal ? getLabelProfileText(defaultTerminal) : '尚未配置' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-text-tertiary">最近在线</p>
|
||||
<p class="text-sm text-text-primary mt-1">{{ defaultTerminal ? formatNullableDateTime(defaultTerminal.last_seen_at) : '未记录' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-text-tertiary">备用终端</p>
|
||||
<p class="text-sm text-text-primary mt-1">{{ Math.max(terminals.length - 1, 0) }} 台</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-sm text-text-secondary bg-bg-primary rounded-lg p-3 border border-border">
|
||||
<p class="font-medium text-text-primary mb-1">派单规则</p>
|
||||
<p>{{ getDispatchNotice() }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 终端列表卡片 -->
|
||||
<section class="card">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">终端列表</h3>
|
||||
<span class="badge badge-neutral">共 {{ terminals.length }} 台</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && terminals.length === 0" class="empty-state mt-4">
|
||||
当前还没有已接入的打印终端。先生成配对码,再让办公室电脑安装打印助手即可。
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 mt-4 lg:grid-cols-2">
|
||||
<div
|
||||
v-for="terminal in terminals"
|
||||
:key="terminal.id"
|
||||
class="panel flex flex-col gap-4 transition-colors"
|
||||
:class="{
|
||||
'border-accent-primary/50 bg-accent-primary/5': terminal.is_default,
|
||||
'opacity-75': terminal.status === 'disabled'
|
||||
}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="text-base font-semibold text-text-primary">{{ terminal.name }}</h4>
|
||||
<span v-if="terminal.is_default" class="badge badge-success">默认派单</span>
|
||||
</div>
|
||||
<p class="text-xs text-text-secondary mt-1">
|
||||
{{ terminal.machine_name || '未上报机器名' }} · {{ terminal.platform || 'Windows' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge" :class="getStatusChipClass(terminal.status)">
|
||||
{{ getStatusText(terminal.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p class="text-xs text-text-tertiary">打印机</p>
|
||||
<p class="text-text-primary mt-0.5 truncate" :title="getPrinterName(terminal)">{{ getPrinterName(terminal) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-text-tertiary">配置</p>
|
||||
<p class="text-text-primary mt-0.5 truncate" :title="getLabelProfileText(terminal)">{{ getLabelProfileText(terminal) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-text-tertiary">最近在线</p>
|
||||
<p class="text-text-primary mt-0.5">{{ formatNullableDateTime(terminal.last_seen_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-text-tertiary">接单状态</p>
|
||||
<p class="text-text-primary mt-0.5 truncate" :title="isTerminalReady(terminal) ? '已就绪' : getTerminalReadinessText(terminal)">
|
||||
{{ isTerminalReady(terminal) ? '已就绪' : getTerminalReadinessText(terminal) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="getTerminalError(terminal)" class="p-2.5 rounded-md bg-status-error/10 border border-status-error/20">
|
||||
<p class="text-xs text-status-error">{{ getTerminalError(terminal) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 mt-auto pt-3 border-t border-border">
|
||||
<button
|
||||
class="btn-secondary px-3 py-1.5 min-h-0 text-xs"
|
||||
:disabled="busyTerminalId === terminal.id"
|
||||
@click="handleToggleStatus(terminal)"
|
||||
>
|
||||
{{ busyTerminalId === terminal.id ? '处理中...' : terminal.status === 'disabled' ? '重新启用' : '停用' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!terminal.is_default"
|
||||
class="btn-primary px-3 py-1.5 min-h-0 text-xs"
|
||||
:disabled="busyTerminalId === terminal.id || terminal.status === 'disabled'"
|
||||
@click="handleSetDefault(terminal)"
|
||||
>
|
||||
{{ busyTerminalId === terminal.id ? '处理中...' : '设为默认' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 右侧副栏:配对、资源下载 -->
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- 终端接入钥匙卡片 -->
|
||||
<section class="card">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">终端接入钥匙</h3>
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col gap-4">
|
||||
<div class="panel text-center py-6 border-dashed">
|
||||
<p class="text-sm text-text-secondary">配对码</p>
|
||||
<p class="text-3xl font-mono font-bold tracking-widest text-text-primary mt-2">
|
||||
{{ pairCode?.code || '等待生成' }}
|
||||
</p>
|
||||
<p class="text-xs text-text-tertiary mt-2">
|
||||
{{ pairCode ? `过期时间 ${formatNullableDateTime(pairCode.expires_at)}` : '点击下方按钮生成一次性配对码' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="btn-primary flex-1" :disabled="pairCodeLoading" @click="handleCreatePairCode">
|
||||
{{ pairCodeLoading ? '生成中...' : '生成配对码' }}
|
||||
</button>
|
||||
<button class="btn-secondary flex-1" :disabled="!pairCode?.code" @click="copyPairCode">
|
||||
复制配对码
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm text-text-secondary bg-bg-primary rounded-lg p-3 border border-border">
|
||||
<p class="font-medium text-text-primary mb-2">接入顺序:</p>
|
||||
<ol class="list-decimal pl-5 space-y-1.5 text-xs">
|
||||
<li>先下载安装打印助手</li>
|
||||
<li>输入配对码完成绑定</li>
|
||||
<li>选择标签打印机并完成 60x40mm 测试</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 资源下载卡片 -->
|
||||
<section class="card">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">助手与驱动中心</h3>
|
||||
<span class="badge badge-neutral">{{ resourceLoading ? '同步中' : '服务器托管' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-4">
|
||||
<!-- 打印助手 -->
|
||||
<div class="panel">
|
||||
<p class="text-xs text-text-tertiary uppercase mb-2">打印助手</p>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-medium text-text-primary">{{ latestAgentRelease?.version || '等待版本信息' }}</p>
|
||||
<p class="text-xs text-text-secondary mt-1">
|
||||
{{ latestAgentRelease?.published_at ? formatNullableDateTime(latestAgentRelease.published_at) : '暂无发布时间' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-primary px-3 py-1.5 min-h-0 text-xs"
|
||||
:disabled="downloadingKey === 'agent'"
|
||||
@click="handleDownloadPrintAgent"
|
||||
>
|
||||
{{ downloadingKey === 'agent' ? '下载中' : '下载' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 芯烨官方最新版 -->
|
||||
<div class="panel">
|
||||
<p class="text-xs text-text-tertiary uppercase mb-2">芯烨官方最新版</p>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-medium text-text-primary">{{ latestDriver?.title || '官方入口待接入' }}</p>
|
||||
<p class="text-xs text-text-secondary mt-1">
|
||||
{{ latestDriver?.checked_at ? `最近核对 ${formatNullableDateTime(latestDriver.checked_at)}` : '一键安装版驱动' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-secondary px-3 py-1.5 min-h-0 text-xs"
|
||||
:disabled="!latestDriver?.official_url"
|
||||
@click="openExternal(latestDriver?.official_url)"
|
||||
>
|
||||
打开下载页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 公司归档版驱动 -->
|
||||
<div class="panel">
|
||||
<p class="text-xs text-text-tertiary uppercase mb-2">公司归档版驱动</p>
|
||||
<div v-if="archivedDrivers.length" class="space-y-3 mt-1">
|
||||
<div v-for="release in archivedDrivers" :key="release.version" class="flex items-center justify-between gap-4 pt-3 border-t border-border first:border-0 first:pt-0">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-text-primary">{{ release.version }}</p>
|
||||
<p class="text-xs text-text-secondary mt-0.5">
|
||||
{{ release.published_at ? formatNullableDateTime(release.published_at) : '未知时间' }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-secondary px-3 py-1.5 min-h-0 text-xs"
|
||||
:disabled="downloadingKey === `driver-${release.version}`"
|
||||
@click="handleDownloadArchivedDriver(release)"
|
||||
>
|
||||
{{ downloadingKey === `driver-${release.version}` ? '下载中' : '下载' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-sm text-text-secondary mt-2">暂无归档驱动</p>
|
||||
</div>
|
||||
|
||||
<p v-if="resourceError" class="text-xs text-status-warning bg-status-warning/10 p-2 rounded-md border border-status-warning/20">
|
||||
{{ resourceError }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
buildArchivedXPrinterDriverDownloadUrl,
|
||||
buildPrintAgentLatestDownloadUrl,
|
||||
createPrintTerminalPairCode,
|
||||
downloadPrintResource,
|
||||
getArchivedXPrinterDrivers,
|
||||
getLatestXPrinterDriver,
|
||||
getPrintAgentReleases,
|
||||
getPrintTerminals,
|
||||
setDefaultPrintTerminal,
|
||||
updatePrintTerminalStatus
|
||||
} from '@/api/print'
|
||||
import type {
|
||||
PrintAgentReleaseInfo,
|
||||
PrintDriverLatestInfo,
|
||||
PrinterDriverRelease,
|
||||
PrintTerminal,
|
||||
PrintTerminalPairCode
|
||||
} from '@/types'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
|
||||
const terminals = ref<PrintTerminal[]>([])
|
||||
const loading = ref(false)
|
||||
const resourceLoading = ref(false)
|
||||
const pairCodeLoading = ref(false)
|
||||
const busyTerminalId = ref<number | null>(null)
|
||||
const downloadingKey = ref('')
|
||||
const pairCode = ref<PrintTerminalPairCode | null>(null)
|
||||
const printAgentReleases = ref<PrintAgentReleaseInfo[]>([])
|
||||
const latestDriver = ref<PrintDriverLatestInfo | null>(null)
|
||||
const archivedDrivers = ref<PrinterDriverRelease[]>([])
|
||||
const resourceError = ref<string | null>(null)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const onlineCount = computed(() => terminals.value.filter((item) => item.status === 'online').length)
|
||||
const readyCount = computed(() => terminals.value.filter((item) => isTerminalReady(item)).length)
|
||||
const defaultTerminal = computed(() => terminals.value.find((item) => item.is_default) || null)
|
||||
const latestAgentRelease = computed(() => printAgentReleases.value[0] || null)
|
||||
|
||||
const formatNullableDateTime = (value?: string | null) => {
|
||||
if (!value) return '未上报'
|
||||
try {
|
||||
return formatDateTime(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const triggerBlobDownload = (blob: Blob, fileName: string) => {
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = objectUrl
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
|
||||
const getStatusText = (status?: string | null) => {
|
||||
if (status === 'online') return '在线'
|
||||
if (status === 'disabled') return '已停用'
|
||||
return '离线'
|
||||
}
|
||||
|
||||
const getStatusChipClass = (status?: string | null) => {
|
||||
if (status === 'online') return 'badge-success'
|
||||
if (status === 'disabled') return 'badge-neutral'
|
||||
return 'badge-warning'
|
||||
}
|
||||
|
||||
const getPrinterName = (terminal: PrintTerminal) => {
|
||||
return terminal.selected_printer_name || terminal.printer_snapshot?.[0]?.name || '未选择打印机'
|
||||
}
|
||||
|
||||
const getTerminalError = (terminal: PrintTerminal) => {
|
||||
return (
|
||||
terminal.current_error_message ||
|
||||
terminal.latest_error_message ||
|
||||
terminal.recent_failed_job?.error_message ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
const isTerminalReady = (terminal: PrintTerminal) => {
|
||||
if (terminal.status === 'disabled') return false
|
||||
if (typeof terminal.readiness?.is_ready === 'boolean') {
|
||||
return terminal.readiness.is_ready
|
||||
}
|
||||
|
||||
if (!terminal.selected_printer_name) return false
|
||||
const profile = terminal.label_profile
|
||||
if (!profile) return false
|
||||
if (profile.is_ready === false || profile.configured === false) return false
|
||||
|
||||
const statusValue = (profile.profile_status || profile.status || '').toLowerCase()
|
||||
if (['not_ready', 'pending', 'missing', 'error'].includes(statusValue)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const getLabelProfileText = (terminal: PrintTerminal) => {
|
||||
if (terminal.readiness?.message) {
|
||||
return terminal.readiness.message
|
||||
}
|
||||
|
||||
const profile = terminal.label_profile
|
||||
if (!profile) {
|
||||
return '未完成标签配置'
|
||||
}
|
||||
|
||||
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 '未完成标签配置'
|
||||
}
|
||||
|
||||
const getTerminalReadinessText = (terminal: PrintTerminal) => {
|
||||
if (terminal.status === 'disabled') {
|
||||
return '这台终端已停用,不会再被派发远程打印任务。'
|
||||
}
|
||||
|
||||
if (terminal.status !== 'online') {
|
||||
return '电脑离线时仍会保留登记信息,等重新上线后再恢复状态。'
|
||||
}
|
||||
|
||||
return getLabelProfileText(terminal)
|
||||
}
|
||||
|
||||
const getDispatchChipText = () => {
|
||||
if (!defaultTerminal.value) return '未设置'
|
||||
if (defaultTerminal.value.status === 'disabled') return '已停用'
|
||||
if (isTerminalReady(defaultTerminal.value)) return '已就绪'
|
||||
if (defaultTerminal.value.status === 'online') return '待配置'
|
||||
return '等待上线'
|
||||
}
|
||||
|
||||
const getDispatchChipClass = () => {
|
||||
if (!defaultTerminal.value) return 'badge-neutral'
|
||||
if (defaultTerminal.value.status === 'disabled') return 'badge-neutral'
|
||||
if (isTerminalReady(defaultTerminal.value)) return 'badge-success'
|
||||
if (defaultTerminal.value.status === 'online') return 'badge-warning'
|
||||
return 'badge-warning'
|
||||
}
|
||||
|
||||
const getDispatchHeadline = () => {
|
||||
if (!defaultTerminal.value) {
|
||||
return '当前还没有默认派单电脑,先生成配对码让一台办公室电脑接入即可。'
|
||||
}
|
||||
|
||||
if (defaultTerminal.value.status === 'disabled') {
|
||||
return '默认终端已被停用,新的远程标签暂时不能正常下发。'
|
||||
}
|
||||
|
||||
if (isTerminalReady(defaultTerminal.value)) {
|
||||
return `当前会由 ${getPrinterName(defaultTerminal.value)} 自动领取并打印远程标签任务。`
|
||||
}
|
||||
|
||||
if (defaultTerminal.value.status === 'online') {
|
||||
return '电脑已经在线,但标签打印机或 60x40mm 配置还没完成。'
|
||||
}
|
||||
|
||||
return '默认打印电脑当前离线,远程打印任务会继续排队等待它重新上线。'
|
||||
}
|
||||
|
||||
const getDispatchNotice = () => {
|
||||
if (!defaultTerminal.value) {
|
||||
return '先接入第一台电脑,系统会自动把它设为默认终端。'
|
||||
}
|
||||
|
||||
if (defaultTerminal.value.status === 'disabled') {
|
||||
return '当前默认终端已停用,请先切换默认终端或重新启用。'
|
||||
}
|
||||
|
||||
if (isTerminalReady(defaultTerminal.value)) {
|
||||
return '默认终端已就绪,新的远程标签会自动进入它的队列。'
|
||||
}
|
||||
|
||||
if (defaultTerminal.value.status === 'online') {
|
||||
return '当前电脑在线,但还不能自动接单,请先完成打印机和 60x40mm 配置。'
|
||||
}
|
||||
|
||||
return '默认终端离线时,系统只排队不改派,等它重新上线后继续自动打印。'
|
||||
}
|
||||
|
||||
const loadTerminals = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
terminals.value = await getPrintTerminals()
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '获取打印终端失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadResources = async () => {
|
||||
resourceLoading.value = true
|
||||
resourceError.value = null
|
||||
|
||||
const [agentResult, latestDriverResult, archiveResult] = await Promise.allSettled([
|
||||
getPrintAgentReleases(),
|
||||
getLatestXPrinterDriver(),
|
||||
getArchivedXPrinterDrivers()
|
||||
])
|
||||
|
||||
if (agentResult.status === 'fulfilled') {
|
||||
printAgentReleases.value = agentResult.value
|
||||
} else {
|
||||
resourceError.value = '打印助手版本信息暂时读取失败,下载入口仍会保留。'
|
||||
}
|
||||
|
||||
if (latestDriverResult.status === 'fulfilled') {
|
||||
latestDriver.value = latestDriverResult.value
|
||||
} else {
|
||||
resourceError.value = '芯烨官方驱动信息暂时读取失败。'
|
||||
}
|
||||
|
||||
if (archiveResult.status === 'fulfilled') {
|
||||
archivedDrivers.value = archiveResult.value
|
||||
} else {
|
||||
resourceError.value = '公司归档版驱动列表暂时读取失败。'
|
||||
}
|
||||
|
||||
resourceLoading.value = false
|
||||
}
|
||||
|
||||
const loadAll = async () => {
|
||||
await Promise.all([loadTerminals(), loadResources()])
|
||||
}
|
||||
|
||||
const handleCreatePairCode = async () => {
|
||||
pairCodeLoading.value = true
|
||||
try {
|
||||
pairCode.value = await createPrintTerminalPairCode()
|
||||
alert('新的配对码已生成')
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '生成配对码失败')
|
||||
} finally {
|
||||
pairCodeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copyPairCode = async () => {
|
||||
if (!pairCode.value?.code) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(pairCode.value.code)
|
||||
alert('配对码已复制')
|
||||
} catch {
|
||||
alert('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetDefault = async (terminal: PrintTerminal) => {
|
||||
if (terminal.is_default) return
|
||||
|
||||
busyTerminalId.value = terminal.id
|
||||
try {
|
||||
await setDefaultPrintTerminal(terminal.id)
|
||||
await loadTerminals()
|
||||
alert(`已将 ${terminal.name} 设为默认打印电脑`)
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '设置默认打印终端失败')
|
||||
} finally {
|
||||
busyTerminalId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (terminal: PrintTerminal) => {
|
||||
const nextStatus = terminal.status === 'disabled' ? 'offline' : 'disabled'
|
||||
const confirmText =
|
||||
nextStatus === 'disabled'
|
||||
? '停用后新的远程打印任务不会再发给这台电脑,确定继续吗?'
|
||||
: '重新启用后,这台电脑会在助手下一次心跳后恢复接单状态,确定继续吗?'
|
||||
|
||||
if (!window.confirm(confirmText)) {
|
||||
return
|
||||
}
|
||||
|
||||
busyTerminalId.value = terminal.id
|
||||
try {
|
||||
await updatePrintTerminalStatus(terminal.id, nextStatus)
|
||||
await loadTerminals()
|
||||
alert(nextStatus === 'disabled' ? '打印终端已停用' : '打印终端已重新启用')
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '更新终端状态失败')
|
||||
} finally {
|
||||
busyTerminalId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadPrintAgent = async () => {
|
||||
downloadingKey.value = 'agent'
|
||||
try {
|
||||
const url = latestAgentRelease.value?.download_url || buildPrintAgentLatestDownloadUrl()
|
||||
const { blob, fileName } = await downloadPrintResource(url)
|
||||
triggerBlobDownload(blob, latestAgentRelease.value?.filename || fileName || 'print-agent-setup.exe')
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '下载打印助手失败')
|
||||
} finally {
|
||||
downloadingKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadArchivedDriver = async (release: PrinterDriverRelease) => {
|
||||
const key = `driver-${release.version}`
|
||||
downloadingKey.value = key
|
||||
try {
|
||||
const url = release.download_url || buildArchivedXPrinterDriverDownloadUrl(release.version)
|
||||
const { blob, fileName } = await downloadPrintResource(url)
|
||||
triggerBlobDownload(blob, release.filename || fileName || `xprinter-driver-${release.version}.exe`)
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '下载归档版驱动失败')
|
||||
} finally {
|
||||
downloadingKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const openExternal = (url?: string | null) => {
|
||||
if (!url) {
|
||||
alert('官方驱动入口暂时不可用')
|
||||
return
|
||||
}
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 所有自定义的控制台样式已被移除,使用全局 tailwind/style.css 标准组件 */
|
||||
</style>
|
||||
27
print-agent-dotnet.sln
Normal file
27
print-agent-dotnet.sln
Normal file
@ -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
|
||||
@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>0.2.0</Version>
|
||||
<AssemblyVersion>0.2.0.0</AssemblyVersion>
|
||||
<FileVersion>0.2.0.0</FileVersion>
|
||||
<Company>常州简尚家具有限公司</Company>
|
||||
<Product>固定资产打印助手</Product>
|
||||
<DebugType>none</DebugType>
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<SelfContained>true</SelfContained>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="QRCoder" Version="1.7.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
365
print-agent-dotnet/FixedAssetPrintAgent/LabelPrintService.cs
Normal file
365
print-agent-dotnet/FixedAssetPrintAgent/LabelPrintService.cs
Normal file
@ -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<PrinterInfo> ListPrinters()
|
||||
{
|
||||
var defaultPrinter = string.Empty;
|
||||
try
|
||||
{
|
||||
var settings = new PrinterSettings();
|
||||
defaultPrinter = settings.PrinterName;
|
||||
}
|
||||
catch
|
||||
{
|
||||
defaultPrinter = string.Empty;
|
||||
}
|
||||
|
||||
return PrinterSettings.InstalledPrinters
|
||||
.Cast<string>()
|
||||
.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<int> PrintAssetLabelsAsync(string printerName, IReadOnlyList<AssetLabelItem> 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<Bitmap> 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<string> 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..]];
|
||||
}
|
||||
}
|
||||
511
print-agent-dotnet/FixedAssetPrintAgent/MainForm.cs
Normal file
511
print-agent-dotnet/FixedAssetPrintAgent/MainForm.cs
Normal file
@ -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<Task> 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
286
print-agent-dotnet/FixedAssetPrintAgent/PrintAgentModels.cs
Normal file
286
print-agent-dotnet/FixedAssetPrintAgent/PrintAgentModels.cs
Normal file
@ -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<AssetLabelItem> 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<PrintAgentReleaseInfo> 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<PrinterInfo> Printers { get; set; } = [];
|
||||
public List<PrintAgentReleaseInfo> Releases { get; set; } = [];
|
||||
public DriverLatestInfo? LatestDriver { get; set; }
|
||||
public List<DriverArchiveRelease> 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<string> 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]
|
||||
};
|
||||
}
|
||||
}
|
||||
846
print-agent-dotnet/FixedAssetPrintAgent/PrintAgentRuntime.cs
Normal file
846
print-agent-dotnet/FixedAssetPrintAgent/PrintAgentRuntime.cs
Normal file
@ -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<PrintAgentRuntimeState>? 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<AssetLabelPayload>(
|
||||
job.PayloadJson.GetRawText(),
|
||||
PrintAgentApiClient.SerializerOptions);
|
||||
}
|
||||
|
||||
if (job.PayloadJson.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var raw = job.PayloadJson.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
return JsonSerializer.Deserialize<AssetLabelPayload>(
|
||||
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<Task> 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<AgentConfiguration> LoadAsync()
|
||||
{
|
||||
if (!File.Exists(_configFilePath))
|
||||
{
|
||||
return new AgentConfiguration();
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(_configFilePath);
|
||||
return await JsonSerializer.DeserializeAsync<AgentConfiguration>(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<PairResponse> PairAsync(
|
||||
string serverUrl,
|
||||
string terminalName,
|
||||
string machineName,
|
||||
string platform,
|
||||
string pairCode)
|
||||
{
|
||||
return SendAsync<PairResponse>(
|
||||
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<PrinterInfo> printerSnapshot,
|
||||
PrintLabelProfile labelProfile)
|
||||
{
|
||||
return SendAsync<object>(
|
||||
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<PrintJobClaimData> ClaimNextJobAsync(string serverUrl, string deviceToken, int terminalId)
|
||||
{
|
||||
return SendAsync<PrintJobClaimData>(
|
||||
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<object>(
|
||||
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<PrinterInfo> printerSnapshot,
|
||||
PrintLabelProfile labelProfile,
|
||||
bool success,
|
||||
string? errorMessage)
|
||||
{
|
||||
return SendAsync<object>(
|
||||
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<PrintAgentReleaseCollection> GetReleasesAsync(string serverUrl)
|
||||
{
|
||||
return SendAsync<PrintAgentReleaseCollection>(
|
||||
serverUrl,
|
||||
"/downloads/print-agent/releases",
|
||||
HttpMethod.Get,
|
||||
null,
|
||||
null,
|
||||
false);
|
||||
}
|
||||
|
||||
public Task<DriverLatestInfo> GetLatestDriverAsync(string serverUrl)
|
||||
{
|
||||
return SendAsync<DriverLatestInfo>(
|
||||
serverUrl,
|
||||
"/downloads/printer-driver/xprinter/latest",
|
||||
HttpMethod.Get,
|
||||
null,
|
||||
null,
|
||||
false);
|
||||
}
|
||||
|
||||
public Task<List<DriverArchiveRelease>> GetArchivedDriversAsync(string serverUrl)
|
||||
{
|
||||
return SendAsync<List<DriverArchiveRelease>>(
|
||||
serverUrl,
|
||||
"/downloads/printer-driver/xprinter/archive",
|
||||
HttpMethod.Get,
|
||||
null,
|
||||
null,
|
||||
false);
|
||||
}
|
||||
|
||||
private async Task<T> SendAsync<T>(
|
||||
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<T>();
|
||||
}
|
||||
|
||||
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<T>(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();
|
||||
}
|
||||
}
|
||||
17
print-agent-dotnet/FixedAssetPrintAgent/Program.cs
Normal file
17
print-agent-dotnet/FixedAssetPrintAgent/Program.cs
Normal file
@ -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());
|
||||
}
|
||||
}
|
||||
93
print-agent-dotnet/build-release.ps1
Normal file
93
print-agent-dotnet/build-release.ps1
Normal file
@ -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 清单和下载包。"
|
||||
43
print-agent-dotnet/installer/FixedAssetPrintAgent.iss
Normal file
43
print-agent-dotnet/installer/FixedAssetPrintAgent.iss
Normal file
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user