feat(打印): 新增远程标签打印与 .NET 打印助手

- 新增打印终端、配对码、打印任务相关表结构与接口\n- 增加后台打印终端/任务管理页和资产标签远程打印入口\n- 引入 .NET 8 Windows 打印助手源码及下载清单配置
This commit is contained in:
WuYongJie 2026-03-25 21:05:30 +08:00
parent 9519c10b78
commit c7a9324635
40 changed files with 7197 additions and 160 deletions

12
.gitignore vendored
View File

@ -27,3 +27,15 @@ migration_manual_overrides.json
05-settings-page.png 05-settings-page.png
nul 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

View File

@ -7,6 +7,7 @@
"dev": "nodemon --exec ts-node src/index.ts", "dev": "nodemon --exec ts-node src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "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", "seed:demo": "node scripts/seedDemoData.js",
"repair:mojibake": "node scripts/repairMojibake.js", "repair:mojibake": "node scripts/repairMojibake.js",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",

View File

@ -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;

View File

@ -168,12 +168,87 @@ model User {
processed_sync_changes SyncChange[] @relation("SyncChangeProcessedBy") processed_sync_changes SyncChange[] @relation("SyncChangeProcessedBy")
inventory_tasks_created InventoryTask[] @relation("InventoryTaskCreatedBy") inventory_tasks_created InventoryTask[] @relation("InventoryTaskCreatedBy")
inventory_actions InventoryTaskAction[] @relation("InventoryTaskActionOperator") inventory_actions InventoryTaskAction[] @relation("InventoryTaskActionOperator")
print_terminal_pair_codes PrintTerminalPairCode[] @relation("PrintTerminalPairCodeCreator")
requested_print_jobs PrintJob[] @relation("PrintJobRequester")
@@index([role]) @@index([role])
@@index([status]) @@index([status])
@@map("user") @@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 { model SiteConfig {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
site_name String @db.VarChar(100) site_name String @db.VarChar(100)

View 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, '测试打印状态上报失败')
}
}

View 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)
}
}

View File

@ -27,6 +27,10 @@ import syncRoutes from './routes/sync'
import notificationRoutes from './routes/notifications' import notificationRoutes from './routes/notifications'
import inventoryRoutes from './routes/inventory' import inventoryRoutes from './routes/inventory'
import publicInventoryRoutes from './routes/publicInventory' 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 { keycloak, memoryStore } from './utils/keycloak'
import { protect, requireAdmin } from './middleware/auth' import { protect, requireAdmin } from './middleware/auth'
import { startHrSyncScheduler } from './services/hrSyncService' import { startHrSyncScheduler } from './services/hrSyncService'
@ -85,6 +89,10 @@ app.use('/api/sync', protect(), requireAdmin(), syncRoutes)
app.use('/api/notifications', notificationRoutes) app.use('/api/notifications', notificationRoutes)
app.use('/api/inventory', inventoryRoutes) app.use('/api/inventory', inventoryRoutes)
app.use('/api/public/inventory', publicInventoryRoutes) 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 // Health check
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {

View 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

View 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

View 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

View 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

View 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

View 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')
})

View 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
}
}

File diff suppressed because it is too large Load Diff

View File

@ -50,12 +50,14 @@ services:
HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-5} HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-5}
HR_SYNC_EXCLUDED_DEPARTMENTS: ${HR_SYNC_EXCLUDED_DEPARTMENTS:-供应商,快递公司,外部交流} HR_SYNC_EXCLUDED_DEPARTMENTS: ${HR_SYNC_EXCLUDED_DEPARTMENTS:-供应商,快递公司,外部交流}
HR_SYNC_EXCLUDED_NAME_KEYWORDS: ${HR_SYNC_EXCLUDED_NAME_KEYWORDS:-} HR_SYNC_EXCLUDED_NAME_KEYWORDS: ${HR_SYNC_EXCLUDED_NAME_KEYWORDS:-}
DOWNLOADS_DIR: /app/downloads
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
volumes: volumes:
- backend_uploads:/app/uploads - backend_uploads:/app/uploads
- ./frontend/public/branding:/app/branding:ro - ./frontend/public/branding:/app/branding:ro
- ./downloads:/app/downloads:ro
# 前端服务 # 前端服务
frontend: frontend:

View 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"
}

View 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"
}

View 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"
}

View File

@ -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"
}

View 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": "芯烨官方最新版下载入口,页面内容会随官方更新。"
}

View File

@ -45,6 +45,16 @@ server {
proxy_set_header X-Real-IP $remote_addr; 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)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";
@ -97,6 +107,16 @@ server {
proxy_set_header X-Real-IP $remote_addr; 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)$ { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y; expires 1y;
add_header Cache-Control "public, immutable"; add_header Cache-Control "public, immutable";

View 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
View 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'
}
}

View File

@ -12,6 +12,40 @@
</div> </div>
<div class="space-y-4"> <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 class="grid gap-4 lg:grid-cols-2">
<div <div
v-for="equipment in equipmentList" v-for="equipment in equipmentList"
@ -42,6 +76,9 @@
<button @click="handlePrint" class="btn-primary flex-1" :disabled="loading"> <button @click="handlePrint" class="btn-primary flex-1" :disabled="loading">
{{ loading ? '生成中...' : '打印二维码' }} {{ loading ? '生成中...' : '打印二维码' }}
</button> </button>
<button @click="handleRemotePrint" class="btn-secondary flex-1" :disabled="remoteActionDisabled">
{{ remoteSubmitting ? '发送中...' : '发送到办公室打印' }}
</button>
<button @click="handleDownload" class="btn-secondary flex-1" :disabled="loading"> <button @click="handleDownload" class="btn-secondary flex-1" :disabled="loading">
{{ loading ? '生成中...' : '下载图片' }} {{ loading ? '生成中...' : '下载图片' }}
</button> </button>
@ -54,7 +91,15 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { getEquipmentBatchForBarcode } from '@/api/barcode' 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 { interface EquipmentBarcode {
id: number id: number
@ -86,33 +131,139 @@ const LABEL_PRESET = {
heightMm: 40, heightMm: 40,
previewWidthPx: 360, previewWidthPx: 360,
previewHeightPx: 240, previewHeightPx: 240,
canvasWidthPx: 709, // 300 DPI canvasWidthPx: 709,
canvasHeightPx: 472, // 300 DPI canvasHeightPx: 472,
qrSizePx: 260, // 二维码尺寸 outerInsetPx: 10,
paddingPx: 14, headerHeightPx: 82,
qrPanelWidthPx: 324,
qrCodeSizePx: 286,
companyName: '常州简尚家具有限公司' companyName: '常州简尚家具有限公司'
} as const } as const
const siteStore = useSiteStore()
const equipmentList = ref<EquipmentBarcode[]>([]) const equipmentList = ref<EquipmentBarcode[]>([])
const previewImages = ref<Record<number, string>>({}) const previewImages = ref<Record<number, string>>({})
const loading = ref(false) 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(() => ({ const previewCardStyle = computed(() => ({
width: '100%', width: '100%',
maxWidth: `${LABEL_PRESET.previewWidthPx}px`, maxWidth: `${LABEL_PRESET.previewWidthPx}px`,
aspectRatio: `${LABEL_PRESET.previewWidthPx} / ${LABEL_PRESET.previewHeightPx}`, aspectRatio: `${LABEL_PRESET.previewWidthPx} / ${LABEL_PRESET.previewHeightPx}`,
background: '#ffffff', background: '#FFFFFF',
borderRadius: '16px', borderRadius: '0',
overflow: 'hidden', overflow: 'hidden'
boxShadow: '0 1px 4px rgba(0,0,0,0.1)'
})) }))
const formatDate = (value?: string) => { const formatNullableDateTime = (value?: string | null) => {
if (!value) return '' if (!value) return '未上报'
return value.includes('T') ? value.split('T')[0] : value 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) => const loadImage = (src: string) =>
new Promise<HTMLImageElement>((resolve, reject) => { new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image() const image = new Image()
@ -121,6 +272,107 @@ const loadImage = (src: string) =>
image.src = src 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 renderCardToCanvas = async (equipment: EquipmentBarcode) => {
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = LABEL_PRESET.canvasWidthPx canvas.width = LABEL_PRESET.canvasWidthPx
@ -131,178 +383,250 @@ const renderCardToCanvas = async (equipment: EquipmentBarcode) => {
throw new Error('无法创建画布') throw new Error('无法创建画布')
} }
const { canvasWidthPx, canvasHeightPx, qrSizePx } = LABEL_PRESET const {
canvasWidthPx,
canvasHeightPx,
outerInsetPx,
headerHeightPx,
qrPanelWidthPx,
qrCodeSizePx
} = LABEL_PRESET
// 纯白背景 const display = buildIndustrialCardDisplay({
ctx.fillStyle = '#ffffff' 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.fillRect(0, 0, canvasWidthPx, canvasHeightPx)
ctx.strokeStyle = '#000000'
ctx.lineWidth = 8
ctx.strokeRect(4, 4, canvasWidthPx - 8, canvasHeightPx - 8)
// ═══════════════════════════════════════════════════════ const contentX = outerInsetPx
// 60x40mm 标签设计 - 信息丰富 + 大字体 (300 DPI) 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.fillStyle = '#000000'
ctx.fillRect(0, 0, leftSectionWidth, canvasHeightPx) ctx.fillRect(contentX, contentY, contentWidth, headerHeightPx)
// 斜切装饰 - 右上角白色三角形 const whiteBodyMeasure = createMeasureText(ctx, '700', BODY_FONT)
ctx.fillStyle = '#ffffff' const companyFit = fitSingleLineText(
ctx.beginPath() {
ctx.moveTo(leftSectionWidth - 40, 0) text: display.companyName,
ctx.lineTo(leftSectionWidth, 0) maxWidth: contentWidth - 110,
ctx.lineTo(leftSectionWidth, 40) maxFontSize: 32,
ctx.closePath() minFontSize: 22,
ctx.fill() 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'
})
// 二维码位置 - 左侧居中 ctx.fillStyle = '#FFFFFF'
const qrX = Math.floor((leftSectionWidth - qrSizePx) / 2) + 4 ctx.fillRect(contentX + contentWidth - 52, contentY + 16, 24, 24)
const qrY = Math.floor((canvasHeightPx - qrSizePx) / 2) ctx.fillRect(contentX + contentWidth - 20, contentY + 16, 5, 24)
// 生成二维码 const mainY = contentY + headerHeightPx + 10
const qrDataUrl = await QRCode.toDataURL(equipment.equipment_code, { const mainHeight = canvasHeightPx - mainY - outerInsetPx
width: qrSizePx, const qrPanelX = contentX
margin: 0, 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: { color: {
dark: '#000000', dark: '#000000',
light: '#ffffff' light: '#FFFFFF'
}, },
errorCorrectionLevel: 'H' errorCorrectionLevel: 'H'
}) })
const qrImage = await loadImage(qrDataUrl) const qrImage = await loadImage(qrDataUrl)
const qrImageX = qrFrameX + Math.floor((qrPanelInnerSize - qrCodeSizePx) / 2)
const qrImageY = qrFrameY + Math.floor((qrPanelInnerSize - qrCodeSizePx) / 2)
// 二维码白色背景块 ctx.imageSmoothingEnabled = false
ctx.fillStyle = '#ffffff' ctx.drawImage(qrImage, qrImageX, qrImageY, qrCodeSizePx, qrCodeSizePx)
ctx.fillRect(qrX - 6, qrY - 6, qrSizePx + 12, qrSizePx + 12) ctx.imageSmoothingEnabled = true
// 绘制二维码 const leftCaptionMeasure = createMeasureText(ctx, '900', COMPACT_FONT)
ctx.drawImage(qrImage, qrX, qrY, qrSizePx, qrSizePx) 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'
})
// 二维码外框 - 粗黑线 const rightX = qrPanelX + qrPanelWidthPx + 10
ctx.strokeStyle = '#000000' const rightWidth = canvasWidthPx - rightX - outerInsetPx
ctx.lineWidth = 3 const blackCompactMeasure = createMeasureText(ctx, '900', COMPACT_FONT)
ctx.strokeRect(qrX - 6, qrY - 6, qrSizePx + 12, qrSizePx + 12) 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.fillStyle = '#000000'
ctx.fillRect(qrX - 10, qrY - 10, cornerSize, cornerSize) ctx.fillRect(rightX, mainY, typeBadgeWidth, typeBadgeHeight)
ctx.fillRect(qrX + qrSizePx - 4, qrY - 10, cornerSize, cornerSize) drawTextLine(ctx, {
ctx.fillRect(qrX - 10, qrY + qrSizePx - 4, cornerSize, cornerSize) text: headerFit.text,
ctx.fillRect(qrX + qrSizePx - 4, qrY + qrSizePx - 4, cornerSize, cornerSize) x: rightX + typeBadgeWidth / 2,
y: mainY + typeBadgeHeight / 2 + 1,
fontSize: headerFit.fontSize,
weight: '900',
family: COMPACT_FONT,
color: '#FFFFFF',
align: 'center',
baseline: 'middle'
})
// 公司名称竖排 - 左侧边缘 drawRule(
ctx.save() ctx,
ctx.translate(10, canvasHeightPx / 2) rightX + typeBadgeWidth + 12,
ctx.rotate(-Math.PI / 2) mainY + Math.floor(typeBadgeHeight / 2),
ctx.fillStyle = '#000000' Math.max(0, rightWidth - typeBadgeWidth - 12),
ctx.textAlign = 'center' 3
ctx.textBaseline = 'middle' )
ctx.font = 'bold 12px "Microsoft YaHei", "PingFang SC", Arial, sans-serif'
ctx.fillText('常州简尚家具有限公司', 0, 0)
ctx.restore()
// ═══════════════════════════════════════════════════════ 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 codeBottomY = codeY + codeLines.length * codeLineHeight
const infoY = 18 drawRule(ctx, rightX, codeBottomY + 10, rightWidth, 4)
// 完整公司名称 - 两行分散布局 const purchaseDateLabelY = codeBottomY + 18
ctx.fillStyle = '#000000' drawLabelCell(ctx, '采购日期', rightX, purchaseDateLabelY, 142, 40, 16)
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)
// 粗分隔线 let purchaseDateBottomY = purchaseDateLabelY + 40
ctx.strokeStyle = '#000000' if (display.purchaseDateText) {
ctx.lineWidth = 3 const purchaseDateMeasure = createMeasureText(ctx, '800', BODY_FONT)
ctx.beginPath() const purchaseDateFit = fitSingleLineText(
ctx.moveTo(infoX, infoY + 68) {
ctx.lineTo(canvasWidthPx - 12, infoY + 68) text: display.purchaseDateText,
ctx.stroke() maxWidth: rightWidth,
maxFontSize: 28,
minFontSize: 22,
allowTruncate: true
},
purchaseDateMeasure
)
// 资产名称 - 最大最突出 const purchaseDateTextY = purchaseDateLabelY + 50
ctx.fillStyle = '#000000' drawTextLine(ctx, {
ctx.font = 'bold 41px "Microsoft YaHei", "PingFang SC", Arial, sans-serif' text: purchaseDateFit.text,
const nameText = equipment.equipment_name || '-' x: rightX,
let displayName = nameText y: purchaseDateTextY,
const maxNameWidth = rightSectionWidth - 24 fontSize: purchaseDateFit.fontSize,
if (ctx.measureText(nameText).width > maxNameWidth) { weight: '800',
while (ctx.measureText(displayName + '...').width > maxNameWidth && displayName.length > 0) { family: BODY_FONT,
displayName = displayName.slice(0, -1) color: '#000000'
} })
displayName += '...'
}
ctx.fillText(displayName, infoX, infoY + 82)
// 型号 (如果有) - 放大确保打印清晰 purchaseDateBottomY = purchaseDateTextY + purchaseDateFit.fontSize
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
} }
// 编码 - 更大字体 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.fillStyle = '#000000'
ctx.font = 'bold 30px "Microsoft YaHei", "PingFang SC", Arial, sans-serif' ctx.fillRect(rightX + rightWidth - 30, bottomAccentY - 10, 30, 22)
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()
return canvas return canvas
} }
@ -344,11 +668,25 @@ const ensureImagesGenerated = async (): Promise<string[]> => {
return getCachedImages() ?? syncPreviewImages() 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 () => { const fetchEquipment = async () => {
loading.value = true loading.value = true
try { try {
await siteStore.fetchSiteConfig()
const response = await getEquipmentBatchForBarcode(props.equipmentIds) const response = await getEquipmentBatchForBarcode(props.equipmentIds)
const data = response.data || response 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 () => { const handlePrint = async () => {
if (equipmentList.value.length === 0) { if (equipmentList.value.length === 0) {
alert('没有可打印的二维码') alert('没有可打印的二维码')
@ -470,14 +844,15 @@ const handleDownload = async () => {
onMounted(() => { onMounted(() => {
fetchEquipment() fetchEquipment()
fetchDefaultTerminal()
}) })
</script> </script>
<style scoped> <style scoped>
.qrcode-card { .qrcode-card {
border: 1px solid rgba(0, 0, 0, 0.08); border: 2px solid #000000;
background: #ffffff; background: #ffffff;
border-radius: 12px; border-radius: 0;
padding: 12px; padding: 12px;
} }
@ -493,7 +868,7 @@ onMounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--text-tertiary); color: #000000;
font-size: 14px; font-size: 14px;
} }
</style> </style>

View 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
}
}

View File

@ -701,3 +701,153 @@ export interface NotificationItem {
related_sync_job_id?: number | null related_sync_job_id?: number | null
metadata?: NotificationMetadata | 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
}

View File

@ -45,6 +45,8 @@
<DepartmentManagement v-else-if="activeTab === 'departments'" /> <DepartmentManagement v-else-if="activeTab === 'departments'" />
<MaintenanceManagement v-else-if="activeTab === 'maintenance'" /> <MaintenanceManagement v-else-if="activeTab === 'maintenance'" />
<DisposalManagement v-else-if="activeTab === 'disposal'" /> <DisposalManagement v-else-if="activeTab === 'disposal'" />
<PrintTerminalManagement v-else-if="activeTab === 'printTerminals'" />
<PrintJobManagement v-else-if="activeTab === 'printJobs'" />
<OperationLogList v-else-if="activeTab === 'logs'" /> <OperationLogList v-else-if="activeTab === 'logs'" />
</div> </div>
</div> </div>
@ -63,6 +65,8 @@ import DepartmentManagement from './components/DepartmentManagement.vue'
import MaintenanceManagement from './components/MaintenanceManagement.vue' import MaintenanceManagement from './components/MaintenanceManagement.vue'
import DisposalManagement from './components/DisposalManagement.vue' import DisposalManagement from './components/DisposalManagement.vue'
import OperationLogList from './components/OperationLogList.vue' import OperationLogList from './components/OperationLogList.vue'
import PrintTerminalManagement from './components/PrintTerminalManagement.vue'
import PrintJobManagement from './components/PrintJobManagement.vue'
type TabKey = type TabKey =
| 'site' | 'site'
@ -74,6 +78,8 @@ type TabKey =
| 'departments' | 'departments'
| 'maintenance' | 'maintenance'
| 'disposal' | 'disposal'
| 'printTerminals'
| 'printJobs'
| 'logs' | 'logs'
const tabs: Array<{ key: TabKey; label: string }> = [ const tabs: Array<{ key: TabKey; label: string }> = [
@ -86,6 +92,8 @@ const tabs: Array<{ key: TabKey; label: string }> = [
{ key: 'departments', label: '组织架构' }, { key: 'departments', label: '组织架构' },
{ key: 'maintenance', label: '维修管理' }, { key: 'maintenance', label: '维修管理' },
{ key: 'disposal', label: '报废管理' }, { key: 'disposal', label: '报废管理' },
{ key: 'printTerminals', label: '打印终端' },
{ key: 'printJobs', label: '打印任务' },
{ key: 'logs', label: '操作日志' } { key: 'logs', label: '操作日志' }
] ]

View 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>

View File

@ -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
View 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

View File

@ -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>

View 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..]];
}
}

View 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;
}
}

View File

@ -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();
}
}

View 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]
};
}
}

View 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();
}
}

View 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());
}
}

View 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 清单和下载包。"

View 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