feat(资产与打印): 提交近期借还盘点与打印助手源码

- 提交借用单缺失异常处理、扫码借还搜索与站内消息快捷处理源码
- 提交盘点范围、多终端打印、组织同步与认证模式相关源码
- 提交 .NET/Node 打印助手及版本清单源码
This commit is contained in:
WuYongJie 2026-03-27 20:18:31 +08:00
parent c7a9324635
commit 0dc3c09315
110 changed files with 16051 additions and 965 deletions

View File

@ -0,0 +1,550 @@
const fs = require('fs')
const path = require('path')
const { spawnSync } = require('child_process')
const XLSX = require(path.resolve(__dirname, '../../frontend/node_modules/xlsx'))
const ExcelJS = require(path.resolve(__dirname, '../node_modules/exceljs'))
const { resolveStorageLocationMatch } = require('./locationNormalization')
const DEFAULT_SOURCE_WORKBOOK = path.resolve(__dirname, '../../docs/总表待导入.xlsx')
const DEFAULT_SOURCE_SHEET = 'Sheet2'
const DEFAULT_CANDIDATE_WORKBOOK = path.resolve(
__dirname,
'../../docs/总表待导入-Sheet2_严格可安全补导候选-2026-03-27.xlsx'
)
const OUTPUT_ROOT = path.resolve(__dirname, '../../temp_migration_output')
const DOCS_ROOT = path.resolve(__dirname, '../../docs')
const DEFAULT_OUTPUT_WORKBOOK_BASENAME = '总表待导入-Sheet2_公共设备补导分流-2026-03-27.xlsx'
const PLINK_PATH = process.env.FIXED_ASSET_PLINK_PATH || 'C:\\Program Files\\PuTTY\\plink.exe'
const SSH_HOST = process.env.FIXED_ASSET_SSH_HOST || 'root@1.13.22.133'
const SSH_HOSTKEY =
process.env.FIXED_ASSET_SSH_HOSTKEY || 'SHA256:huTZZAYXh4yd5AvE4jo7drXaF/WSFF0SkzYHoddbMLA'
const SSH_PASSWORD = process.env.FIXED_ASSET_SSH_PASSWORD || 'jsjj8888'
const REMOTE_NODE_COMMAND = 'cd /opt/equipment-management && docker exec -i equipment-backend node'
const DEPARTMENT_ALIASES = {
'3D视觉(广东)': '视觉中心',
'3D视觉(常州)': '视觉中心',
事业一部: '运营中心A',
事业二部: '运营中心A',
事业三部: '运营中心A',
事业四部: '运营中心A',
策划一部: '品牌策划中心',
策划二部: '品牌策划中心',
策划三部: '品牌策划中心',
策划四部: '品牌策划中心',
策划五部: '品牌策划中心',
策划六部: '品牌策划中心',
客服二部: '客服中心',
客服三部: '客服中心',
客服四部: '客服中心',
客服五部: '客服中心',
客服六部: '客服中心',
品牌营销策划中心: '品牌策划中心',
品牌创意视觉中心: '视觉中心',
品牌供应链中心: '供应链中心',
智管信息中心: '效率部(懂业务的技术部)',
总账组: '财务中心',
结算组: '财务中心',
视觉一部: '视觉中心',
视觉二部: '视觉中心',
视觉三部: '视觉中心',
视觉四部: '视觉中心',
视觉五部: '视觉中心',
视觉六部: '视觉中心',
'基本盘 采购部': '供应链中心',
'基本盘 核价组': '供应链中心',
产品视觉中心: '视觉中心',
分销外贸: '外贸部'
}
const PUBLIC_STATUS_MAP = {
在用: '可用',
闲置: '可用'
}
const SHEET2_LOCATION_OVERRIDES = {
山东: '济南',
运营中心: '常州-天安',
客服中心: '常州-天安',
人力行政中心: '常州-天安',
庙西宿舍: '常州-仓库',
财务中心: '常州-天安',
天安17楼: '常州-天安',
运营中心B: '常州-天安',
供应链中心: '常州-天安',
'2楼': '常州-天安',
湖塘摄影棚: '影棚',
睢宁仓: '睢宁仓库',
横林二仓仓库: '常州-仓库',
横山桥: '常州-仓库',
客服二部: '常州-天安',
苏北仓: '苏北仓库',
仓储中心: '常州-仓库',
横林二仓二楼会议室: '常州-仓库',
横林二仓二楼张总办公室: '常州-仓库',
红星美凯龙: '常州-美凯龙',
天安三楼: '常州-天安',
'1楼 客服四部': '常州-天安',
'3楼 智管信息中心': '常州-天安',
常州运c: '常州-天安',
二楼: '常州-天安',
分销外贸: '常州-天安',
佛山顺德仓: '佛山-顺德仓库',
河北仓: '河北-仓库',
横林二仓采购办公室: '常州-仓库',
横林二仓仓库办公室: '常州-仓库',
横林二仓二楼顾总办公室: '常州-仓库',
横林二仓二楼贾总办公室: '常州-仓库',
横林二仓二楼朱总办公室: '常州-仓库',
横林二仓一楼仓储办公室: '常州-仓库',
客服六部: '常州-天安',
三楼: '常州-天安',
山东曹县: '山东-曹县',
事业一部: '常州-天安',
天安10号楼三楼机房: '常州-天安',
天安10号楼四楼厨房: '常州-天安',
天安10号楼一楼机房: '常州-天安',
天安四楼会议室: '常州-天安',
运营八部: '常州-天安',
运营十部: '常州-天安',
总经理室: '常州-天安'
}
const normalizeText = (value) =>
String(value == null ? '' : value)
.replace(/\r/g, ' ')
.replace(/\n/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const normalizeLookupKey = (value) => normalizeText(value).toUpperCase()
const buildTimestampLabel = () =>
new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19)
const parseArgs = () => {
const args = process.argv.slice(2)
const options = {
sourceWorkbook: DEFAULT_SOURCE_WORKBOOK,
sourceSheet: DEFAULT_SOURCE_SHEET,
candidateWorkbook: DEFAULT_CANDIDATE_WORKBOOK,
outputWorkbook: path.join(DOCS_ROOT, DEFAULT_OUTPUT_WORKBOOK_BASENAME)
}
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--source-workbook') {
options.sourceWorkbook = args[index + 1] || options.sourceWorkbook
index += 1
} else if (arg === '--source-sheet') {
options.sourceSheet = args[index + 1] || options.sourceSheet
index += 1
} else if (arg === '--candidate-workbook') {
options.candidateWorkbook = args[index + 1] || options.candidateWorkbook
index += 1
} else if (arg === '--output-workbook') {
options.outputWorkbook = args[index + 1] || options.outputWorkbook
index += 1
}
}
return options
}
const resolvePathArg = (value) => {
const normalized = normalizeText(value)
if (!normalized) {
return ''
}
return path.isAbsolute(normalized) ? normalized : path.resolve(process.cwd(), normalized)
}
const ensureOutputDir = () => {
const timestamp = buildTimestampLabel()
const outputDir = path.join(OUTPUT_ROOT, `sheet2_public_dry_run_${timestamp}`)
fs.mkdirSync(outputDir, { recursive: true })
return outputDir
}
const writeJson = (filePath, value) => {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`)
}
const writeWorkbook = async (filePath, sheets) => {
const workbook = new ExcelJS.Workbook()
workbook.creator = 'Codex'
workbook.created = new Date()
for (const sheet of sheets) {
const worksheet = workbook.addWorksheet(sheet.name)
const rows = Array.isArray(sheet.rows) && sheet.rows.length ? sheet.rows : [{}]
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))]
worksheet.columns = headers.map((header) => ({
header,
key: header,
width: Math.min(Math.max(header.length + 6, 14), 40)
}))
rows.forEach((row) => worksheet.addRow(row))
worksheet.getRow(1).font = { bold: true, name: 'Arial' }
worksheet.views = [{ state: 'frozen', ySplit: 1 }]
worksheet.eachRow((row) => {
row.font = { name: 'Arial' }
row.alignment = { vertical: 'middle', wrapText: true }
})
}
await workbook.xlsx.writeFile(filePath)
}
const resolveAvailableWorkbookPath = (preferredPath) => {
const resolvedPreferredPath = resolvePathArg(preferredPath)
if (!resolvedPreferredPath) {
throw new Error('输出工作簿路径不能为空')
}
const workbookDir = path.dirname(resolvedPreferredPath)
const extension = path.extname(resolvedPreferredPath) || '.xlsx'
const baseName = path.basename(resolvedPreferredPath, extension)
const timestampedPath = path.join(workbookDir, `${baseName}-${buildTimestampLabel()}${extension}`)
try {
const fd = fs.openSync(resolvedPreferredPath, 'a')
fs.closeSync(fd)
return resolvedPreferredPath
} catch (error) {
if (error && (error.code === 'EBUSY' || error.code === 'EPERM')) {
return timestampedPath
}
throw error
}
}
const loadSourceRowMap = (sourceWorkbookPath, sourceSheetName) => {
const workbook = XLSX.readFile(sourceWorkbookPath, { raw: false })
const sheet = workbook.Sheets[sourceSheetName]
if (!sheet) {
throw new Error(`未找到源工作表: ${sourceSheetName}`)
}
const matrix = XLSX.utils.sheet_to_json(sheet, { header: 1, raw: false, defval: '' })
if (!matrix.length) {
return new Map()
}
const headerIndex = new Map()
matrix[0].forEach((header, index) => {
headerIndex.set(normalizeText(header), index)
})
const codeColumnIndex = headerIndex.get('资产编码')
if (codeColumnIndex === undefined) {
throw new Error('源工作表缺少“资产编码”列')
}
const rowMap = new Map()
for (let rowIndex = 1; rowIndex < matrix.length; rowIndex += 1) {
const row = matrix[rowIndex]
const code = normalizeText(row[codeColumnIndex]).toUpperCase()
if (code && !rowMap.has(code)) {
rowMap.set(code, rowIndex + 1)
}
}
return rowMap
}
const loadCandidateRows = (candidateWorkbookPath) => {
const workbook = XLSX.readFile(candidateWorkbookPath, { raw: false })
const sheet = workbook.Sheets[workbook.SheetNames[0]]
return XLSX.utils.sheet_to_json(sheet, { defval: '' })
}
const fetchLiveRefs = () => {
if (!fs.existsSync(PLINK_PATH)) {
throw new Error(`未找到 plink.exe: ${PLINK_PATH}`)
}
if (!SSH_PASSWORD) {
throw new Error('缺少 SSH 密码,无法读取线上固定资产基础数据')
}
const script = `
let PrismaClient
try { ({ PrismaClient } = require('@prisma/client')) } catch { ({ PrismaClient } = require('/app/node_modules/@prisma/client')) }
const prisma = new PrismaClient()
;(async () => {
const [assetTypes, departments, locations] = await Promise.all([
prisma.assetType.findMany({
where: { status: 'active' },
select: { id: true, name: true, short_name: true },
orderBy: { name: 'asc' }
}),
prisma.department.findMany({
where: { status: 'active', source_system: 'ding_hr_business' },
select: { id: true, name: true },
orderBy: { name: 'asc' }
}),
prisma.storageLocation.findMany({
where: { status: 'active' },
select: { id: true, name: true },
orderBy: { name: 'asc' }
})
])
console.log(JSON.stringify({ assetTypes, departments, locations }))
await prisma.$disconnect()
})().catch(async (error) => {
console.error(error && error.stack ? error.stack : String(error))
try { await prisma.$disconnect() } catch {}
process.exit(1)
})
`
const result = spawnSync(
PLINK_PATH,
['-batch', '-ssh', '-hostkey', SSH_HOSTKEY, '-pw', SSH_PASSWORD, SSH_HOST, REMOTE_NODE_COMMAND],
{
encoding: 'utf8',
input: script,
maxBuffer: 32 * 1024 * 1024,
windowsHide: true
}
)
if (result.error) {
throw result.error
}
if (result.status !== 0) {
throw new Error(`读取线上基础数据失败: ${result.stderr || result.stdout}`)
}
const stdout = normalizeText(result.stdout)
if (!stdout) {
throw new Error('线上基础数据返回为空')
}
return JSON.parse(stdout)
}
const buildImportDescription = (row, sourceRow) => {
const parts = [
['source_workbook', 'docs/总表待导入.xlsx'],
['source_sheet', 'Sheet2'],
['source_row', sourceRow || ''],
['source_status', normalizeText(row['当前状态'])],
['raw_department', normalizeText(row['部门'])],
['raw_location', normalizeText(row['地址'])],
['raw_asset_category', normalizeText(row['资产分类'])]
]
return parts.map(([key, value]) => `${key}=${String(value).replace(/;/g, ',')}`).join('; ')
}
const resolveSheet2LocationMatch = (rawValue, locationByName) => {
const normalized = normalizeText(rawValue)
const override = SHEET2_LOCATION_OVERRIDES[normalized]
if (override) {
const match = locationByName.get(normalizeLookupKey(override)) || null
return {
rawValue: normalized,
normalizedValue: override,
match,
rule: 'sheet2-confirmed-override'
}
}
return resolveStorageLocationMatch(rawValue, locationByName)
}
const buildReadyImportPayload = (rows) =>
rows.map((row) => ({
legacyCode: row.资产编码,
equipmentName: row.资产名称,
equipmentModel: row.规格型号 || null,
equipmentType: row.资产名称,
departmentName: row.系统归并部门 || '',
storageLocationName: row.系统归并地址 || '',
systemStatus: row.系统建议状态,
purchaseDate: null,
supplier: '',
unitPrice: 0,
description: row.导入备注
}))
const classifyRows = (candidateRows, sourceRowMap, liveRefs) => {
const departmentByName = new Map(
liveRefs.departments.map((item) => [normalizeLookupKey(item.name), item])
)
const locationByName = new Map(
liveRefs.locations.map((item) => [normalizeLookupKey(item.name), item])
)
const readyRows = []
const pendingRows = []
for (const rawRow of candidateRows) {
const assetCode = normalizeText(rawRow['资产编码']).toUpperCase()
const assetName = normalizeText(rawRow['资产名称'])
const rawDepartment = normalizeText(rawRow['部门'])
const resolvedDepartment = DEPARTMENT_ALIASES[rawDepartment] || rawDepartment
const departmentMatch = resolvedDepartment
? departmentByName.get(normalizeLookupKey(resolvedDepartment)) || null
: null
const rawLocation = normalizeText(rawRow['地址'])
const locationMatch = resolveSheet2LocationMatch(rawLocation, locationByName)
const rawStatus = normalizeText(rawRow['当前状态'])
const systemStatus = PUBLIC_STATUS_MAP[rawStatus] || ''
const sourceRow = sourceRowMap.get(assetCode) || ''
const baseRow = {
来源工作表: normalizeText(rawRow['来源工作表']) || 'Sheet2',
源数据行号: sourceRow,
资产编码: assetCode,
资产名称: assetName,
当前状态: rawStatus,
系统建议状态: systemStatus,
资产分类: normalizeText(rawRow['资产分类']),
部门: rawDepartment,
系统归并部门: departmentMatch?.name || resolvedDepartment,
地址: rawLocation,
系统归并地址: locationMatch.match?.name || locationMatch.normalizedValue,
地址归并规则: locationMatch.rule || '',
规格型号: normalizeText(rawRow['规格型号']),
导入备注: buildImportDescription(rawRow, sourceRow)
}
const reasons = []
if (!departmentMatch) {
reasons.push(`部门未匹配:${rawDepartment || '(空)'}`)
}
if (!locationMatch.match) {
reasons.push(`地址未匹配:${rawLocation || '(空)'}`)
}
if (!systemStatus) {
reasons.push(`状态无法自动映射:${rawStatus || '(空)'}`)
}
if (!reasons.length) {
readyRows.push(baseRow)
continue
}
pendingRows.push({
...baseRow,
待核对原因: reasons.join(';'),
建议处理: reasons.some((item) => item.startsWith('地址未匹配'))
? '先确认应归并到哪个系统地址,再导入'
: '先确认状态或部门后再导入'
})
}
return { readyRows, pendingRows }
}
const buildSummaryRows = ({ total, ready, pending, addressPending, otherPending, liveRefs }) => [
{ 项目: '候选总数', 说明: total },
{ 项目: '可直接补录', 说明: ready },
{ 项目: '待人工核对', 说明: pending },
{ 项目: '其中地址待核对', 说明: addressPending },
{ 项目: '其中其他待核对', 说明: otherPending },
{ 项目: '状态映射规则', 说明: '在用/闲置 -> 系统“可用”;其余状态不自动导入' },
{ 项目: '人员处理规则', 说明: '本批当前使用员工全部为空,按公共设备处理,不自动建立借出记录' },
{ 项目: '线上部门数', 说明: liveRefs.departments.length },
{ 项目: '线上地址数', 说明: liveRefs.locations.length },
{ 项目: '线上资产类型数', 说明: liveRefs.assetTypes.length }
]
const run = async () => {
const options = parseArgs()
const sourceWorkbookPath = resolvePathArg(options.sourceWorkbook)
const candidateWorkbookPath = resolvePathArg(options.candidateWorkbook)
if (!fs.existsSync(sourceWorkbookPath)) {
throw new Error(`源工作簿不存在: ${sourceWorkbookPath}`)
}
if (!fs.existsSync(candidateWorkbookPath)) {
throw new Error(`候选工作簿不存在: ${candidateWorkbookPath}`)
}
const outputDir = ensureOutputDir()
const workbookPath = resolveAvailableWorkbookPath(options.outputWorkbook)
const sourceRowMap = loadSourceRowMap(sourceWorkbookPath, options.sourceSheet)
const candidateRows = loadCandidateRows(candidateWorkbookPath)
const liveRefs = fetchLiveRefs()
const { readyRows, pendingRows } = classifyRows(candidateRows, sourceRowMap, liveRefs)
const addressPendingRows = pendingRows.filter((row) => row.待核对原因.includes('地址未匹配'))
const otherPendingRows = pendingRows.filter((row) => !row.待核对原因.includes('地址未匹配'))
const readyPayload = buildReadyImportPayload(readyRows)
const manifest = {
generatedAt: new Date().toISOString(),
sourceWorkbook: sourceWorkbookPath,
sourceSheet: options.sourceSheet,
candidateWorkbook: candidateWorkbookPath,
totalCandidates: candidateRows.length,
readyCount: readyRows.length,
pendingCount: pendingRows.length,
addressPendingCount: addressPendingRows.length,
otherPendingCount: otherPendingRows.length,
rulesVersion: 'sheet2-public-supplement-v1',
assumptions: [
'当前使用员工为空的记录按公共设备处理',
'在用/闲置统一映射为系统可用',
'地址不能 100% 确认时不自动导入'
]
}
await writeWorkbook(workbookPath, [
{
name: '汇总说明',
rows: buildSummaryRows({
total: candidateRows.length,
ready: readyRows.length,
pending: pendingRows.length,
addressPending: addressPendingRows.length,
otherPending: otherPendingRows.length,
liveRefs
})
},
{ name: '可直接补录', rows: readyRows },
{ name: '待人工核对', rows: pendingRows },
{ name: '地址待核对', rows: addressPendingRows },
{ name: '其他待核对', rows: otherPendingRows.length ? otherPendingRows : [{ 说明: '当前无记录' }] }
])
writeJson(path.join(outputDir, 'sheet2_public_manifest.json'), manifest)
writeJson(path.join(outputDir, 'sheet2_public_ready_payload.json'), {
payloadCount: readyPayload.length,
payload: readyPayload
})
writeJson(path.join(outputDir, 'sheet2_public_pending_rows.json'), {
pendingCount: pendingRows.length,
rows: pendingRows
})
writeJson(path.join(outputDir, 'sheet2_live_refs.json'), liveRefs)
console.log(
JSON.stringify(
{
outputDir,
workbookPath,
manifest
},
null,
2
)
)
}
run().catch((error) => {
console.error(error && error.stack ? error.stack : String(error))
process.exit(1)
})

View File

@ -6,6 +6,13 @@ import {
getDisplayEquipmentValue, getDisplayEquipmentValue,
syncMountedChildrenState syncMountedChildrenState
} from '../utils/assetValue' } from '../utils/assetValue'
import { getBorrowAnomalyMeta } from '../utils/borrowAnomaly'
import {
normalizeReturnDepartmentInput,
resolveBorrowDepartmentUpdate
} from '../utils/borrowDepartment'
import { buildBusinessDepartmentWhere } from '../utils/organization'
import { logOperation } from './operationLogController'
const parseNumber = (value: any): number | undefined => { const parseNumber = (value: any): number | undefined => {
const num = Number(value) const num = Number(value)
@ -18,6 +25,18 @@ const parseDate = (value?: any): Date | null => {
return Number.isNaN(date.getTime()) ? null : date return Number.isNaN(date.getTime()) ? null : date
} }
const parseBorrowAnomalyBorrowTime = (value?: any): Date | null => {
if (!value || typeof value !== 'string') return null
const normalizedValue = value.trim()
if (!normalizedValue) return null
if (/^\d{4}-\d{2}-\d{2}$/.test(normalizedValue)) {
return parseDate(`${normalizedValue}T00:00:00`)
}
return parseDate(normalizedValue)
}
const equipmentInclude = { const equipmentInclude = {
storageLocation: { storageLocation: {
select: { select: {
@ -38,6 +57,20 @@ const personInclude = {
} }
} }
const buildReturnAssetKeywordWhere = (keyword: string) => [
{ equipment_code: { contains: keyword } },
{ equipment_name: { contains: keyword } },
{ equipment_model: { contains: keyword } },
{ equipment_type: { contains: keyword } },
{
storageLocation: {
is: {
name: { contains: keyword }
}
}
}
]
const getEquipmentByIdentifier = async (equipmentId?: number, equipmentCode?: string) => { const getEquipmentByIdentifier = async (equipmentId?: number, equipmentCode?: string) => {
if (equipmentId) { if (equipmentId) {
return prisma.equipment.findUnique({ where: { id: equipmentId }, include: equipmentInclude }) return prisma.equipment.findUnique({ where: { id: equipmentId }, include: equipmentInclude })
@ -48,6 +81,62 @@ const getEquipmentByIdentifier = async (equipmentId?: number, equipmentCode?: st
return null return null
} }
const getBorrowAnomalyMetaFromEquipment = (equipment: {
status?: string | null
total_quantity?: number | null
parent_id?: number | null
}, activeBorrowCount: number) =>
getBorrowAnomalyMeta({
status: equipment.status,
activeBorrowCount,
totalQuantity: equipment.total_quantity,
parentId: equipment.parent_id
})
const loadBorrowAnomalyTarget = async (equipmentId: number) => {
const equipment = await getEquipmentByIdentifier(equipmentId)
if (!equipment) {
throw new Error('资产不存在')
}
const activeBorrowCount = await prisma.borrowRecord.count({
where: {
equipment_id: equipment.id,
status: '已借出'
}
})
const anomalyMeta = getBorrowAnomalyMetaFromEquipment(equipment, activeBorrowCount)
return {
equipment,
activeBorrowCount,
anomalyMeta
}
}
const ensureBorrowAnomalyHandleable = (
equipment: {
status?: string | null
total_quantity?: number | null
parent_id?: number | null
},
activeBorrowCount: number
) => {
const anomalyMeta = getBorrowAnomalyMetaFromEquipment(equipment, activeBorrowCount)
if (anomalyMeta.state !== 'borrow_record_missing') {
throw new Error('该资产当前不存在借用单缺失问题')
}
if (!anomalyMeta.canHandle) {
throw new Error(anomalyMeta.blockedReason || '该资产当前不支持异常借出处理')
}
return anomalyMeta
}
const ensurePersonAvailable = async (personId?: number) => { const ensurePersonAvailable = async (personId?: number) => {
if (!personId) { if (!personId) {
throw new Error('使用者ID不能为空') throw new Error('使用者ID不能为空')
@ -59,6 +148,46 @@ const ensurePersonAvailable = async (personId?: number) => {
return person return person
} }
const ensureBorrowAnomalyBorrowTime = (value?: any) => {
const borrowTime = parseBorrowAnomalyBorrowTime(value)
if (!borrowTime) {
throw new Error('借出日期不能为空')
}
if (borrowTime.getTime() > Date.now()) {
throw new Error('借出日期不能晚于当前时间')
}
return borrowTime
}
const validateReturnDepartmentUpdate = async (input: Record<string, any>) => {
const normalized = normalizeReturnDepartmentInput(input)
if (normalized.departmentAction !== 'set' || !normalized.departmentId) {
return normalized
}
const department = await prisma.department.findFirst({
where: {
id: normalized.departmentId,
AND: [
buildBusinessDepartmentWhere(),
{ status: 'active' }
]
},
select: {
id: true
}
})
if (!department) {
throw new Error('所选业务部门不存在或已停用')
}
return normalized
}
const ensureEquipmentBorrowable = (equipment: any) => { const ensureEquipmentBorrowable = (equipment: any) => {
if (equipment.parent_id) { if (equipment.parent_id) {
throw new Error('该子资产当前已挂载,请先卸载后再借用') throw new Error('该子资产当前已挂载,请先卸载后再借用')
@ -134,6 +263,7 @@ const borrowEquipmentInTransaction = async (
const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0) const nextRemainingQuantity = Math.max(Number(equipment.remaining_quantity) - 1, 0)
const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出' const nextStatus = nextRemainingQuantity > 0 ? '可用' : '已借出'
const departmentUpdateData = resolveBorrowDepartmentUpdate(person.main_department_id)
const values = calculateEquipmentValues({ const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price), unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity), totalQuantity: Number(equipment.total_quantity),
@ -151,7 +281,8 @@ const borrowEquipmentInTransaction = async (
total_value: values.total_value, total_value: values.total_value,
borrowed_value: values.borrowed_value, borrowed_value: values.borrowed_value,
available_value: values.available_value, available_value: values.available_value,
status: nextStatus status: nextStatus,
...(departmentUpdateData || {})
} }
}) })
@ -246,7 +377,10 @@ const returnEquipmentInTransaction = async (
equipment: any, equipment: any,
borrowRecord: any, borrowRecord: any,
returnCondition: string, returnCondition: string,
notes?: string notes?: string,
equipmentUpdateData?: {
department_id: number | null
} | null
) => { ) => {
return prisma.$transaction(async (tx) => { return prisma.$transaction(async (tx) => {
const returnTime = new Date() const returnTime = new Date()
@ -287,7 +421,8 @@ const returnEquipmentInTransaction = async (
total_value: values.total_value, total_value: values.total_value,
borrowed_value: values.borrowed_value, borrowed_value: values.borrowed_value,
available_value: values.available_value, available_value: values.available_value,
status: '可用' status: '可用',
...(equipmentUpdateData || {})
} }
}) })
@ -306,6 +441,89 @@ const returnEquipmentInTransaction = async (
}) })
} }
const resolveBorrowAnomalyReturnedInTransaction = async (equipment: any) => {
return prisma.$transaction(async (tx) => {
const nextRemainingQuantity = Number(equipment.total_quantity)
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: nextRemainingQuantity,
status: '可用'
})
const updatedEquipment = await tx.equipment.update({
where: { id: equipment.id },
data: {
status: '可用',
remaining_quantity: nextRemainingQuantity,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value
},
include: equipmentInclude
})
await syncMountedChildrenState(tx, equipment.id, '可用')
return updatedEquipment
})
}
const registerBorrowAnomalyInTransaction = async (
equipment: any,
person: any,
borrowTime: Date,
notes?: string
) => {
return prisma.$transaction(async (tx) => {
const { children, snapshot } = await getChildrenSnapshot(equipment.id)
const departmentUpdateData = resolveBorrowDepartmentUpdate(person.main_department_id)
const nextRemainingQuantity = Math.max(Number(equipment.total_quantity) - 1, 0)
const values = calculateEquipmentValues({
unitPrice: Number(equipment.unit_price),
totalQuantity: Number(equipment.total_quantity),
remainingQuantity: nextRemainingQuantity,
status: '已借出'
})
const record = await tx.borrowRecord.create({
data: {
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
person_id: person.id,
person_name: person.real_name,
person_nickname: person.nickname,
person_identifier: person.id_number,
borrow_time: borrowTime,
notes: notes?.trim() || null,
status: '已借出',
children_snapshot: snapshot
}
})
const updatedEquipment = await tx.equipment.update({
where: { id: equipment.id },
data: {
status: '已借出',
remaining_quantity: nextRemainingQuantity,
total_value: values.total_value,
borrowed_value: values.borrowed_value,
available_value: values.available_value,
...(departmentUpdateData || {})
},
include: equipmentInclude
})
await syncMountedChildrenState(tx, equipment.id, '已借出')
return {
record,
updatedEquipment,
childrenCount: children.length
}
})
}
const mapBorrowRecord = (record: any) => ({ const mapBorrowRecord = (record: any) => ({
id: record.id, id: record.id,
equipment_id: record.equipment_id, equipment_id: record.equipment_id,
@ -320,7 +538,8 @@ const mapBorrowRecord = (record: any) => ({
real_name: record.person.real_name, real_name: record.person.real_name,
department: record.person.mainDepartment?.name || record.person.department, department: record.person.mainDepartment?.name || record.person.department,
main_department: record.person.mainDepartment || null, main_department: record.person.mainDepartment || null,
phone: record.person.phone phone: record.person.phone,
status: record.person.status
} }
: { : {
id: record.person_id, id: record.person_id,
@ -568,6 +787,13 @@ export const returnEquipment = async (req: Request, res: Response) => {
confirm_children_diff confirm_children_diff
} = req.body as Record<string, any> } = req.body as Record<string, any>
let departmentUpdate
try {
departmentUpdate = await validateReturnDepartmentUpdate(req.body || {})
} catch (error: any) {
return errorResponse(res, error.message || '部门归属处理无效', 422)
}
const equipmentIdNum = parseNumber(equipment_id) const equipmentIdNum = parseNumber(equipment_id)
const equipment = await getEquipmentByIdentifier(equipmentIdNum, equipment_code) const equipment = await getEquipmentByIdentifier(equipmentIdNum, equipment_code)
@ -605,7 +831,13 @@ export const returnEquipment = async (req: Request, res: Response) => {
.join(';') .join(';')
: notes : notes
await returnEquipmentInTransaction(equipment, borrowRecord, return_condition, finalNotes) await returnEquipmentInTransaction(
equipment,
borrowRecord,
return_condition,
finalNotes,
departmentUpdate.equipmentUpdateData
)
successResponse( successResponse(
res, res,
@ -639,6 +871,13 @@ export const scanReturn = async (req: Request, res: Response) => {
confirm_children_diff confirm_children_diff
} = req.body as Record<string, any> } = req.body as Record<string, any>
let departmentUpdate
try {
departmentUpdate = await validateReturnDepartmentUpdate(req.body || {})
} catch (error: any) {
return errorResponse(res, error.message || '部门归属处理无效', 422)
}
const equipment = await getEquipmentByIdentifier(undefined, equipment_code) const equipment = await getEquipmentByIdentifier(undefined, equipment_code)
if (!equipment) { if (!equipment) {
@ -679,7 +918,8 @@ export const scanReturn = async (req: Request, res: Response) => {
equipment, equipment,
borrowRecord, borrowRecord,
return_condition, return_condition,
finalNotes finalNotes,
departmentUpdate.equipmentUpdateData
) )
successResponse( successResponse(
@ -834,6 +1074,15 @@ export const getBorrowRecordByCode = async (req: Request, res: Response) => {
}) })
if (borrowRecords.length === 0) { if (borrowRecords.length === 0) {
const anomalyMeta = getBorrowAnomalyMetaFromEquipment(equipment, 0)
if (anomalyMeta.state === 'borrow_record_missing') {
const suffix = anomalyMeta.blockedReason ? `;${anomalyMeta.blockedReason}` : ''
return errorResponse(
res,
`该资产当前标记为已借出,但系统里没有对应借用记录,无法直接归还,请先核对。${suffix}`,
409
)
}
return errorResponse(res, '未找到对应的借用记录', 404) return errorResponse(res, '未找到对应的借用记录', 404)
} }
@ -944,3 +1193,241 @@ export const getBorrowRecordList = async (req: Request, res: Response) => {
errorResponse(res, error.message || '获取借用记录列表失败') errorResponse(res, error.message || '获取借用记录列表失败')
} }
} }
// 搜索可归还资产
export const searchReturnAssets = async (req: Request, res: Response) => {
try {
const keyword = typeof req.query.keyword === 'string' ? req.query.keyword.trim() : ''
const equipmentList = await prisma.equipment.findMany({
where: {
AND: [
{
OR: [
{ status: '已借出' },
{
borrow_records: {
some: {
status: '已借出'
}
}
}
]
},
...(keyword
? [
{
OR: buildReturnAssetKeywordWhere(keyword)
}
]
: [])
]
},
orderBy: [{ updated_at: 'desc' }, { id: 'desc' }],
select: {
id: true,
equipment_code: true,
equipment_name: true,
equipment_model: true,
equipment_type: true,
status: true,
total_quantity: true,
parent_id: true,
storage_location_id: true,
storageLocation: {
select: {
id: true,
name: true,
code: true,
status: true,
}
},
borrow_records: {
where: {
status: '已借出'
},
orderBy: [{ borrow_time: 'desc' }, { id: 'desc' }],
select: {
id: true,
person_name: true,
person_nickname: true,
person_identifier: true,
person: {
select: {
department: true,
mainDepartment: {
select: {
name: true
}
}
}
}
}
}
}
})
const pushUnique = (bucket: string[], value?: string | null) => {
const normalized = (value || '').trim()
if (!normalized) return
if (!bucket.includes(normalized)) {
bucket.push(normalized)
}
}
const list = equipmentList.map((equipment) => {
const borrowerNames: string[] = []
const borrowerIdentifiers: string[] = []
const borrowerDepartments: string[] = []
for (const record of equipment.borrow_records) {
const borrowerName = [record.person_nickname, record.person_name].filter(Boolean).join(' · ')
const borrowerIdentifier = record.person_identifier || null
const borrowerDepartment =
record.person?.mainDepartment?.name || record.person?.department || null
pushUnique(borrowerNames, borrowerName)
pushUnique(borrowerIdentifiers, borrowerIdentifier)
pushUnique(borrowerDepartments, borrowerDepartment)
}
const activeBorrowCount = equipment.borrow_records.length
const anomalyMeta = getBorrowAnomalyMetaFromEquipment(equipment, activeBorrowCount)
const searchState = anomalyMeta.state === 'borrow_record_missing' ? 'borrow_record_missing' : 'returnable'
return {
equipment_id: equipment.id,
equipment_code: equipment.equipment_code,
equipment_name: equipment.equipment_name,
equipment_model: equipment.equipment_model,
equipment_type: equipment.equipment_type,
storage_location_id: equipment.storage_location_id,
storageLocation: equipment.storageLocation,
active_borrow_count: activeBorrowCount,
borrower_names: borrowerNames,
borrower_identifiers: borrowerIdentifiers,
borrower_departments: borrowerDepartments,
can_return: activeBorrowCount > 0,
can_handle_borrow_anomaly: anomalyMeta.canHandle,
borrow_anomaly_blocked_reason: anomalyMeta.blockedReason,
search_state: searchState,
state_message: searchState === 'returnable' ? '可直接归还' : anomalyMeta.message
}
})
successResponse(res, list, '获取成功')
} catch (error: any) {
errorResponse(res, error.message || '搜索可归还资产失败')
}
}
export const resolveBorrowAnomalyAsReturned = async (req: Request, res: Response) => {
try {
const equipmentId = parseNumber(req.params.equipment_id)
if (!equipmentId) {
return errorResponse(res, '资产ID无效', 422)
}
const { equipment, activeBorrowCount } = await loadBorrowAnomalyTarget(equipmentId)
try {
ensureBorrowAnomalyHandleable(equipment, activeBorrowCount)
} catch (error: any) {
return errorResponse(res, error.message || '该资产当前不支持异常借出处理', 422)
}
const updatedEquipment = await resolveBorrowAnomalyReturnedInTransaction(equipment)
const userId = res.locals.user?.id || null
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
await logOperation(
userId,
username,
'borrow_anomaly_mark_returned',
'异常借出处理:确认已归还,系统补齐为可用状态',
equipment.id,
equipment.equipment_code,
req.ip
)
successResponse(
res,
{
equipment_id: updatedEquipment.id,
equipment_code: updatedEquipment.equipment_code,
equipment_name: updatedEquipment.equipment_name,
status: updatedEquipment.status,
remaining_quantity: updatedEquipment.remaining_quantity
},
'已按异常借出处理为归还完成'
)
} catch (error: any) {
errorResponse(res, error.message || '异常借出归还处理失败')
}
}
export const registerBorrowAnomaly = async (req: Request, res: Response) => {
try {
const equipmentId = parseNumber(req.params.equipment_id)
if (!equipmentId) {
return errorResponse(res, '资产ID无效', 422)
}
const { person_id, borrow_time, notes } = req.body as Record<string, any>
let person
try {
person = await ensurePersonAvailable(parseNumber(person_id))
} catch (error: any) {
return errorResponse(res, error.message || '使用者验证失败', 422)
}
let borrowTime
try {
borrowTime = ensureBorrowAnomalyBorrowTime(borrow_time)
} catch (error: any) {
return errorResponse(res, error.message || '借出日期无效', 422)
}
const { equipment, activeBorrowCount } = await loadBorrowAnomalyTarget(equipmentId)
try {
ensureBorrowAnomalyHandleable(equipment, activeBorrowCount)
} catch (error: any) {
return errorResponse(res, error.message || '该资产当前不支持异常借出处理', 422)
}
const { record, updatedEquipment, childrenCount } = await registerBorrowAnomalyInTransaction(
equipment,
person,
borrowTime,
typeof notes === 'string' ? notes : undefined
)
const userId = res.locals.user?.id || null
const username = res.locals.user?.username || res.locals.user?.name || 'unknown'
await logOperation(
userId,
username,
'borrow_anomaly_register_borrow',
`异常借出处理:补登记借出给 ${person.nickname}(${person.real_name}),借出日期 ${borrow_time}`,
equipment.id,
equipment.equipment_code,
req.ip
)
successResponse(
res,
{
id: record.id,
equipment_id: updatedEquipment.id,
equipment_code: updatedEquipment.equipment_code,
equipment_name: updatedEquipment.equipment_name,
borrow_time: record.borrow_time,
status: record.status,
children_count: childrenCount
},
'已补登记借出记录'
)
} catch (error: any) {
errorResponse(res, error.message || '补登记借出失败')
}
}

View File

@ -9,6 +9,8 @@ import {
sumBundleChildrenValue, sumBundleChildrenValue,
syncMountedChildrenState syncMountedChildrenState
} from '../utils/assetValue' } from '../utils/assetValue'
import { getBorrowAnomalyMeta } from '../utils/borrowAnomaly'
import { getEquipmentDeleteBlockedReason } from '../utils/equipmentDeletion'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
type EquipmentEntryMode = 'single' | 'parent' | 'child' type EquipmentEntryMode = 'single' | 'parent' | 'child'
@ -163,7 +165,13 @@ const serializeEquipmentListItem = (equipment: any) => {
return { return {
...restEquipment, ...restEquipment,
children, children,
current_borrow: mapCurrentBorrow(borrow_records) current_borrow: mapCurrentBorrow(borrow_records),
borrow_anomaly: getBorrowAnomalyMeta({
status: restEquipment.status,
activeBorrowCount: Array.isArray(borrow_records) ? borrow_records.length : 0,
totalQuantity: restEquipment.total_quantity,
parentId: restEquipment.parent_id
})
} }
} }
@ -894,7 +902,8 @@ export const deleteEquipment = async (req: Request, res: Response) => {
children: true, children: true,
mountHistories: true, mountHistories: true,
disposalRecords: true, disposalRecords: true,
maintenanceRecords: true maintenanceRecords: true,
inventoryTaskItems: true
} }
} }
} }
@ -904,37 +913,21 @@ export const deleteEquipment = async (req: Request, res: Response) => {
return errorResponse(res, '资产不存在', 404) return errorResponse(res, '资产不存在', 404)
} }
// 检查是否有未归还的借用记录 const blockedReason = getEquipmentDeleteBlockedReason({
const activeBorrowCount = await prisma.borrowRecord.count({ borrowRecordCount: equipment._count.borrow_records,
where: { isMountedChild: Boolean(equipment.parent_id),
equipment_id: equipmentId, childCount: equipment._count.children,
status: '已借出' mountHistoryCount: equipment._count.mountHistories,
} disposalRecordCount: equipment._count.disposalRecords,
maintenanceRecordCount: equipment._count.maintenanceRecords,
inventoryTaskItemCount: equipment._count.inventoryTaskItems
}) })
if (activeBorrowCount > 0) { if (blockedReason) {
return errorResponse(res, '该资产存在未归还的借用记录,无法删除') return errorResponse(res, blockedReason)
}
if (equipment.parent_id) {
return errorResponse(res, '该资产当前作为子资产挂载中,无法删除')
}
if (equipment._count.children > 0) {
return errorResponse(res, '该资产存在已挂载的子资产,无法删除')
}
if (equipment._count.mountHistories > 0 || equipment._count.disposalRecords > 0 || equipment._count.maintenanceRecords > 0) {
return errorResponse(res, '该资产已存在维修、报废或挂载历史,无法删除')
} }
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.borrowRecord.deleteMany({
where: {
equipment_id: equipmentId
}
})
await tx.equipment.delete({ await tx.equipment.delete({
where: { id: equipmentId } where: { id: equipmentId }
}) })

View File

@ -4,11 +4,13 @@ import {
assignUnassignedTaskItem, assignUnassignedTaskItem,
closeInventoryTask, closeInventoryTask,
createInventoryTask, createInventoryTask,
deleteInventoryTask,
getInventoryTaskDetail, getInventoryTaskDetail,
getTaskUnassignedItems, getTaskUnassignedItems,
listInventoryTasks, listInventoryTasks,
previewInventoryTask previewInventoryTask
} from '../services/inventoryService' } from '../services/inventoryService'
import { normalizeInventoryScopeInput } from '../utils/inventoryScope'
const toOptionalNumber = (value: unknown) => { const toOptionalNumber = (value: unknown) => {
if (value === undefined || value === null || value === '') return null if (value === undefined || value === null || value === '') return null
@ -18,10 +20,15 @@ const toOptionalNumber = (value: unknown) => {
export const previewInventoryTaskController = async (req: Request, res: Response) => { export const previewInventoryTaskController = async (req: Request, res: Response) => {
try { try {
const result = await previewInventoryTask({ const normalizedScope = normalizeInventoryScopeInput({
departmentId: toOptionalNumber(req.body?.department_id), department_id: toOptionalNumber(req.body?.department_id),
assetTypeId: toOptionalNumber(req.body?.asset_type_id) department_ids: req.body?.department_ids,
asset_type_id: toOptionalNumber(req.body?.asset_type_id),
asset_type_ids: req.body?.asset_type_ids,
storage_location_id: toOptionalNumber(req.body?.storage_location_id),
storage_location_ids: req.body?.storage_location_ids
}) })
const result = await previewInventoryTask(normalizedScope)
successResponse(res, result, '预览成功') successResponse(res, result, '预览成功')
} catch (error: any) { } catch (error: any) {
errorResponse(res, error.message || '预览盘点任务失败', 422) errorResponse(res, error.message || '预览盘点任务失败', 422)
@ -35,12 +42,22 @@ export const createInventoryTaskController = async (req: Request, res: Response)
return errorResponse(res, '未获取到当前登录用户', 401) return errorResponse(res, '未获取到当前登录用户', 401)
} }
const normalizedScope = normalizeInventoryScopeInput({
department_id: toOptionalNumber(req.body?.department_id),
department_ids: req.body?.department_ids,
asset_type_id: toOptionalNumber(req.body?.asset_type_id),
asset_type_ids: req.body?.asset_type_ids,
storage_location_id: toOptionalNumber(req.body?.storage_location_id),
storage_location_ids: req.body?.storage_location_ids
})
const result = await createInventoryTask( const result = await createInventoryTask(
{ {
title: req.body?.title, title: req.body?.title,
description: req.body?.description, description: req.body?.description,
department_id: toOptionalNumber(req.body?.department_id), department_ids: normalizedScope.departmentIds,
asset_type_id: toOptionalNumber(req.body?.asset_type_id) asset_type_ids: normalizedScope.assetTypeIds,
storage_location_ids: normalizedScope.storageLocationIds
}, },
currentUserId currentUserId
) )
@ -96,6 +113,24 @@ export const closeInventoryTaskController = async (req: Request, res: Response)
} }
} }
export const deleteInventoryTaskController = async (req: Request, res: Response) => {
try {
const taskId = Number(req.params.id)
if (!Number.isInteger(taskId) || taskId <= 0) {
return errorResponse(res, '盘点任务不存在', 404)
}
await deleteInventoryTask(taskId)
successResponse(res, null, '任务已删除')
} catch (error: any) {
const message = error.message || '删除盘点任务失败'
if (message.includes('不存在')) {
return errorResponse(res, message, 404)
}
errorResponse(res, message, 422)
}
}
export const getInventoryTaskUnassignedController = async (req: Request, res: Response) => { export const getInventoryTaskUnassignedController = async (req: Request, res: Response) => {
try { try {
const taskId = Number(req.params.id) const taskId = Number(req.params.id)

View File

@ -16,6 +16,23 @@ const getCurrentUserId = (res: Response) => {
return Number.isNaN(userId) ? null : userId return Number.isNaN(userId) ? null : userId
} }
const getCurrentUserRole = (res: Response) => {
const role = res.locals.user?.role
return typeof role === 'string' ? role : null
}
const ensureAdminNotificationInbox = async (userId: number, role: string | null) => {
if (role !== 'admin') {
return
}
await prisma.$executeRaw`
INSERT IGNORE INTO user_notification (user_id, notification_id)
SELECT ${userId}, n.id
FROM notification n
`
}
const sanitizeNotification = (item: any) => ({ const sanitizeNotification = (item: any) => ({
id: item.id, id: item.id,
is_read: item.is_read, is_read: item.is_read,
@ -38,6 +55,7 @@ export const getNotificationList = async (req: Request, res: Response) => {
if (!userId) { if (!userId) {
return errorResponse(res, '未获取到当前用户信息', 401) return errorResponse(res, '未获取到当前用户信息', 401)
} }
await ensureAdminNotificationInbox(userId, getCurrentUserRole(res))
const { page = 1, pageSize = 10, status = 'all' } = req.query const { page = 1, pageSize = 10, status = 'all' } = req.query
const where: any = { user_id: userId } const where: any = { user_id: userId }
@ -89,6 +107,7 @@ export const getUnreadNotificationCount = async (_req: Request, res: Response) =
if (!userId) { if (!userId) {
return errorResponse(res, '未获取到当前用户信息', 401) return errorResponse(res, '未获取到当前用户信息', 401)
} }
await ensureAdminNotificationInbox(userId, getCurrentUserRole(res))
const count = await prisma.userNotification.count({ const count = await prisma.userNotification.count({
where: { where: {
@ -109,6 +128,7 @@ export const markNotificationAsRead = async (req: Request, res: Response) => {
if (!userId) { if (!userId) {
return errorResponse(res, '未获取到当前用户信息', 401) return errorResponse(res, '未获取到当前用户信息', 401)
} }
await ensureAdminNotificationInbox(userId, getCurrentUserRole(res))
const id = Number(req.params.id) const id = Number(req.params.id)
const existing = await prisma.userNotification.findFirst({ const existing = await prisma.userNotification.findFirst({
@ -148,6 +168,7 @@ export const markAllNotificationsAsRead = async (_req: Request, res: Response) =
if (!userId) { if (!userId) {
return errorResponse(res, '未获取到当前用户信息', 401) return errorResponse(res, '未获取到当前用户信息', 401)
} }
await ensureAdminNotificationInbox(userId, getCurrentUserRole(res))
const result = await prisma.userNotification.updateMany({ const result = await prisma.userNotification.updateMany({
where: { where: {

View File

@ -108,7 +108,7 @@ export const createAssetLabelPrintJob = async (req: Request, res: Response) => {
} }
try { try {
const result = await createAssetLabelPrintJobService(req.body?.equipment_ids, userId) const result = await createAssetLabelPrintJobService(req.body, userId)
successResponse(res, result.job, result.queue_notice) successResponse(res, result.job, result.queue_notice)
} catch (error) { } catch (error) {
handlePrintServiceError(res, error, '创建打印任务失败') handlePrintServiceError(res, error, '创建打印任务失败')

View File

@ -43,7 +43,7 @@ const SESSION_SECRET = process.env.SESSION_SECRET || 'equipment-management-secre
// Middleware // Middleware
app.use(cors({ origin: true, credentials: true })) app.use(cors({ origin: true, credentials: true }))
app.use(express.json()) app.use(express.json({ limit: '30mb' }))
app.use(express.urlencoded({ extended: true })) app.use(express.urlencoded({ extended: true }))
app.use( app.use(
session({ session({

View File

@ -36,10 +36,12 @@ router.get('/user', (req, res) => {
// Keycloak 配置信息(前端使用) // Keycloak 配置信息(前端使用)
router.get('/config', (req, res) => { router.get('/config', (req, res) => {
const authMode = process.env.AUTH_MODE === 'keycloak' ? 'keycloak' : 'local'
successResponse(res, { successResponse(res, {
url: process.env.KEYCLOAK_URL || 'http://localhost:8080', authMode,
realm: process.env.KEYCLOAK_REALM || 'equipment-management', url: authMode === 'keycloak' ? (process.env.KEYCLOAK_URL || '') : '',
clientId: process.env.KEYCLOAK_CLIENT_ID || 'equipment-client' realm: authMode === 'keycloak' ? (process.env.KEYCLOAK_REALM || 'equipment-management') : '',
clientId: authMode === 'keycloak' ? (process.env.KEYCLOAK_CLIENT_ID || 'equipment-client') : ''
}, '获取成功') }, '获取成功')
}) })
@ -114,4 +116,3 @@ router.post('/local/login', async (req: Request, res: Response) => {
}) })
export default router export default router

View File

@ -3,6 +3,8 @@ import {
borrowEquipment, borrowEquipment,
scanBorrow, scanBorrow,
scanBorrowBatch, scanBorrowBatch,
resolveBorrowAnomalyAsReturned,
registerBorrowAnomaly,
getBorrowRecordByCode, getBorrowRecordByCode,
getBorrowRecordList getBorrowRecordList
} from '../controllers/borrowController' } from '../controllers/borrowController'
@ -13,6 +15,8 @@ const router = Router()
router.post('/', borrowEquipment) router.post('/', borrowEquipment)
router.post('/scan', scanBorrow) router.post('/scan', scanBorrow)
router.post('/scan-batch', scanBorrowBatch) router.post('/scan-batch', scanBorrowBatch)
router.post('/anomalies/:equipment_id/mark-returned', resolveBorrowAnomalyAsReturned)
router.post('/anomalies/:equipment_id/register-borrow', registerBorrowAnomaly)
router.get('/scan/:equipment_code', getBorrowRecordByCode) router.get('/scan/:equipment_code', getBorrowRecordByCode)
router.get('/records', getBorrowRecordList) router.get('/records', getBorrowRecordList)
@ -24,9 +28,11 @@ export const returnRouter = Router()
import { import {
returnEquipment, returnEquipment,
scanReturn, scanReturn,
scanReturnBatch scanReturnBatch,
searchReturnAssets
} from '../controllers/borrowController' } from '../controllers/borrowController'
returnRouter.post('/', returnEquipment) returnRouter.post('/', returnEquipment)
returnRouter.post('/scan', scanReturn) returnRouter.post('/scan', scanReturn)
returnRouter.post('/scan-batch', scanReturnBatch) returnRouter.post('/scan-batch', scanReturnBatch)
returnRouter.get('/search-assets', searchReturnAssets)

View File

@ -3,6 +3,7 @@ import {
assignInventoryTaskUnassignedController, assignInventoryTaskUnassignedController,
closeInventoryTaskController, closeInventoryTaskController,
createInventoryTaskController, createInventoryTaskController,
deleteInventoryTaskController,
getInventoryTaskDetailController, getInventoryTaskDetailController,
getInventoryTaskListController, getInventoryTaskListController,
getInventoryTaskUnassignedController, getInventoryTaskUnassignedController,
@ -19,6 +20,7 @@ router.post('/tasks', createInventoryTaskController)
router.get('/tasks', getInventoryTaskListController) router.get('/tasks', getInventoryTaskListController)
router.get('/tasks/:id', getInventoryTaskDetailController) router.get('/tasks/:id', getInventoryTaskDetailController)
router.post('/tasks/:id/close', closeInventoryTaskController) router.post('/tasks/:id/close', closeInventoryTaskController)
router.delete('/tasks/:id', deleteInventoryTaskController)
router.get('/tasks/:id/unassigned', getInventoryTaskUnassignedController) router.get('/tasks/:id/unassigned', getInventoryTaskUnassignedController)
router.post('/tasks/:id/unassigned/:itemId/assign', assignInventoryTaskUnassignedController) router.post('/tasks/:id/unassigned/:itemId/assign', assignInventoryTaskUnassignedController)

View File

@ -0,0 +1,91 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
buildHrSyncPreviewLine,
formatBorrowedAssetSummary,
resolveHrSyncPersonDisplayName,
resolveSourcePersonStatus
} from '../hrSyncHelpers'
test('resolvePersonStatus 在状态值 2 被配置为停用时应返回 inactive', () => {
const previous = process.env.HR_SYNC_INACTIVE_STATUS_VALUES
process.env.HR_SYNC_INACTIVE_STATUS_VALUES = '2,5'
try {
assert.equal(resolveSourcePersonStatus('2'), 'inactive')
assert.equal(resolveSourcePersonStatus('5'), 'inactive')
assert.equal(resolveSourcePersonStatus('3'), 'active')
} finally {
process.env.HR_SYNC_INACTIVE_STATUS_VALUES = previous
}
})
test('resolvePersonStatus 在状态文案包含离职语义时应返回 inactive', () => {
const previousKeywords = process.env.HR_SYNC_INACTIVE_STATUS_KEYWORDS
process.env.HR_SYNC_INACTIVE_STATUS_KEYWORDS = '离职,停用,inactive'
try {
assert.equal(resolveSourcePersonStatus('离职'), 'inactive')
assert.equal(resolveSourcePersonStatus('已停用'), 'inactive')
assert.equal(resolveSourcePersonStatus('Inactive'), 'inactive')
assert.equal(resolveSourcePersonStatus('在职'), 'active')
} finally {
process.env.HR_SYNC_INACTIVE_STATUS_KEYWORDS = previousKeywords
}
})
test('formatBorrowedAssetSummary 会按“编码 / 名称”输出并在超出数量时补充总数', () => {
const summary = formatBorrowedAssetSummary([
{ equipmentCode: 'JS-PC-1001', equipmentName: '主机' },
{ equipmentCode: 'JS-MON-2001', equipmentName: '显示器' },
{ equipmentCode: 'JS-KB-3001', equipmentName: '键盘' },
{ equipmentCode: 'JS-MS-4001', equipmentName: '鼠标' }
])
assert.equal(summary, 'JS-PC-1001 / 主机、JS-MON-2001 / 显示器、JS-KB-3001 / 键盘 等 4 项')
})
test('resolveHrSyncPersonDisplayName 会优先返回花名', () => {
assert.equal(
resolveHrSyncPersonDisplayName({
nickname: '三藏',
realName: '张亮'
}),
'三藏'
)
assert.equal(
resolveHrSyncPersonDisplayName({
nickname: '',
realName: '张亮'
}),
'张亮'
)
})
test('buildHrSyncPreviewLine 会优先使用结构化资产生成预览行', () => {
const line = buildHrSyncPreviewLine({
changeType: 'person_deactivated',
title: '人员离职/停用:三藏',
detail: '该人员已不在本次同步名单中,请行政确认是否还存在资产交接或借还处理。',
afterSnapshot: {
assets: [
{ equipmentCode: 'JS-PC-1001', equipmentName: '主机' },
{ equipmentCode: 'JS-MON-2001', equipmentName: '显示器' }
]
}
})
assert.equal(line, '人员离职/停用:三藏(名下资产:JS-PC-1001 / 主机、JS-MON-2001 / 显示器)')
})
test('buildHrSyncPreviewLine 在没有结构化资产时仍兼容旧的详情文本', () => {
const line = buildHrSyncPreviewLine({
changeType: 'person_position_changed',
title: '岗位变动:悟空',
detail: '岗位已从“摄影师”调整为“导演”。 名下资产:JS-PC-2001 / 主机。'
})
assert.equal(line, '岗位变动:悟空(名下资产:JS-PC-2001 / 主机)')
})

View File

@ -1,10 +1,13 @@
import test from 'node:test' import test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { import {
getPrintTerminalAdminStatusChangeError,
resolveEffectiveTerminalStatus, resolveEffectiveTerminalStatus,
normalizeLabelProfile, normalizeLabelProfile,
pickNextClaimablePrintJob, pickNextClaimablePrintJob,
resolveTerminalReadiness resolveTerminalReadiness,
sanitizeRenderedLabelImages,
stripRenderedLabelData
} from '../printService' } from '../printService'
test('normalizeLabelProfile 应回填 60x40 与默认方向', () => { test('normalizeLabelProfile 应回填 60x40 与默认方向', () => {
@ -142,3 +145,86 @@ test('resolveEffectiveTerminalStatus 对手动设为 offline 的终端保持 off
assert.equal(status, 'offline') assert.equal(status, 'offline')
}) })
test('getPrintTerminalAdminStatusChangeError 允许默认终端被直接停用', () => {
const error = getPrintTerminalAdminStatusChangeError(
{
is_default: true
},
'disabled'
)
assert.equal(error, null)
})
test('sanitizeRenderedLabelImages 会按 equipment_ids 顺序重排网页生成的 PNG 标签', () => {
const renderedLabels = sanitizeRenderedLabelImages(
[
{
equipment_id: 2,
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0y'
},
{
equipment_id: 1,
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x'
}
],
[1, 2]
)
assert.deepEqual(renderedLabels, [
{
equipment_id: 1,
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x'
},
{
equipment_id: 2,
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0y'
}
])
})
test('sanitizeRenderedLabelImages 在缺少网页成品图时直接报错,避免错误打印旧版布局', () => {
assert.throws(
() =>
sanitizeRenderedLabelImages(
[
{
equipment_id: 1,
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x'
}
],
[1, 2]
),
/成品标签图/
)
})
test('stripRenderedLabelData 不向管理页返回大图或内部存储引用', () => {
const payload = stripRenderedLabelData({
job_type: 'asset_label',
generated_at: '2026-03-26T09:00:00.000Z',
equipment_ids: [1],
items: [],
rendered_labels: [
{
equipment_id: 1,
image_data_url: 'data:image/png;base64,ZmFrZS1pbWFnZS0x'
}
],
rendered_label_files: [
{
equipment_id: 1,
storage_key: '20260326/test/1.png',
content_type: 'image/png'
}
]
})
assert.deepEqual(payload, {
job_type: 'asset_label',
generated_at: '2026-03-26T09:00:00.000Z',
equipment_ids: [1],
items: []
})
})

View File

@ -0,0 +1,113 @@
export type OpenBorrowAssetPreview = {
equipmentId?: number | null
borrowRecordId?: number | null
personId?: number | null
equipmentCode: string
equipmentName: string
}
type HrSyncPersonDisplayNameSource = {
nickname?: string | null
realName?: string | null
}
const safeTrim = (value: unknown) => {
if (value === null || value === undefined) {
return null
}
const text = String(value).trim()
return text ? text : null
}
const getConfiguredSet = (value: string, defaultValue: string) =>
new Set(
((value && value.trim() ? value : defaultValue) || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean)
)
const getInactiveStatusValues = () =>
getConfiguredSet(process.env.HR_SYNC_INACTIVE_STATUS_VALUES || '', '2,5')
const getInactiveStatusKeywords = () =>
Array.from(
getConfiguredSet(
process.env.HR_SYNC_INACTIVE_STATUS_KEYWORDS || '',
'离职,停用,停职,离岗,辞职,注销,禁用,失效,inactive,disabled,left'
)
).map((keyword) => keyword.toLowerCase())
export const resolveSourcePersonStatus = (employeeStatus: unknown): 'active' | 'inactive' => {
const inactiveStatusValues = getInactiveStatusValues()
const sourceStatus =
employeeStatus === null || employeeStatus === undefined ? null : String(employeeStatus).trim()
if (sourceStatus && inactiveStatusValues.has(sourceStatus)) {
return 'inactive'
}
if (sourceStatus) {
const normalizedStatus = sourceStatus.toLowerCase()
const inactiveStatusKeywords = getInactiveStatusKeywords()
if (inactiveStatusKeywords.some((keyword) => normalizedStatus.includes(keyword))) {
return 'inactive'
}
}
return 'active'
}
export const formatBorrowedAssetSummary = (assets: OpenBorrowAssetPreview[], limit = 3) => {
if (assets.length === 0) {
return '暂无待处理资产'
}
const summary = assets
.slice(0, limit)
.map((asset) => `${asset.equipmentCode} / ${asset.equipmentName}`)
.join('、')
return assets.length > limit ? `${summary} 等 ${assets.length} 项` : summary
}
export const resolveHrSyncPersonDisplayName = (person: HrSyncPersonDisplayNameSource) =>
safeTrim(person.nickname) || safeTrim(person.realName) || '未命名'
const extractBorrowedAssetSummary = (detail: string) => {
const matched = detail.match(/名下(?:待处理)?资产:([^。]+)/)
return matched?.[1]?.trim() || null
}
const extractStructuredBorrowedAssets = (afterSnapshot?: Record<string, any> | null) => {
if (!afterSnapshot || !Array.isArray(afterSnapshot.assets)) {
return []
}
return afterSnapshot.assets
.map((asset: Record<string, any>) => ({
equipmentCode: safeTrim(asset?.equipmentCode) || '',
equipmentName: safeTrim(asset?.equipmentName) || ''
}))
.filter((asset) => asset.equipmentCode && asset.equipmentName)
}
export const buildHrSyncPreviewLine = (change: {
changeType: string
title: string
detail: string
afterSnapshot?: Record<string, any> | null
}) => {
if (change.changeType !== 'person_deactivated' && change.changeType !== 'person_position_changed') {
return change.title
}
const structuredAssets = extractStructuredBorrowedAssets(change.afterSnapshot)
const assetSummary =
structuredAssets.length > 0
? formatBorrowedAssetSummary(structuredAssets)
: extractBorrowedAssetSummary(change.detail)
return assetSummary ? `${change.title}(名下资产:${assetSummary})` : change.title
}

View File

@ -7,6 +7,12 @@ import {
buildBusinessDepartmentWhere, buildBusinessDepartmentWhere,
buildSyncedBusinessDepartmentWhere buildSyncedBusinessDepartmentWhere
} from '../utils/organization' } from '../utils/organization'
import {
buildHrSyncPreviewLine,
resolveHrSyncPersonDisplayName,
resolveSourcePersonStatus,
type OpenBorrowAssetPreview
} from './hrSyncHelpers'
type SourceDepartment = { type SourceDepartment = {
sourceGroupId: number | null sourceGroupId: number | null
@ -147,6 +153,7 @@ const REQUIRED_SQLSERVER_FIELDS = [
'SQLSERVER_USER', 'SQLSERVER_USER',
'SQLSERVER_PASSWORD' 'SQLSERVER_PASSWORD'
] as const ] as const
const DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE = 'Asia/Shanghai'
const departmentCollator = new Intl.Collator('zh-CN', { const departmentCollator = new Intl.Collator('zh-CN', {
numeric: true, numeric: true,
@ -181,9 +188,6 @@ const getConfiguredSet = (value: string, defaultValue: string) =>
const getFormalStatusValues = () => const getFormalStatusValues = () =>
getConfiguredSet(process.env.HR_SYNC_ALLOWED_STATUS_VALUES || '', '3') getConfiguredSet(process.env.HR_SYNC_ALLOWED_STATUS_VALUES || '', '3')
const getInactiveStatusValues = () =>
getConfiguredSet(process.env.HR_SYNC_INACTIVE_STATUS_VALUES || '', '5')
const getExcludedSourceDepartmentNames = () => const getExcludedSourceDepartmentNames = () =>
getConfiguredSet( getConfiguredSet(
process.env.HR_SYNC_EXCLUDED_DEPARTMENT_NAMES || process.env.HR_SYNC_EXCLUDED_DEPARTMENTS || '', process.env.HR_SYNC_EXCLUDED_DEPARTMENT_NAMES || process.env.HR_SYNC_EXCLUDED_DEPARTMENTS || '',
@ -192,6 +196,61 @@ const getExcludedSourceDepartmentNames = () =>
const getHrSyncSourceMode = (): HrSyncSourceMode => 'register_primary' const getHrSyncSourceMode = (): HrSyncSourceMode => 'register_primary'
const isValidTimeZone = (timeZone: string) => {
try {
new Intl.DateTimeFormat('zh-CN', { timeZone }).format(new Date())
return true
} catch {
return false
}
}
const getConfiguredScheduleTimezone = () => {
const configuredTimezone = (process.env.HR_SYNC_TIMEZONE || '').trim()
if (!configuredTimezone) {
return DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE
}
if (isValidTimeZone(configuredTimezone)) {
return configuredTimezone
}
console.warn(
`[hr-sync] invalid HR_SYNC_TIMEZONE=${configuredTimezone}, fallback=${DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE}`
)
return DEFAULT_HR_SYNC_SCHEDULE_TIMEZONE
}
const getCurrentClockInTimeZone = (date: Date, timeZone: string) => {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
})
const partMap = new Map<string, string>()
for (const part of formatter.formatToParts(date)) {
if (part.type !== 'literal') {
partMap.set(part.type, part.value)
}
}
const year = partMap.get('year') || '0000'
const month = partMap.get('month') || '00'
const day = partMap.get('day') || '00'
const hour = partMap.get('hour') || '00'
const minute = partMap.get('minute') || '00'
return {
dateKey: `${year}-${month}-${day}`,
timeKey: `${hour}:${minute}`
}
}
export const getHrSyncConfigStatus = () => { export const getHrSyncConfigStatus = () => {
const missingFields = REQUIRED_SQLSERVER_FIELDS.filter((field) => { const missingFields = REQUIRED_SQLSERVER_FIELDS.filter((field) => {
const value = process.env[field] const value = process.env[field]
@ -202,7 +261,8 @@ export const getHrSyncConfigStatus = () => {
enabled: process.env.HR_SYNC_ENABLED === 'true', enabled: process.env.HR_SYNC_ENABLED === 'true',
complete: missingFields.length === 0, complete: missingFields.length === 0,
missingFields, missingFields,
scheduleTimes: getConfiguredScheduleTimes() scheduleTimes: getConfiguredScheduleTimes(),
scheduleTimezone: getConfiguredScheduleTimezone()
} }
} }
@ -258,8 +318,8 @@ const formatDepartmentName = (value: string | null | undefined) => value || '未
const inferTriggerMode = (operator: string): SyncTriggerMode => const inferTriggerMode = (operator: string): SyncTriggerMode =>
operator.includes('scheduler') ? 'scheduler' : 'manual' operator.includes('scheduler') ? 'scheduler' : 'manual'
const getConfiguredScheduleTimes = () => const getConfiguredScheduleTimes = () => {
Array.from( const scheduleTimes = Array.from(
new Set( new Set(
(process.env.HR_SYNC_SCHEDULES || process.env.HR_SYNC_SCHEDULE || '03:00') (process.env.HR_SYNC_SCHEDULES || process.env.HR_SYNC_SCHEDULE || '03:00')
.split(',') .split(',')
@ -268,6 +328,9 @@ const getConfiguredScheduleTimes = () =>
) )
) )
return scheduleTimes.length > 0 ? scheduleTimes : ['03:00']
}
const normalizePhone = (value: unknown) => { const normalizePhone = (value: unknown) => {
const text = safeTrim(value) const text = safeTrim(value)
if (!text) return null if (!text) return null
@ -293,16 +356,8 @@ const parseExtensionFlowerName = (extension: unknown) => {
} }
} }
const resolvePersonStatus = (employeeStatus: unknown): 'active' | 'inactive' => { const resolvePersonStatus = (employeeStatus: unknown): 'active' | 'inactive' =>
const inactiveStatusValues = getInactiveStatusValues() resolveSourcePersonStatus(safeTrim(employeeStatus))
const sourceStatus = safeTrim(employeeStatus)
if (sourceStatus && inactiveStatusValues.has(sourceStatus)) {
return 'inactive'
}
return 'active'
}
const resolveDepartmentBinding = ( const resolveDepartmentBinding = (
sourceDepartmentName: string | null, sourceDepartmentName: string | null,
@ -720,6 +775,45 @@ const getPersonSnapshotMap = async () => {
) )
} }
const getOpenBorrowAssetMap = async () => {
const borrowRecords = await prisma.borrowRecord.findMany({
where: {
return_time: null
},
orderBy: [
{ person_id: 'asc' },
{ equipment_code: 'asc' }
],
select: {
id: true,
equipment_id: true,
person_id: true,
equipment_code: true,
equipment: {
select: {
equipment_name: true
}
}
}
})
const assetMap = new Map<number, OpenBorrowAssetPreview[]>()
for (const record of borrowRecords) {
const current = assetMap.get(record.person_id) || []
current.push({
equipmentId: record.equipment_id,
borrowRecordId: record.id,
personId: record.person_id,
equipmentCode: record.equipment_code,
equipmentName: record.equipment?.equipment_name || '未命名资产'
})
assetMap.set(record.person_id, current)
}
return assetMap
}
const buildSyncChange = (change: HrSyncChangeDraft) => change const buildSyncChange = (change: HrSyncChangeDraft) => change
const diffDepartmentSnapshots = ( const diffDepartmentSnapshots = (
@ -779,7 +873,8 @@ const diffDepartmentSnapshots = (
const diffPersonSnapshots = ( const diffPersonSnapshots = (
beforeMap: Map<string, PersonSnapshot>, beforeMap: Map<string, PersonSnapshot>,
afterMap: Map<string, PersonSnapshot> afterMap: Map<string, PersonSnapshot>,
openBorrowAssetMap: Map<number, OpenBorrowAssetPreview[]>
) => { ) => {
const changes: HrSyncChangeDraft[] = [] const changes: HrSyncChangeDraft[] = []
const keys = Array.from(new Set([...beforeMap.keys(), ...afterMap.keys()])).sort((left, right) => const keys = Array.from(new Set([...beforeMap.keys(), ...afterMap.keys()])).sort((left, right) =>
@ -789,6 +884,8 @@ const diffPersonSnapshots = (
for (const key of keys) { for (const key of keys) {
const before = beforeMap.get(key) const before = beforeMap.get(key)
const after = afterMap.get(key) const after = afterMap.get(key)
const currentPerson = after || before
const personDisplayName = resolveHrSyncPersonDisplayName(currentPerson || {})
if (!before && after && after.status === 'active') { if (!before && after && after.status === 'active') {
changes.push( changes.push(
@ -797,10 +894,13 @@ const diffPersonSnapshots = (
entityKey: key, entityKey: key,
entityName: after.realName, entityName: after.realName,
changeType: 'person_created', changeType: 'person_created',
title: `新人入职:${after.realName}`, title: `新人入职:${personDisplayName}`,
detail: `系统归属部门:${formatDepartmentName(after.mainDepartmentName)},岗位:${after.position || '未填写'}。`, detail: `系统归属部门:${formatDepartmentName(after.mainDepartmentName)},岗位:${after.position || '未填写'}。`,
beforeSnapshot: null, beforeSnapshot: null,
afterSnapshot: after afterSnapshot: {
...after,
displayName: personDisplayName
}
}) })
) )
continue continue
@ -810,6 +910,8 @@ const diffPersonSnapshots = (
continue continue
} }
const openBorrowAssets = openBorrowAssetMap.get(after.id || before.id) || []
if (before.status !== after.status) { if (before.status !== after.status) {
changes.push( changes.push(
buildSyncChange({ buildSyncChange({
@ -817,13 +919,21 @@ const diffPersonSnapshots = (
entityKey: key, entityKey: key,
entityName: after.realName, entityName: after.realName,
changeType: after.status === 'inactive' ? 'person_deactivated' : 'person_reactivated', changeType: after.status === 'inactive' ? 'person_deactivated' : 'person_reactivated',
title: `${after.status === 'inactive' ? '人员离职/停用' : '人员重新启用'}:${after.realName}`, title: `${after.status === 'inactive' ? '人员离职/停用' : '人员重新启用'}:${personDisplayName}`,
detail: detail:
after.status === 'inactive' after.status === 'inactive'
? '该人员已不在本次同步名单中,请行政确认是否还存在资产交接或借还处理。' ? `该人员已不在本次同步名单中,请行政确认是否还存在资产交接或借还处理。${
openBorrowAssets.length > 0
? ` 当前名下还有 ${openBorrowAssets.length} 件待处理资产,可在下方直接快捷入库。`
: ' 当前名下没有待处理资产。'
}`
: '该人员重新进入同步名单,可继续参与借还与盘点流程。', : '该人员重新进入同步名单,可继续参与借还与盘点流程。',
beforeSnapshot: before, beforeSnapshot: before,
afterSnapshot: after afterSnapshot: {
...after,
displayName: personDisplayName,
assets: openBorrowAssets
}
}) })
) )
} }
@ -839,10 +949,13 @@ const diffPersonSnapshots = (
entityKey: key, entityKey: key,
entityName: after.realName, entityName: after.realName,
changeType: 'person_main_department_changed', changeType: 'person_main_department_changed',
title: `主部门变动:${after.realName}`, title: `主部门变动:${personDisplayName}`,
detail: `主部门已从“${formatDepartmentName(before.mainDepartmentName)}”调整为“${formatDepartmentName(after.mainDepartmentName)}”。`, detail: `主部门已从“${formatDepartmentName(before.mainDepartmentName)}”调整为“${formatDepartmentName(after.mainDepartmentName)}”。`,
beforeSnapshot: before, beforeSnapshot: before,
afterSnapshot: after afterSnapshot: {
...after,
displayName: personDisplayName
}
}) })
) )
} }
@ -854,10 +967,13 @@ const diffPersonSnapshots = (
entityKey: key, entityKey: key,
entityName: after.realName, entityName: after.realName,
changeType: 'person_departments_changed', changeType: 'person_departments_changed',
title: `关联部门变动:${after.realName}`, title: `关联部门变动:${personDisplayName}`,
detail: `关联部门已从“${formatNameList(before.departmentNames)}”调整为“${formatNameList(after.departmentNames)}”。`, detail: `关联部门已从“${formatNameList(before.departmentNames)}”调整为“${formatNameList(after.departmentNames)}”。`,
beforeSnapshot: before, beforeSnapshot: before,
afterSnapshot: after afterSnapshot: {
...after,
displayName: personDisplayName
}
}) })
) )
} }
@ -869,10 +985,18 @@ const diffPersonSnapshots = (
entityKey: key, entityKey: key,
entityName: after.realName, entityName: after.realName,
changeType: 'person_position_changed', changeType: 'person_position_changed',
title: `岗位变动:${after.realName}`, title: `岗位变动:${personDisplayName}`,
detail: `岗位已从“${before.position || '未填写'}”调整为“${after.position || '未填写'}”。`, detail:
`岗位已从“${before.position || '未填写'}”调整为“${after.position || '未填写'}”。` +
(openBorrowAssets.length > 0
? ` 当前名下还有 ${openBorrowAssets.length} 件资产,可在下方直接快捷入库。`
: ''),
beforeSnapshot: before, beforeSnapshot: before,
afterSnapshot: after afterSnapshot: {
...after,
displayName: personDisplayName,
assets: openBorrowAssets
}
}) })
) )
} }
@ -910,7 +1034,13 @@ const summarizeHrSyncChanges = (changes: HrSyncChangeDraft[]): HrSyncSummary =>
change_type: change.changeType change_type: change.changeType
})) }))
const previewText = previewChanges.map((change, index) => `${index + 1}. ${change.title}`).join('\n') const previewText = previewChanges
.map((change, index) => `${index + 1}. ${buildHrSyncPreviewLine({
changeType: change.change_type,
title: change.title,
detail: change.detail
})}`)
.join('\n')
return { return {
title: `组织同步发现 ${changes.length} 项变化`, title: `组织同步发现 ${changes.length} 项变化`,
@ -981,44 +1111,41 @@ const persistHrSyncArtifacts = async ({
}) })
} }
let notificationId: number | null = null const notification = await tx.notification.create({
if (status === 'failed' || changes.length > 0) { data: {
const notification = await tx.notification.create({ title: summary.title,
data: { content: summary.content,
title: summary.title, type: 'hr_sync',
content: summary.content, level: status === 'failed' ? 'error' : changes.length > 0 ? 'warning' : 'info',
type: 'hr_sync', action_url: '/notifications',
level: status === 'failed' ? 'error' : 'warning', related_sync_job_id: syncJob.id,
action_url: '/settings?tab=sync', metadata: toJsonText({
related_sync_job_id: syncJob.id, status,
metadata: toJsonText({ changes_count: changes.length,
status, counts: summary.counts,
changes_count: changes.length, preview_changes: summary.previewChanges
counts: summary.counts,
preview_changes: summary.previewChanges
})
}
})
notificationId = notification.id
const adminUsers = await tx.user.findMany({
where: {
role: 'admin',
status: 'active'
},
select: { id: true }
})
if (adminUsers.length > 0) {
await tx.userNotification.createMany({
data: adminUsers.map((user) => ({
user_id: user.id,
notification_id: notification.id
})),
skipDuplicates: true
}) })
} }
})
const notificationId: number | null = notification.id
const adminUsers = await tx.user.findMany({
where: {
role: 'admin',
status: 'active'
},
select: { id: true }
})
if (adminUsers.length > 0) {
await tx.userNotification.createMany({
data: adminUsers.map((user) => ({
user_id: user.id,
notification_id: notification.id
})),
skipDuplicates: true
})
} }
return { return {
@ -1459,6 +1586,9 @@ const syncPersons = async (sourcePersons: SourcePerson[], localDepartmentMap: Lo
const deactivatedResult = await prisma.person.updateMany({ const deactivatedResult = await prisma.person.updateMany({
where: { where: {
source_system: HR_SOURCE_SYSTEM, source_system: HR_SOURCE_SYSTEM,
status: {
not: 'inactive'
},
source_userid: { source_userid: {
notIn: sourceUserIds notIn: sourceUserIds
} }
@ -1651,14 +1781,15 @@ export const runHrSync = async (
} }
} }
const [afterDepartmentMap, afterPersonMap] = await Promise.all([ const [afterDepartmentMap, afterPersonMap, openBorrowAssetMap] = await Promise.all([
getDepartmentSnapshotMap(), getDepartmentSnapshotMap(),
getPersonSnapshotMap() getPersonSnapshotMap(),
getOpenBorrowAssetMap()
]) ])
const changes: HrSyncChangeDraft[] = [ const changes: HrSyncChangeDraft[] = [
...diffDepartmentSnapshots(beforeDepartmentMap, afterDepartmentMap), ...diffDepartmentSnapshots(beforeDepartmentMap, afterDepartmentMap),
...diffPersonSnapshots(beforePersonMap, afterPersonMap) ...diffPersonSnapshots(beforePersonMap, afterPersonMap, openBorrowAssetMap)
] ]
if (equipmentDepartmentMigration.unresolved > 0) { if (equipmentDepartmentMigration.unresolved > 0) {
@ -1753,12 +1884,12 @@ export const startHrSyncScheduler = () => {
} }
const scheduleTimes = getConfiguredScheduleTimes() const scheduleTimes = getConfiguredScheduleTimes()
const scheduleTimezone = getConfiguredScheduleTimezone()
setInterval(async () => { setInterval(async () => {
const now = new Date() const now = new Date()
const hh = String(now.getHours()).padStart(2, '0') const currentClock = getCurrentClockInTimeZone(now, scheduleTimezone)
const mm = String(now.getMinutes()).padStart(2, '0') const currentTime = currentClock.timeKey
const currentTime = `${hh}:${mm}` const runKey = `${currentClock.dateKey}@${currentTime}`
const runKey = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}@${currentTime}`
if (!scheduleTimes.includes(currentTime) || schedulerRunning || lastSchedulerRunKeys.has(runKey)) { if (!scheduleTimes.includes(currentTime) || schedulerRunning || lastSchedulerRunKeys.has(runKey)) {
return return
@ -1768,7 +1899,9 @@ export const startHrSyncScheduler = () => {
try { try {
await runHrSync('system-scheduler', 'scheduler') await runHrSync('system-scheduler', 'scheduler')
lastSchedulerRunKeys.add(runKey) lastSchedulerRunKeys.add(runKey)
console.log(`[hr-sync] completed at ${now.toISOString()}`) console.log(
`[hr-sync] completed at ${now.toISOString()} (schedule_timezone=${scheduleTimezone} ${currentTime})`
)
} catch (error) { } catch (error) {
console.error('[hr-sync] failed:', error) console.error('[hr-sync] failed:', error)
} finally { } finally {
@ -1776,5 +1909,7 @@ export const startHrSyncScheduler = () => {
} }
}, 60 * 1000) }, 60 * 1000)
console.log(`[hr-sync] scheduler started, schedule=${scheduleTimes.join(',')}`) console.log(
`[hr-sync] scheduler started, schedule=${scheduleTimes.join(',')}, timezone=${scheduleTimezone}`
)
} }

View File

@ -1,13 +1,22 @@
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import type { PrismaClient } from '@prisma/client' import type { PrismaClient } from '@prisma/client'
import prisma from '../utils/prisma' import prisma from '../utils/prisma'
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization' import { buildBusinessDepartmentWhere } from '../utils/organization'
import {
normalizeInventoryScopeInput,
resolveUniqueActiveBorrowRecord,
type NormalizeInventoryScopeInputParams,
type NormalizedInventoryScopeInput
} from '../utils/inventoryScope'
import { getInventoryTaskDeleteBlockedReason } from '../utils/inventoryTaskLifecycle'
type InventoryScopeOptions = { type ScopeEntity = {
departmentId?: number | null id: number
assetTypeId?: number | null name: string
} }
type InventoryScopeOptions = NormalizeInventoryScopeInputParams | NormalizedInventoryScopeInput
type FrozenParticipantDraft = { type FrozenParticipantDraft = {
person_id: number person_id: number
person_name: string person_name: string
@ -37,8 +46,9 @@ type FrozenItemDraft = {
} }
type InventoryScopeBuildResult = { type InventoryScopeBuildResult = {
scopeDepartment: { id: number; name: string } | null scopeDepartments: ScopeEntity[]
scopeAssetType: { id: number; name: string } | null scopeAssetTypes: ScopeEntity[]
scopeStorageLocations: ScopeEntity[]
participants: FrozenParticipantDraft[] participants: FrozenParticipantDraft[]
items: FrozenItemDraft[] items: FrozenItemDraft[]
} }
@ -62,54 +72,101 @@ const INVENTORY_PARTICIPANT_STATUS = {
const EXCLUDED_EQUIPMENT_STATUSES = ['已报废'] const EXCLUDED_EQUIPMENT_STATUSES = ['已报废']
const buildScopeWhere = ({ departmentId, assetTypeId }: InventoryScopeOptions) => { const buildScopeWhere = ({
departmentIds,
assetTypeIds,
storageLocationIds
}: NormalizedInventoryScopeInput) => {
const where: Record<string, any> = { const where: Record<string, any> = {
status: { status: {
notIn: EXCLUDED_EQUIPMENT_STATUSES notIn: EXCLUDED_EQUIPMENT_STATUSES
} }
} }
if (departmentId) { if (departmentIds.length > 0) {
where.department_id = departmentId where.department_id = {
in: departmentIds
}
} }
if (assetTypeId) { if (assetTypeIds.length > 0) {
where.asset_type_id = assetTypeId where.asset_type_id = {
in: assetTypeIds
}
}
if (storageLocationIds.length > 0) {
where.storage_location_id = {
in: storageLocationIds
}
} }
return where return where
} }
const getScopeMeta = async ({ departmentId, assetTypeId }: InventoryScopeOptions) => { const getScopeMeta = async ({
const [department, assetType] = await Promise.all([ departmentIds,
departmentId assetTypeIds,
? prisma.department.findFirst({ storageLocationIds
}: NormalizedInventoryScopeInput) => {
const [departments, assetTypes, storageLocations] = await Promise.all([
departmentIds.length > 0
? prisma.department.findMany({
where: { where: {
id: departmentId, id: {
...buildSyncedBusinessDepartmentWhere() in: departmentIds
},
AND: [
buildBusinessDepartmentWhere(),
{ status: 'active' }
]
}, },
select: { id: true, name: true, status: true } orderBy: { name: 'asc' },
select: { id: true, name: true }
}) })
: Promise.resolve(null), : Promise.resolve([]),
assetTypeId assetTypeIds.length > 0
? prisma.assetType.findUnique({ ? prisma.assetType.findMany({
where: { id: assetTypeId }, where: {
select: { id: true, name: true, status: true } id: {
in: assetTypeIds
},
status: 'active'
},
orderBy: { name: 'asc' },
select: { id: true, name: true }
}) })
: Promise.resolve(null) : Promise.resolve([]),
storageLocationIds.length > 0
? prisma.storageLocation.findMany({
where: {
id: {
in: storageLocationIds
},
status: 'active'
},
orderBy: { name: 'asc' },
select: { id: true, name: true }
})
: Promise.resolve([])
]) ])
if (departmentId && (!department || department.status !== 'active')) { if (departmentIds.length > 0 && departments.length !== departmentIds.length) {
throw new Error('所选业务部门不存在或已停用') throw new Error('所选业务部门不存在或已停用')
} }
if (assetTypeId && (!assetType || assetType.status !== 'active')) { if (assetTypeIds.length > 0 && assetTypes.length !== assetTypeIds.length) {
throw new Error('所选资产类型不存在或已停用') throw new Error('所选资产类型不存在或已停用')
} }
if (storageLocationIds.length > 0 && storageLocations.length !== storageLocationIds.length) {
throw new Error('所选地区不存在或已停用')
}
return { return {
scopeDepartment: department ? { id: department.id, name: department.name } : null, scopeDepartments: departments,
scopeAssetType: assetType ? { id: assetType.id, name: assetType.name } : null scopeAssetTypes: assetTypes,
scopeStorageLocations: storageLocations
} }
} }
@ -243,10 +300,16 @@ const buildAssetTypeBreakdown = (items: FrozenItemDraft[]) =>
export const buildInventoryScope = async ( export const buildInventoryScope = async (
options: InventoryScopeOptions options: InventoryScopeOptions
): Promise<InventoryScopeBuildResult> => { ): Promise<InventoryScopeBuildResult> => {
const meta = await getScopeMeta(options) const normalizedScope = normalizeInventoryScopeInput(options)
const meta = await getScopeMeta(normalizedScope)
const equipments = await prisma.equipment.findMany({ const equipments = await prisma.equipment.findMany({
where: buildScopeWhere(options), where: buildScopeWhere(normalizedScope),
orderBy: [{ department_id: 'asc' }, { asset_type_id: 'asc' }, { created_at: 'desc' }], orderBy: [
{ storage_location_id: 'asc' },
{ department_id: 'asc' },
{ asset_type_id: 'asc' },
{ created_at: 'desc' }
],
select: { select: {
id: true, id: true,
equipment_code: true, equipment_code: true,
@ -267,6 +330,12 @@ export const buildInventoryScope = async (
id: true, id: true,
name: true name: true
} }
},
storageLocation: {
select: {
id: true,
name: true
}
} }
} }
}) })
@ -281,12 +350,13 @@ export const buildInventoryScope = async (
for (const equipment of equipments) { for (const equipment of equipments) {
const borrowAnchorId = resolveBorrowAnchorId(equipment.id, equipmentMap, activeBorrowRecordMap) const borrowAnchorId = resolveBorrowAnchorId(equipment.id, equipmentMap, activeBorrowRecordMap)
const activeBorrowRecords = activeBorrowRecordMap.get(borrowAnchorId) || [] const activeBorrowRecords = activeBorrowRecordMap.get(borrowAnchorId) || []
const activeBorrowRecord = const activeBorrowRecord = resolveUniqueActiveBorrowRecord(activeBorrowRecords)
activeBorrowRecords.length === 1 && activeBorrowRecords[0].person?.status === 'active'
? activeBorrowRecords[0]
: null
const participantPersonId = activeBorrowRecord?.person?.id || null if (!activeBorrowRecord?.person) {
continue
}
const participantPersonId = activeBorrowRecord.person.id
const snapshotDepartmentId = const snapshotDepartmentId =
activeBorrowRecord?.person?.mainDepartment?.id || activeBorrowRecord?.person?.mainDepartment?.id ||
equipment.department?.id || equipment.department?.id ||
@ -297,19 +367,17 @@ export const buildInventoryScope = async (
equipment.department?.name || equipment.department?.name ||
null null
if (participantPersonId && activeBorrowRecord?.person) { participantMap.set(participantPersonId, {
participantMap.set(participantPersonId, { person_id: activeBorrowRecord.person.id,
person_id: activeBorrowRecord.person.id, person_name: activeBorrowRecord.person.real_name,
person_name: activeBorrowRecord.person.real_name, person_nickname: activeBorrowRecord.person.nickname,
person_nickname: activeBorrowRecord.person.nickname, person_source_userid: activeBorrowRecord.person.source_userid || null,
person_source_userid: activeBorrowRecord.person.source_userid || null, department_id: activeBorrowRecord.person.mainDepartment?.id || null,
department_id: activeBorrowRecord.person.mainDepartment?.id || null, department_name:
department_name: activeBorrowRecord.person.mainDepartment?.name ||
activeBorrowRecord.person.mainDepartment?.name || activeBorrowRecord.person.department ||
activeBorrowRecord.person.department || null
null })
})
}
items.push({ items.push({
participant_person_id: participantPersonId, participant_person_id: participantPersonId,
@ -327,13 +395,14 @@ export const buildInventoryScope = async (
snapshot_person_nickname: activeBorrowRecord?.person?.nickname || null, snapshot_person_nickname: activeBorrowRecord?.person?.nickname || null,
snapshot_status: equipment.status, snapshot_status: equipment.status,
source_borrow_record_id: activeBorrowRecord?.id || null, source_borrow_record_id: activeBorrowRecord?.id || null,
is_unassigned: !activeBorrowRecord is_unassigned: false
}) })
} }
return { return {
scopeDepartment: meta.scopeDepartment, scopeDepartments: meta.scopeDepartments,
scopeAssetType: meta.scopeAssetType, scopeAssetTypes: meta.scopeAssetTypes,
scopeStorageLocations: meta.scopeStorageLocations,
participants: Array.from(participantMap.values()).sort((left, right) => participants: Array.from(participantMap.values()).sort((left, right) =>
left.person_nickname.localeCompare(right.person_nickname, 'zh-CN') left.person_nickname.localeCompare(right.person_nickname, 'zh-CN')
), ),
@ -343,13 +412,17 @@ export const buildInventoryScope = async (
export const previewInventoryTask = async (options: InventoryScopeOptions) => { export const previewInventoryTask = async (options: InventoryScopeOptions) => {
const scope = await buildInventoryScope(options) const scope = await buildInventoryScope(options)
const unassignedCount = scope.items.filter((item) => item.is_unassigned).length
return { return {
scope_department: scope.scopeDepartment, scope_department: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0] : null,
scope_asset_type: scope.scopeAssetType, scope_asset_type: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0] : null,
scope_departments: scope.scopeDepartments,
scope_asset_types: scope.scopeAssetTypes,
scope_storage_locations: scope.scopeStorageLocations,
participants_count: scope.participants.length, participants_count: scope.participants.length,
items_count: scope.items.length, items_count: scope.items.length,
unassigned_count: scope.items.filter((item) => item.is_unassigned).length, unassigned_count: unassignedCount,
department_breakdown: buildDepartmentBreakdown(scope.items), department_breakdown: buildDepartmentBreakdown(scope.items),
asset_type_breakdown: buildAssetTypeBreakdown(scope.items), asset_type_breakdown: buildAssetTypeBreakdown(scope.items),
participants_sample: scope.participants.slice(0, 10), participants_sample: scope.participants.slice(0, 10),
@ -358,8 +431,11 @@ export const previewInventoryTask = async (options: InventoryScopeOptions) => {
} }
const buildTaskScopeSnapshot = (scope: InventoryScopeBuildResult) => ({ const buildTaskScopeSnapshot = (scope: InventoryScopeBuildResult) => ({
scope_department: scope.scopeDepartment, scope_department: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0] : null,
scope_asset_type: scope.scopeAssetType, scope_asset_type: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0] : null,
scope_departments: scope.scopeDepartments,
scope_asset_types: scope.scopeAssetTypes,
scope_storage_locations: scope.scopeStorageLocations,
participants_count: scope.participants.length, participants_count: scope.participants.length,
items_count: scope.items.length, items_count: scope.items.length,
unassigned_count: scope.items.filter((item) => item.is_unassigned).length, unassigned_count: scope.items.filter((item) => item.is_unassigned).length,
@ -447,34 +523,78 @@ const refreshParticipantStatus = async (tx: PrismaClient, participantId: number)
}) })
} }
const serializeTaskListItem = (task: any) => ({ const parseScopeSnapshot = (task: any) => {
id: task.id, let snapshot = null
title: task.title,
description: task.description, if (task.scope_snapshot_json) {
status: task.status, try {
public_token: task.public_token, snapshot = JSON.parse(task.scope_snapshot_json)
scope_department_id: task.scope_department_id, } catch {
scope_department_name: task.scope_department_name, snapshot = null
scope_asset_type_id: task.scope_asset_type_id, }
scope_asset_type_name: task.scope_asset_type_name, }
participants_count: task.participants_count,
items_count: task.items_count, const scopeDepartments = Array.isArray(snapshot?.scope_departments)
completed_count: task.completed_count, ? snapshot.scope_departments
exception_count: task.exception_count, : task.scope_department_id && task.scope_department_name
unassigned_count: task.unassigned_count, ? [{ id: task.scope_department_id, name: task.scope_department_name }]
created_by: task.created_by, : []
created_by_name: task.creator?.name || task.creator?.username || null,
closed_at: task.closed_at, const scopeAssetTypes = Array.isArray(snapshot?.scope_asset_types)
created_at: task.created_at, ? snapshot.scope_asset_types
updated_at: task.updated_at : task.scope_asset_type_id && task.scope_asset_type_name
}) ? [{ id: task.scope_asset_type_id, name: task.scope_asset_type_name }]
: []
const scopeStorageLocations = Array.isArray(snapshot?.scope_storage_locations)
? snapshot.scope_storage_locations
: []
return {
scope_snapshot: snapshot,
scope_departments: scopeDepartments,
scope_asset_types: scopeAssetTypes,
scope_storage_locations: scopeStorageLocations
}
}
const serializeTaskListItem = (task: any) => {
const serializedScope = parseScopeSnapshot(task)
return {
id: task.id,
title: task.title,
description: task.description,
status: task.status,
public_token: task.public_token,
scope_department_id: task.scope_department_id,
scope_department_name: task.scope_department_name,
scope_asset_type_id: task.scope_asset_type_id,
scope_asset_type_name: task.scope_asset_type_name,
participants_count: task.participants_count,
items_count: task.items_count,
completed_count: task.completed_count,
exception_count: task.exception_count,
unassigned_count: task.unassigned_count,
created_by: task.created_by,
created_by_name: task.creator?.name || task.creator?.username || null,
closed_at: task.closed_at,
created_at: task.created_at,
updated_at: task.updated_at,
...serializedScope
}
}
export const createInventoryTask = async ( export const createInventoryTask = async (
input: { input: {
title: string title: string
description?: string | null description?: string | null
department_id?: number | null department_id?: number | null
asset_type_id?: number | null asset_type_id?: number | null
storage_location_id?: number | null
department_ids?: number[] | null
asset_type_ids?: number[] | null
storage_location_ids?: number[] | null
}, },
operatorUserId: number operatorUserId: number
) => { ) => {
@ -484,8 +604,12 @@ export const createInventoryTask = async (
} }
const scope = await buildInventoryScope({ const scope = await buildInventoryScope({
departmentId: input.department_id || null, department_id: input.department_id || null,
assetTypeId: input.asset_type_id || null asset_type_id: input.asset_type_id || null,
storage_location_id: input.storage_location_id || null,
department_ids: input.department_ids || [],
asset_type_ids: input.asset_type_ids || [],
storage_location_ids: input.storage_location_ids || []
}) })
const task = await prisma.$transaction(async (tx) => { const task = await prisma.$transaction(async (tx) => {
@ -495,10 +619,10 @@ export const createInventoryTask = async (
description: input.description?.trim() || null, description: input.description?.trim() || null,
status: INVENTORY_TASK_STATUSES.active, status: INVENTORY_TASK_STATUSES.active,
public_token: createPublicToken(), public_token: createPublicToken(),
scope_department_id: scope.scopeDepartment?.id || null, scope_department_id: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0].id : null,
scope_department_name: scope.scopeDepartment?.name || null, scope_department_name: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0].name : null,
scope_asset_type_id: scope.scopeAssetType?.id || null, scope_asset_type_id: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0].id : null,
scope_asset_type_name: scope.scopeAssetType?.name || null, scope_asset_type_name: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0].name : null,
scope_snapshot_json: JSON.stringify(buildTaskScopeSnapshot(scope)), scope_snapshot_json: JSON.stringify(buildTaskScopeSnapshot(scope)),
created_by: operatorUserId created_by: operatorUserId
} }
@ -642,6 +766,8 @@ export const getInventoryTaskDetail = async (taskId: number) => {
throw new Error('盘点任务不存在') throw new Error('盘点任务不存在')
} }
const serializedScope = parseScopeSnapshot(task)
const departmentBreakdown = Array.from( const departmentBreakdown = Array.from(
task.items.reduce((map, item) => { task.items.reduce((map, item) => {
const key = item.snapshot_department_name || '未归属部门' const key = item.snapshot_department_name || '未归属部门'
@ -684,7 +810,7 @@ export const getInventoryTaskDetail = async (taskId: number) => {
return { return {
...serializeTaskListItem(task), ...serializeTaskListItem(task),
scope_snapshot: task.scope_snapshot_json ? JSON.parse(task.scope_snapshot_json) : null, ...serializedScope,
participants: task.participants, participants: task.participants,
items: task.items, items: task.items,
recent_actions: task.actions, recent_actions: task.actions,
@ -729,6 +855,29 @@ export const closeInventoryTask = async (taskId: number, operatorUserId: number)
}) })
} }
export const deleteInventoryTask = async (taskId: number) => {
const task = await prisma.inventoryTask.findUnique({
where: { id: taskId },
select: {
id: true,
status: true
}
})
if (!task) {
throw new Error('盘点任务不存在')
}
const blockedReason = getInventoryTaskDeleteBlockedReason(task.status)
if (blockedReason) {
throw new Error(blockedReason)
}
await prisma.inventoryTask.delete({
where: { id: taskId }
})
}
export const getInventoryTaskPublicInfo = async (publicToken: string) => { export const getInventoryTaskPublicInfo = async (publicToken: string) => {
const task = await prisma.inventoryTask.findUnique({ const task = await prisma.inventoryTask.findUnique({
where: { where: {
@ -746,6 +895,7 @@ export const getInventoryTaskPublicInfo = async (publicToken: string) => {
completed_count: true, completed_count: true,
exception_count: true, exception_count: true,
unassigned_count: true, unassigned_count: true,
scope_snapshot_json: true,
created_at: true, created_at: true,
closed_at: true closed_at: true
} }
@ -755,7 +905,10 @@ export const getInventoryTaskPublicInfo = async (publicToken: string) => {
throw new Error('盘点任务不存在') throw new Error('盘点任务不存在')
} }
return task return {
...task,
...parseScopeSnapshot(task)
}
} }
export const searchTaskParticipants = async (publicToken: string, keyword: string) => { export const searchTaskParticipants = async (publicToken: string, keyword: string) => {
@ -875,7 +1028,13 @@ export const getParticipantTaskContext = async (taskId: number, participantId: n
throw new Error('盘点任务会话已失效') throw new Error('盘点任务会话已失效')
} }
return { task, participant } return {
task: {
...task,
...parseScopeSnapshot(task)
},
participant
}
} }
export const getParticipantItems = async (taskId: number, participantId: number) => { export const getParticipantItems = async (taskId: number, participantId: number) => {

View File

@ -1,4 +1,6 @@
import { createHash, randomBytes } from 'crypto' import { promises as fs } from 'fs'
import path from 'path'
import { createHash, randomBytes, randomUUID } from 'crypto'
import type { Prisma, PrintJob, PrintTerminal } from '@prisma/client' import type { Prisma, PrintJob, PrintTerminal } from '@prisma/client'
import prisma from '../utils/prisma' import prisma from '../utils/prisma'
@ -60,11 +62,24 @@ export interface AssetLabelPayloadItem {
} | null } | null
} }
export interface AssetLabelRenderedImagePayload {
equipment_id: number
image_data_url: string
}
export interface AssetLabelStoredRenderFilePayload {
equipment_id: number
storage_key: string
content_type: 'image/png'
}
export interface AssetLabelJobPayload { export interface AssetLabelJobPayload {
job_type: 'asset_label' job_type: 'asset_label'
generated_at: string generated_at: string
equipment_ids: number[] equipment_ids: number[]
items: AssetLabelPayloadItem[] items: AssetLabelPayloadItem[]
rendered_labels?: AssetLabelRenderedImagePayload[]
rendered_label_files?: AssetLabelStoredRenderFilePayload[]
} }
export interface PairPrintTerminalInput { export interface PairPrintTerminalInput {
@ -129,6 +144,9 @@ const PRINT_PAIR_CODE_TTL_MINUTES = 10
const MAX_PRINT_JOB_LIST_PAGE_SIZE = 50 const MAX_PRINT_JOB_LIST_PAGE_SIZE = 50
const MAX_RETRY_ATTEMPTS = 3 const MAX_RETRY_ATTEMPTS = 3
const PAIR_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' const PAIR_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const PNG_DATA_URL_PREFIX = 'data:image/png;base64,'
const PNG_DATA_URL_PATTERN = /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/i
const PRINT_JOB_RENDER_STORAGE_ROOT = path.resolve(__dirname, '..', '..', 'uploads', 'print-jobs')
type ParsedTerminalData = { type ParsedTerminalData = {
labelProfile: PrintLabelProfileInput | null labelProfile: PrintLabelProfileInput | null
@ -351,6 +369,213 @@ const sanitizeEquipmentIds = (value: unknown) => {
return uniqueIds return uniqueIds
} }
const decodePngDataUrl = (value: unknown) => {
if (typeof value !== 'string') {
throw new PrintServiceError('网页成品标签图格式无效,请刷新页面后重试', 422)
}
const normalized = value.trim()
if (!PNG_DATA_URL_PATTERN.test(normalized) || !normalized.startsWith(PNG_DATA_URL_PREFIX)) {
throw new PrintServiceError('网页成品标签图格式无效,请刷新页面后重试', 422)
}
const base64 = normalized.slice(PNG_DATA_URL_PREFIX.length)
try {
const buffer = Buffer.from(base64, 'base64')
if (buffer.length === 0) {
throw new Error('empty image')
}
return buffer
} catch {
throw new PrintServiceError('网页成品标签图格式无效,请刷新页面后重试', 422)
}
}
export const sanitizeRenderedLabelImages = (
value: unknown,
equipmentIds: number[]
): AssetLabelRenderedImagePayload[] => {
if (!Array.isArray(value)) {
throw new PrintServiceError('当前页面缺少网页成品标签图,请刷新后重新发送打印', 422)
}
const imageMap = new Map<number, AssetLabelRenderedImagePayload>()
for (const item of value) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw new PrintServiceError('网页成品标签图格式无效,请刷新页面后重试', 422)
}
const equipmentId = Number((item as { equipment_id?: unknown }).equipment_id)
if (!Number.isInteger(equipmentId) || equipmentId <= 0) {
throw new PrintServiceError('网页成品标签图缺少有效的资产编号,请刷新页面后重试', 422)
}
if (imageMap.has(equipmentId)) {
throw new PrintServiceError(`资产 ${equipmentId} 的成品标签图重复提交,请重新发送`, 422)
}
const imageDataUrl = String((item as { image_data_url?: unknown }).image_data_url || '').trim()
decodePngDataUrl(imageDataUrl)
imageMap.set(equipmentId, {
equipment_id: equipmentId,
image_data_url: imageDataUrl
})
}
if (imageMap.size !== equipmentIds.length) {
throw new PrintServiceError('网页成品标签图数量不完整,请刷新页面后重新发送打印', 422)
}
return equipmentIds.map((equipmentId) => {
const matched = imageMap.get(equipmentId)
if (!matched) {
throw new PrintServiceError(
`资产 ${equipmentId} 缺少网页成品标签图,请刷新页面后重新发送打印`,
422
)
}
return matched
})
}
export const stripRenderedLabelData = (payload: AssetLabelJobPayload | null) => {
if (!payload) {
return null
}
const { rendered_labels: _renderedLabels, rendered_label_files: _storedFiles, ...rest } = payload
return rest
}
const sanitizeStorageKey = (storageKey: string) => {
const normalized = storageKey.replace(/\\/g, '/').trim().replace(/^\/+/, '')
if (!normalized || normalized.includes('..')) {
throw new PrintServiceError('打印任务中的标签附件路径无效', 500)
}
return normalized
}
const resolveStoredRenderAbsolutePath = (storageKey: string) => {
const normalizedKey = sanitizeStorageKey(storageKey)
const absolutePath = path.resolve(PRINT_JOB_RENDER_STORAGE_ROOT, normalizedKey)
const relativePath = path.relative(PRINT_JOB_RENDER_STORAGE_ROOT, absolutePath)
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
throw new PrintServiceError('打印任务中的标签附件路径无效', 500)
}
return absolutePath
}
const storeRenderedLabelFiles = async (renderedLabels: AssetLabelRenderedImagePayload[]) => {
const batchKey = `${new Date().toISOString().slice(0, 10).replace(/-/g, '')}-${randomUUID()}`
const batchDirectory = path.join(PRINT_JOB_RENDER_STORAGE_ROOT, batchKey)
await fs.mkdir(batchDirectory, { recursive: true })
const storedFiles: AssetLabelStoredRenderFilePayload[] = []
try {
for (const [index, renderedLabel] of renderedLabels.entries()) {
const fileName = `${String(index + 1).padStart(2, '0')}-${renderedLabel.equipment_id}.png`
const absolutePath = path.join(batchDirectory, fileName)
const storageKey = path.posix.join(batchKey, fileName)
const imageBuffer = decodePngDataUrl(renderedLabel.image_data_url)
await fs.writeFile(absolutePath, imageBuffer)
storedFiles.push({
equipment_id: renderedLabel.equipment_id,
storage_key: storageKey,
content_type: 'image/png'
})
}
return {
batchDirectory,
storedFiles
}
} catch (error) {
await fs.rm(batchDirectory, { recursive: true, force: true })
throw error
}
}
const removeStoredRenderBatch = async (batchDirectory?: string | null) => {
if (!batchDirectory) {
return
}
await fs.rm(batchDirectory, { recursive: true, force: true })
}
const hydrateStoredRenderedLabelFiles = async (
storedFiles: AssetLabelStoredRenderFilePayload[]
): Promise<AssetLabelRenderedImagePayload[]> => {
return Promise.all(
storedFiles.map(async (storedFile) => {
const absolutePath = resolveStoredRenderAbsolutePath(storedFile.storage_key)
const fileBuffer = await fs.readFile(absolutePath)
return {
equipment_id: storedFile.equipment_id,
image_data_url: `${PNG_DATA_URL_PREFIX}${fileBuffer.toString('base64')}`
}
})
)
}
const collectStoredRenderBatchDirectories = (payload: AssetLabelJobPayload | null) => {
if (!payload?.rendered_label_files?.length) {
return []
}
return Array.from(
new Set(
payload.rendered_label_files.map((storedFile) =>
path.dirname(resolveStoredRenderAbsolutePath(storedFile.storage_key))
)
)
)
}
const removeStoredRenderFilesForPayload = async (payload: AssetLabelJobPayload | null) => {
const batchDirectories = collectStoredRenderBatchDirectories(payload)
await Promise.all(batchDirectories.map((batchDirectory) => removeStoredRenderBatch(batchDirectory)))
}
const resolvePrintJobPayloadForResponse = async (
payload: AssetLabelJobPayload | null,
options: {
includeRenderedLabels?: boolean
} = {}
) => {
if (!payload) {
return null
}
const sanitizedPayload = stripRenderedLabelData(payload)
if (!options.includeRenderedLabels) {
return sanitizedPayload
}
if (payload.rendered_labels?.length) {
return {
...sanitizedPayload,
rendered_labels: payload.rendered_labels
}
}
if (!payload.rendered_label_files?.length) {
return sanitizedPayload
}
return {
...sanitizedPayload,
rendered_labels: await hydrateStoredRenderedLabelFiles(payload.rendered_label_files)
}
}
export const resolveEffectiveTerminalStatus = ( export const resolveEffectiveTerminalStatus = (
terminal: Pick<PrintTerminalReadinessInput, 'status' | 'last_seen_at'>, terminal: Pick<PrintTerminalReadinessInput, 'status' | 'last_seen_at'>,
now: Date = new Date() now: Date = new Date()
@ -382,6 +607,13 @@ export const resolveEffectiveTerminalStatus = (
return PRINT_TERMINAL_STATUS.online return PRINT_TERMINAL_STATUS.online
} }
export const getPrintTerminalAdminStatusChangeError = (
_terminal: Pick<PrintTerminal, 'is_default'>,
_nextStatus: 'offline' | 'disabled'
) => {
return null
}
const getEffectiveTerminalStatus = (status: string, lastSeenAt: Date | null) => { const getEffectiveTerminalStatus = (status: string, lastSeenAt: Date | null) => {
if (status === PRINT_TERMINAL_STATUS.disabled) { if (status === PRINT_TERMINAL_STATUS.disabled) {
return PRINT_TERMINAL_STATUS.disabled return PRINT_TERMINAL_STATUS.disabled
@ -496,8 +728,14 @@ const buildPrintJobSummary = (payload: AssetLabelJobPayload | null) => {
} }
} }
const mapPrintJobRecord = (job: PrintJobListRecord) => { const mapPrintJobRecord = async (
job: PrintJobListRecord,
options: {
includeRenderedLabels?: boolean
} = {}
) => {
const payload = parseAssetLabelJobPayload(job.payload_json) const payload = parseAssetLabelJobPayload(job.payload_json)
const responsePayload = await resolvePrintJobPayloadForResponse(payload, options)
const targetParsed = parseTerminalData(job.targetTerminal) const targetParsed = parseTerminalData(job.targetTerminal)
return { return {
@ -511,7 +749,7 @@ const mapPrintJobRecord = (job: PrintJobListRecord) => {
completed_at: job.completed_at, completed_at: job.completed_at,
created_at: job.created_at, created_at: job.created_at,
updated_at: job.updated_at, updated_at: job.updated_at,
payload, payload: responsePayload,
summary: buildPrintJobSummary(payload), summary: buildPrintJobSummary(payload),
target_terminal: { target_terminal: {
id: job.targetTerminal.id, id: job.targetTerminal.id,
@ -778,8 +1016,9 @@ export const updatePrintTerminalAdminStatus = async (
throw new PrintServiceError('打印终端不存在', 404) throw new PrintServiceError('打印终端不存在', 404)
} }
if (nextStatus === PRINT_TERMINAL_STATUS.disabled && existing.is_default) { const statusChangeError = getPrintTerminalAdminStatusChangeError(existing, nextStatus)
throw new PrintServiceError('默认打印电脑不能直接停用,请先切换默认终端', 422) if (statusChangeError) {
throw new PrintServiceError(statusChangeError, 422)
} }
const updated = await prisma.printTerminal.update({ const updated = await prisma.printTerminal.update({
@ -796,53 +1035,75 @@ export const updatePrintTerminalAdminStatus = async (
}) })
} }
export const createAssetLabelPrintJob = async (equipmentIdsInput: unknown, requestedBy: number) => { export const createAssetLabelPrintJob = async (input: unknown, requestedBy: number) => {
const equipmentIds = sanitizeEquipmentIds(equipmentIdsInput) if (!input || typeof input !== 'object' || Array.isArray(input)) {
const [targetTerminal, payload] = await Promise.all([ throw new PrintServiceError('当前页面不是最新版本,请刷新后重试', 422)
}
const equipmentIds = sanitizeEquipmentIds((input as { equipment_ids?: unknown }).equipment_ids)
const renderedLabels = sanitizeRenderedLabelImages(
(input as { rendered_labels?: unknown }).rendered_labels,
equipmentIds
)
const [targetTerminal, basePayload] = await Promise.all([
getDefaultTargetTerminal(), getDefaultTargetTerminal(),
buildAssetLabelPayload(equipmentIds) buildAssetLabelPayload(equipmentIds)
]) ])
const job = await prisma.printJob.create({ const storedRenderBatch = await storeRenderedLabelFiles(renderedLabels)
data: {
job_type: PRINT_JOB_TYPE.assetLabel, let job: PrintJobListRecord
status: PRINT_JOB_STATUS.queued,
target_terminal_id: targetTerminal.id, try {
requested_by: requestedBy, const payload: AssetLabelJobPayload = {
payload_json: stringifyJson(payload) ...basePayload,
}, rendered_label_files: storedRenderBatch.storedFiles
include: { }
targetTerminal: {
select: { job = await prisma.printJob.create({
id: true, data: {
name: true, job_type: PRINT_JOB_TYPE.assetLabel,
status: true, status: PRINT_JOB_STATUS.queued,
selected_printer_name: true, target_terminal_id: targetTerminal.id,
last_seen_at: true, requested_by: requestedBy,
label_profile_json: true, payload_json: stringifyJson(payload)
printer_snapshot_json: true
}
}, },
claimedByTerminal: { include: {
select: { targetTerminal: {
id: true, select: {
name: true id: true,
} name: true,
}, status: true,
requester: { selected_printer_name: true,
select: { last_seen_at: true,
id: true, label_profile_json: true,
username: true, printer_snapshot_json: true
name: true }
},
claimedByTerminal: {
select: {
id: true,
name: true
}
},
requester: {
select: {
id: true,
username: true,
name: true
}
} }
} }
} })
}) } catch (error) {
await removeStoredRenderBatch(storedRenderBatch.batchDirectory)
throw error
}
const parsedTarget = parseTerminalData(targetTerminal) const parsedTarget = parseTerminalData(targetTerminal)
return { return {
job: mapPrintJobRecord(job), job: await mapPrintJobRecord(job),
queue_notice: queue_notice:
parsedTarget.readiness.reason === 'ready' parsedTarget.readiness.reason === 'ready'
? '打印任务已入队,等待默认打印电脑领取' ? '打印任务已入队,等待默认打印电脑领取'
@ -909,7 +1170,7 @@ export const listPrintJobs = async (
]) ])
return { return {
list: jobs.map((job) => mapPrintJobRecord(job)), list: await Promise.all(jobs.map((job) => mapPrintJobRecord(job))),
pagination: { pagination: {
page: safePage, page: safePage,
pageSize: safePageSize, pageSize: safePageSize,
@ -938,6 +1199,8 @@ export const cancelQueuedPrintJob = async (jobId: number) => {
status: PRINT_JOB_STATUS.cancelled status: PRINT_JOB_STATUS.cancelled
} }
}) })
await removeStoredRenderFilesForPayload(parseAssetLabelJobPayload(existing.payload_json))
} }
export const pairPrintTerminalAgent = async (input: PairPrintTerminalInput) => { export const pairPrintTerminalAgent = async (input: PairPrintTerminalInput) => {
@ -1191,10 +1454,35 @@ export const claimNextPrintJob = async (terminalId: number) => {
} }
} }
return { try {
terminal: terminalView, return {
job: mapPrintJobRecord(claimedJob), terminal: terminalView,
message: '领取成功' job: await mapPrintJobRecord(claimedJob, {
includeRenderedLabels: true
}),
message: '领取成功'
}
} catch (error) {
const errorMessage =
error instanceof Error && error.message
? `任务成品标签图读取失败,请在系统里重新发送打印:${error.message}`
: '任务成品标签图读取失败,请在系统里重新发送打印'
await prisma.printJob.update({
where: {
id: claimedJob.id
},
data: {
status: PRINT_JOB_STATUS.failed,
error_message: errorMessage
}
})
return {
terminal: terminalView,
job: null,
message: errorMessage
}
} }
} }
@ -1314,8 +1602,12 @@ export const updateClaimedPrintJobStatus = async (
} }
}) })
if (updated.status === PRINT_JOB_STATUS.completed || updated.status === PRINT_JOB_STATUS.failed) {
await removeStoredRenderFilesForPayload(parseAssetLabelJobPayload(updated.payload_json))
}
return { return {
job: mapPrintJobRecord(updated), job: await mapPrintJobRecord(updated),
message message
} }
} }

View File

@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
getBorrowAnomalyMeta,
isBorrowRecordMissingState
} from '../borrowAnomaly'
test('已借出但没有未归还借用单的单件独立资产可进入异常借出处理', () => {
assert.deepEqual(
getBorrowAnomalyMeta({
status: '已借出',
activeBorrowCount: 0,
totalQuantity: 1,
parentId: null
}),
{
state: 'borrow_record_missing',
canHandle: true,
message: '资产状态为已借出,但系统中没有对应借用记录',
blockedReason: null
}
)
})
test('存在未归还借用单时按正常可归还资产处理', () => {
assert.deepEqual(
getBorrowAnomalyMeta({
status: '已借出',
activeBorrowCount: 1,
totalQuantity: 1,
parentId: null
}),
{
state: 'returnable',
canHandle: false,
message: '资产存在未归还借用记录,可按正常归还处理',
blockedReason: null
}
)
})
test('已挂载子资产不允许直接做异常借出处理', () => {
assert.deepEqual(
getBorrowAnomalyMeta({
status: '已借出',
activeBorrowCount: 0,
totalQuantity: 1,
parentId: 88
}),
{
state: 'borrow_record_missing',
canHandle: false,
message: '资产状态为已借出,但系统中没有对应借用记录',
blockedReason: '该资产当前作为已挂载子资产存在,请先处理所属父资产'
}
)
})
test('多数量资产先拦截人工核对,不开放异常借出处理', () => {
assert.deepEqual(
getBorrowAnomalyMeta({
status: '已借出',
activeBorrowCount: 0,
totalQuantity: 3,
parentId: null
}),
{
state: 'borrow_record_missing',
canHandle: false,
message: '资产状态为已借出,但系统中没有对应借用记录',
blockedReason: '多数量资产暂不支持异常借出处理,请先人工核对'
}
)
})
test('辅助判断函数能识别借用单缺失状态', () => {
assert.equal(isBorrowRecordMissingState('borrow_record_missing'), true)
assert.equal(isBorrowRecordMissingState('returnable'), false)
assert.equal(isBorrowRecordMissingState('none'), false)
})

View File

@ -0,0 +1,71 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
normalizeReturnDepartmentInput,
resolveBorrowDepartmentUpdate
} from '../borrowDepartment'
test('归还时保留当前归属不生成部门更新', () => {
assert.deepEqual(
normalizeReturnDepartmentInput({
department_action: 'keep'
}),
{
departmentAction: 'keep',
departmentId: null,
equipmentUpdateData: null
}
)
})
test('归还时清空归属会生成 department_id = null', () => {
assert.deepEqual(
normalizeReturnDepartmentInput({
department_action: 'clear'
}),
{
departmentAction: 'clear',
departmentId: null,
equipmentUpdateData: {
department_id: null
}
}
)
})
test('归还时指定部门会生成 department_id 更新', () => {
assert.deepEqual(
normalizeReturnDepartmentInput({
department_action: 'set',
department_id: 18
}),
{
departmentAction: 'set',
departmentId: 18,
equipmentUpdateData: {
department_id: 18
}
}
)
})
test('归还时指定部门但未传 department_id 会抛错', () => {
assert.throws(
() =>
normalizeReturnDepartmentInput({
department_action: 'set'
}),
/请选择要改成的业务部门/
)
})
test('出库时使用者有主部门就同步资产归属', () => {
assert.deepEqual(resolveBorrowDepartmentUpdate(23), {
department_id: 23
})
})
test('出库时使用者没有主部门则保持资产原归属', () => {
assert.equal(resolveBorrowDepartmentUpdate(null), null)
assert.equal(resolveBorrowDepartmentUpdate(undefined), null)
})

View File

@ -0,0 +1,93 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { getEquipmentDeleteBlockedReason } from '../equipmentDeletion'
test('允许删除完全未进入任何业务流程的资产', () => {
assert.equal(
getEquipmentDeleteBlockedReason({
borrowRecordCount: 0,
isMountedChild: false,
childCount: 0,
mountHistoryCount: 0,
disposalRecordCount: 0,
maintenanceRecordCount: 0,
inventoryTaskItemCount: 0
}),
null
)
})
test('只要存在任何借用记录就禁止删除', () => {
assert.equal(
getEquipmentDeleteBlockedReason({
borrowRecordCount: 2,
isMountedChild: false,
childCount: 0,
mountHistoryCount: 0,
disposalRecordCount: 0,
maintenanceRecordCount: 0,
inventoryTaskItemCount: 0
}),
'该资产已存在借用记录,无法删除'
)
})
test('挂载中的子资产不允许删除', () => {
assert.equal(
getEquipmentDeleteBlockedReason({
borrowRecordCount: 0,
isMountedChild: true,
childCount: 0,
mountHistoryCount: 0,
disposalRecordCount: 0,
maintenanceRecordCount: 0,
inventoryTaskItemCount: 0
}),
'该资产当前作为子资产挂载中,无法删除'
)
})
test('存在子资产时不允许删除父资产', () => {
assert.equal(
getEquipmentDeleteBlockedReason({
borrowRecordCount: 0,
isMountedChild: false,
childCount: 3,
mountHistoryCount: 0,
disposalRecordCount: 0,
maintenanceRecordCount: 0,
inventoryTaskItemCount: 0
}),
'该资产存在已挂载的子资产,无法删除'
)
})
test('存在挂载、维修或报废历史时不允许删除', () => {
assert.equal(
getEquipmentDeleteBlockedReason({
borrowRecordCount: 0,
isMountedChild: false,
childCount: 0,
mountHistoryCount: 1,
disposalRecordCount: 0,
maintenanceRecordCount: 0,
inventoryTaskItemCount: 0
}),
'该资产已存在维修、报废或挂载历史,无法删除'
)
})
test('存在盘点任务项时不允许删除', () => {
assert.equal(
getEquipmentDeleteBlockedReason({
borrowRecordCount: 0,
isMountedChild: false,
childCount: 0,
mountHistoryCount: 0,
disposalRecordCount: 0,
maintenanceRecordCount: 0,
inventoryTaskItemCount: 1
}),
'该资产已存在盘点记录,无法删除'
)
})

View File

@ -0,0 +1,88 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
normalizeInventoryScopeInput,
resolveUniqueActiveBorrowRecord
} from '../inventoryScope'
test('盘点范围会把旧单选字段归一成数组', () => {
assert.deepEqual(
normalizeInventoryScopeInput({
department_id: 3,
asset_type_id: '9',
storage_location_ids: []
}),
{
departmentIds: [3],
assetTypeIds: [9],
storageLocationIds: []
}
)
})
test('盘点范围会合并新旧字段、去重并过滤非法值', () => {
assert.deepEqual(
normalizeInventoryScopeInput({
department_id: 3,
department_ids: [3, '8', 0, 'abc', 8],
asset_type_ids: ['5', 5, 12, null],
storage_location_ids: [2, '7', -1, undefined, 2]
}),
{
departmentIds: [3, 8],
assetTypeIds: [5, 12],
storageLocationIds: [2, 7]
}
)
})
test('只有唯一且人员仍为 active 的未归还记录才算当前使用人', () => {
const record = resolveUniqueActiveBorrowRecord([
{
id: 11,
person: {
id: 2,
status: 'active'
}
}
])
assert.equal(record?.id, 11)
})
test('多条未归还记录时不纳入盘点参与人', () => {
assert.equal(
resolveUniqueActiveBorrowRecord([
{
id: 11,
person: {
id: 2,
status: 'active'
}
},
{
id: 12,
person: {
id: 3,
status: 'active'
}
}
]),
null
)
})
test('人员已离职时不纳入盘点参与人', () => {
assert.equal(
resolveUniqueActiveBorrowRecord([
{
id: 11,
person: {
id: 2,
status: 'inactive'
}
}
]),
null
)
})

View File

@ -0,0 +1,11 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { getInventoryTaskDeleteBlockedReason } from '../inventoryTaskLifecycle'
test('进行中的盘点任务允许删除', () => {
assert.equal(getInventoryTaskDeleteBlockedReason('active'), null)
})
test('已结束的盘点任务也允许删除', () => {
assert.equal(getInventoryTaskDeleteBlockedReason('closed'), null)
})

View File

@ -0,0 +1,57 @@
export type BorrowAnomalyState = 'none' | 'returnable' | 'borrow_record_missing'
export interface BorrowAnomalyMeta {
state: BorrowAnomalyState
canHandle: boolean
message: string | null
blockedReason: string | null
}
export const isBorrowRecordMissingState = (state?: string | null) => state === 'borrow_record_missing'
export const getBorrowAnomalyMeta = ({
status,
activeBorrowCount,
totalQuantity,
parentId
}: {
status?: string | null
activeBorrowCount?: number | null
totalQuantity?: number | null
parentId?: number | null
}): BorrowAnomalyMeta => {
const normalizedActiveBorrowCount = Math.max(0, Number(activeBorrowCount) || 0)
const normalizedTotalQuantity = Math.max(0, Number(totalQuantity) || 0)
if (normalizedActiveBorrowCount > 0) {
return {
state: 'returnable',
canHandle: false,
message: '资产存在未归还借用记录,可按正常归还处理',
blockedReason: null
}
}
if (status !== '已借出') {
return {
state: 'none',
canHandle: false,
message: null,
blockedReason: null
}
}
let blockedReason: string | null = null
if (parentId) {
blockedReason = '该资产当前作为已挂载子资产存在,请先处理所属父资产'
} else if (normalizedTotalQuantity !== 1) {
blockedReason = '多数量资产暂不支持异常借出处理,请先人工核对'
}
return {
state: 'borrow_record_missing',
canHandle: blockedReason === null,
message: '资产状态为已借出,但系统中没有对应借用记录',
blockedReason
}
}

View File

@ -0,0 +1,82 @@
export type ReturnDepartmentAction = 'keep' | 'clear' | 'set'
export interface NormalizeReturnDepartmentInputParams {
department_action?: unknown
department_id?: unknown
}
export interface NormalizedReturnDepartmentInput {
departmentAction: ReturnDepartmentAction
departmentId: number | null
equipmentUpdateData: {
department_id: number | null
} | null
}
const toOptionalPositiveInteger = (value: unknown) => {
if (value === undefined || value === null || value === '') {
return null
}
const numericValue = Number(value)
if (!Number.isInteger(numericValue) || numericValue <= 0) {
return null
}
return numericValue
}
export const normalizeReturnDepartmentInput = (
input: NormalizeReturnDepartmentInputParams
): NormalizedReturnDepartmentInput => {
const rawAction = typeof input.department_action === 'string'
? input.department_action.trim()
: ''
const departmentAction: ReturnDepartmentAction =
rawAction === 'clear' || rawAction === 'set'
? rawAction
: 'keep'
if (departmentAction === 'keep') {
return {
departmentAction,
departmentId: null,
equipmentUpdateData: null
}
}
if (departmentAction === 'clear') {
return {
departmentAction,
departmentId: null,
equipmentUpdateData: {
department_id: null
}
}
}
const departmentId = toOptionalPositiveInteger(input.department_id)
if (!departmentId) {
throw new Error('请选择要改成的业务部门')
}
return {
departmentAction,
departmentId,
equipmentUpdateData: {
department_id: departmentId
}
}
}
export const resolveBorrowDepartmentUpdate = (mainDepartmentId?: number | null) => {
const departmentId = toOptionalPositiveInteger(mainDepartmentId)
if (!departmentId) {
return null
}
return {
department_id: departmentId
}
}

View File

@ -0,0 +1,41 @@
export interface EquipmentDeleteCheckInput {
borrowRecordCount: number
isMountedChild: boolean
childCount: number
mountHistoryCount: number
disposalRecordCount: number
maintenanceRecordCount: number
inventoryTaskItemCount: number
}
export const getEquipmentDeleteBlockedReason = ({
borrowRecordCount,
isMountedChild,
childCount,
mountHistoryCount,
disposalRecordCount,
maintenanceRecordCount,
inventoryTaskItemCount
}: EquipmentDeleteCheckInput) => {
if (borrowRecordCount > 0) {
return '该资产已存在借用记录,无法删除'
}
if (isMountedChild) {
return '该资产当前作为子资产挂载中,无法删除'
}
if (childCount > 0) {
return '该资产存在已挂载的子资产,无法删除'
}
if (mountHistoryCount > 0 || disposalRecordCount > 0 || maintenanceRecordCount > 0) {
return '该资产已存在维修、报废或挂载历史,无法删除'
}
if (inventoryTaskItemCount > 0) {
return '该资产已存在盘点记录,无法删除'
}
return null
}

View File

@ -0,0 +1,70 @@
export interface NormalizeInventoryScopeInputParams {
department_id?: unknown
department_ids?: unknown
departmentIds?: unknown
asset_type_id?: unknown
asset_type_ids?: unknown
assetTypeIds?: unknown
storage_location_id?: unknown
storage_location_ids?: unknown
storageLocationIds?: unknown
}
export interface NormalizedInventoryScopeInput {
departmentIds: number[]
assetTypeIds: number[]
storageLocationIds: number[]
}
const toNormalizedScopeIds = (values: unknown[]) =>
Array.from(
new Set(
values
.map((value) => Number(value))
.filter((value) => Number.isInteger(value) && value > 0)
)
)
const normalizeScopeIdArray = (primary: unknown, fallback?: unknown) => {
const mergedValues: unknown[] = []
if (Array.isArray(primary)) {
mergedValues.push(...primary)
} else if (primary !== undefined) {
mergedValues.push(primary)
}
if (Array.isArray(fallback)) {
mergedValues.push(...fallback)
} else if (fallback !== undefined) {
mergedValues.push(fallback)
}
return toNormalizedScopeIds(mergedValues)
}
export const normalizeInventoryScopeInput = (
input: NormalizeInventoryScopeInputParams
): NormalizedInventoryScopeInput => ({
departmentIds: normalizeScopeIdArray(input.departmentIds ?? input.department_ids, input.department_id),
assetTypeIds: normalizeScopeIdArray(input.assetTypeIds ?? input.asset_type_ids, input.asset_type_id),
storageLocationIds: normalizeScopeIdArray(
input.storageLocationIds ?? input.storage_location_ids,
input.storage_location_id
)
})
export const resolveUniqueActiveBorrowRecord = <T extends { person?: { status?: string | null } | null }>(
records: T[]
) => {
if (records.length !== 1) {
return null
}
const [record] = records
if (!record?.person || record.person.status !== 'active') {
return null
}
return record
}

View File

@ -0,0 +1,3 @@
export const getInventoryTaskDeleteBlockedReason = (status?: string | null) => {
return null
}

View File

@ -47,7 +47,7 @@ services:
HR_SYNC_SCHEDULE: ${HR_SYNC_SCHEDULE:-03:00} HR_SYNC_SCHEDULE: ${HR_SYNC_SCHEDULE:-03:00}
HR_SYNC_SCHEDULES: ${HR_SYNC_SCHEDULES:-} HR_SYNC_SCHEDULES: ${HR_SYNC_SCHEDULES:-}
HR_SYNC_ALLOWED_STATUS_VALUES: ${HR_SYNC_ALLOWED_STATUS_VALUES:-3} HR_SYNC_ALLOWED_STATUS_VALUES: ${HR_SYNC_ALLOWED_STATUS_VALUES:-3}
HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-5} HR_SYNC_INACTIVE_STATUS_VALUES: ${HR_SYNC_INACTIVE_STATUS_VALUES:-2,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 DOWNLOADS_DIR: /app/downloads

View File

@ -1,9 +1,9 @@
{ {
"version": "0.2.0", "version": "0.2.5",
"published_at": "2026-03-25T17:45:51+08:00", "published_at": "2026-03-26T21:07:29+08:00",
"filename": "print-agent-setup.exe", "filename": "print-agent-setup.exe",
"notes": "打印助手最新稳定版,当前为 .NET 8 原生 Windows 托盘安装包,支持正式安装、卸载、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。", "notes": "打印助手最新稳定版,当前为安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。",
"source_type": "server_release", "source_type": "server_release",
"relative_path": "downloads/print-agent/latest/print-agent-setup.exe", "relative_path": "downloads/print-agent/latest/print-agent-setup.exe",
"relativePath": "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.2.1",
"published_at": "2026-03-26T15:07:34+08:00",
"filename": "print-agent-setup-0.2.1.exe",
"notes": "打印助手 v0.2.1,已切换为网页成品标签图直出模式,远程打印会直接复用网页版式,带正式安装器、控制面板卸载入口、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。",
"source_type": "server_release",
"relative_path": "downloads/print-agent/releases/0.2.1/print-agent-setup-0.2.1.exe",
"relativePath": "downloads/print-agent/releases/0.2.1/print-agent-setup-0.2.1.exe"
}

View File

@ -0,0 +1,9 @@
{
"version": "0.2.3",
"published_at": "2026-03-26T15:44:48+08:00",
"filename": "print-agent-setup-0.2.3.exe",
"notes": "打印助手 v0.2.3,已切换为真正的小体积中文安装包路线,远程打印继续直出网页成品标签图;首次安装前需先装 .NET 8 Windows Desktop Runtime(x64)。",
"source_type": "server_release",
"relative_path": "downloads/print-agent/releases/0.2.3/print-agent-setup-0.2.3.exe",
"relativePath": "downloads/print-agent/releases/0.2.3/print-agent-setup-0.2.3.exe"
}

View File

@ -0,0 +1,9 @@
{
"version": "0.2.4",
"published_at": "2026-03-26T15:53:45+08:00",
"filename": "print-agent-setup-0.2.4.exe",
"notes": "打印助手 v0.2.4,已切回安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。",
"source_type": "server_release",
"relative_path": "downloads/print-agent/releases/0.2.4/print-agent-setup-0.2.4.exe",
"relativePath": "downloads/print-agent/releases/0.2.4/print-agent-setup-0.2.4.exe"
}

View File

@ -0,0 +1,9 @@
{
"version": "0.2.5",
"published_at": "2026-03-26T21:07:29+08:00",
"filename": "print-agent-setup-0.2.5.exe",
"notes": "打印助手 v0.2.5,已切回安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。",
"source_type": "server_release",
"relative_path": "downloads/print-agent/releases/0.2.5/print-agent-setup-0.2.5.exe",
"relativePath": "downloads/print-agent/releases/0.2.5/print-agent-setup-0.2.5.exe"
}

View File

@ -0,0 +1,14 @@
import assert from 'node:assert/strict'
import { resolveApiBaseUrl, resolveAuthMode } from '../src/utils/authMode.ts'
assert.equal(resolveAuthMode(undefined), 'local', '未配置认证模式时必须默认走本地登录')
assert.equal(resolveAuthMode(''), 'local', '空认证模式不应误判为 Keycloak')
assert.equal(resolveAuthMode('local'), 'local', '显式 local 应保持本地登录')
assert.equal(resolveAuthMode('keycloak'), 'keycloak', '只有显式 keycloak 才允许走统一认证')
assert.equal(resolveAuthMode('KEYCLOAK'), 'local', '错误大小写不应被当作有效 Keycloak 配置')
assert.equal(resolveApiBaseUrl(undefined), '/api', '未配置接口地址时必须默认走同域 /api')
assert.equal(resolveApiBaseUrl(''), '/api', '空接口地址不应退回 localhost')
assert.equal(resolveApiBaseUrl(' /api/v2 '), '/api/v2', '显式接口地址应保留并自动去空格')
console.log('auth mode tests: ok')

View File

@ -0,0 +1,38 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { canHandleBorrowAnomaly } from '../src/utils/borrowAnomaly.ts'
test('支持后端返回的 camelCase canHandle 字段', () => {
const result = canHandleBorrowAnomaly({
borrow_anomaly: {
state: 'borrow_record_missing',
canHandle: true
}
})
assert.equal(result, true)
})
test('兼容旧的 snake_case can_handle 字段', () => {
const result = canHandleBorrowAnomaly({
borrow_anomaly: {
state: 'borrow_record_missing',
can_handle: true
}
})
assert.equal(result, true)
})
test('非借用单缺失状态时不可处理', () => {
const result = canHandleBorrowAnomaly({
borrow_anomaly: {
state: 'none',
canHandle: true,
can_handle: true
}
})
assert.equal(result, false)
})

View File

@ -0,0 +1,78 @@
import assert from 'node:assert/strict'
import {
filterManualBorrowCandidates,
findPreferredManualBorrowCandidate,
isManualBorrowCandidate
} from '../src/views/borrow/manualBorrowSearch.ts'
const sampleList = [
{
equipment_code: 'JS-PC-1001',
equipment_name: '设计部高配主机',
equipment_model: 'RTX4090-WORKSTATION',
equipment_type: '主机',
status: '可用',
remaining_quantity: 1,
parent_id: null,
storageLocation: { name: '设计中心-A区' }
},
{
equipment_code: 'JS-PC-1002',
equipment_name: '普通办公主机',
equipment_model: 'OFFICE-01',
equipment_type: '主机',
status: '可用',
remaining_quantity: 2,
parent_id: null,
storageLocation: { name: '行政办公室' }
},
{
equipment_code: 'JS-MEM-2001',
equipment_name: '内存条',
equipment_model: '32G DDR5',
equipment_type: '内存条',
status: '可用',
remaining_quantity: 1,
parent_id: 88,
storageLocation: { name: '设计中心-A区' }
},
{
equipment_code: 'JS-MON-3001',
equipment_name: '曲面显示器',
equipment_model: '34WQHD',
equipment_type: '显示器',
status: '已借出',
remaining_quantity: 0,
parent_id: null,
storageLocation: { name: '摄影棚' }
}
]
assert.equal(isManualBorrowCandidate(sampleList[0]), true, '独立且可用的资产应允许手动借出搜索')
assert.equal(isManualBorrowCandidate(sampleList[2]), false, '已挂载子资产不应出现在借出搜索中')
assert.equal(isManualBorrowCandidate(sampleList[3]), false, '已借出资产不应出现在借出搜索中')
const byCode = filterManualBorrowCandidates(sampleList, 'JS-PC-1002')
assert.equal(byCode.length, 1, '按准确编码搜索应只返回该资产')
assert.equal(byCode[0].equipment_code, 'JS-PC-1002', '准确编码搜索必须命中目标资产')
const byKeyword = filterManualBorrowCandidates(sampleList, '设计')
assert.equal(byKeyword.length, 1, '按名称关键词搜索应排除不可借资产')
assert.equal(byKeyword[0].equipment_code, 'JS-PC-1001', '名称关键词应返回正确资产')
const byModel = filterManualBorrowCandidates(sampleList, '4090')
assert.equal(byModel[0].equipment_code, 'JS-PC-1001', '型号关键词应支持搜索')
const byLocation = filterManualBorrowCandidates(sampleList, '行政')
assert.equal(byLocation[0].equipment_code, 'JS-PC-1002', '地址关键词应支持辅助定位')
const preferredExact = findPreferredManualBorrowCandidate(byCode, 'js-pc-1002')
assert.equal(preferredExact?.equipment_code, 'JS-PC-1002', '精确编码时应优先自动选中')
const preferredSingle = findPreferredManualBorrowCandidate(byKeyword, '设计')
assert.equal(preferredSingle?.equipment_code, 'JS-PC-1001', '只有一条结果时应允许直接读取')
const multipleResults = filterManualBorrowCandidates(sampleList, '主机')
assert.equal(findPreferredManualBorrowCandidate(multipleResults, '主机'), null, '多条结果时不应擅自自动选中')
console.log('borrow manual search tests: ok')

View File

@ -0,0 +1,103 @@
import assert from 'node:assert/strict'
import {
buildManualBorrowPersonDisplay,
filterManualBorrowPersons,
findPreferredManualBorrowPerson,
isManualBorrowSelectedPersonKeyword,
shouldShowManualBorrowPersonResults
} from '../src/views/borrow/manualBorrowPersonSearch.ts'
const samplePersons = [
{
id: 1,
nickname: '阿杰',
real_name: '王杰',
id_number: 'A1001',
department: '设计中心',
department_names: ['设计中心', '品牌设计部'],
phone: '13800000001',
status: 'active'
},
{
id: 2,
nickname: '小周',
real_name: '周晴',
id_number: 'A1002',
department: '行政部',
department_names: ['行政部'],
phone: '13800000002',
status: 'active'
},
{
id: 3,
nickname: '离职张',
real_name: '张离',
id_number: 'A1003',
department: '设计中心',
department_names: ['设计中心'],
phone: '13800000003',
status: 'inactive'
}
]
const byNickname = filterManualBorrowPersons(samplePersons as any, '阿杰')
assert.equal(byNickname.length, 1, '花名精确匹配应只返回一个人')
assert.equal(byNickname[0].id, 1, '花名匹配应返回正确人员')
const byRealName = filterManualBorrowPersons(samplePersons as any, '周晴')
assert.equal(byRealName.length, 1, '姓名搜索应返回正确人员')
assert.equal(byRealName[0].id, 2, '姓名匹配结果不正确')
const byIdNumber = filterManualBorrowPersons(samplePersons as any, 'A1001')
assert.equal(byIdNumber[0].id, 1, '工号搜索应支持')
const byDepartment = filterManualBorrowPersons(samplePersons as any, '设计')
assert.equal(byDepartment.length, 1, '搜索部门时应排除已停用人员')
assert.equal(byDepartment[0].id, 1, '部门搜索结果不正确')
const byPhone = filterManualBorrowPersons(samplePersons as any, '0002')
assert.equal(byPhone[0].id, 2, '手机号搜索应支持辅助定位')
const preferredExact = findPreferredManualBorrowPerson(byNickname as any, '阿杰')
assert.equal(preferredExact?.id, 1, '精确匹配时应支持直接选中')
const preferredSingle = findPreferredManualBorrowPerson(byDepartment as any, '设计')
assert.equal(preferredSingle?.id, 1, '只有一条结果时应支持直接选中')
const selectedDisplay = buildManualBorrowPersonDisplay(samplePersons[0] as any)
assert.equal(selectedDisplay, '阿杰 王杰', '选中后输入框应回填花名和姓名')
assert.equal(
isManualBorrowSelectedPersonKeyword(samplePersons[0] as any, selectedDisplay),
true,
'选中人员后,回填文案不应被当成新的搜索条件清掉'
)
assert.equal(
shouldShowManualBorrowPersonResults(samplePersons[0] as any, selectedDisplay),
false,
'选中人员后不应继续显示搜索结果框'
)
assert.equal(
shouldShowManualBorrowPersonResults(samplePersons[0] as any, '行政'),
true,
'改了关键词后应继续展示新搜索结果'
)
const multiResults = filterManualBorrowPersons(
[
...samplePersons,
{
id: 4,
nickname: '阿杰2',
real_name: '王杰同名',
id_number: 'A1004',
department: '设计中心',
department_names: ['设计中心'],
phone: '13800000004',
status: 'active'
}
] as any,
'王'
)
assert.equal(findPreferredManualBorrowPerson(multiResults as any, '王'), null, '多条结果时不应自动选中')
console.log('borrow person search tests: ok')

View File

@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import { canSubmitBorrowScan, hasSelectedBorrowPerson } from '../src/views/borrow/borrowScanState.ts'
assert.equal(hasSelectedBorrowPerson(null), false, '未选使用者时不应允许提交')
assert.equal(hasSelectedBorrowPerson(Number.NaN), false, '无效使用者编号不应允许提交')
assert.equal(hasSelectedBorrowPerson(12), true, '选中使用者后应识别为有效选择')
assert.equal(
canSubmitBorrowScan({ hasEquipment: true, selectedPersonId: 12, loading: false }),
true,
'已有资产且已选使用者时,应允许直接点击确认出库'
)
assert.equal(
canSubmitBorrowScan({ hasEquipment: true, selectedPersonId: null, loading: false }),
false,
'未选使用者时不应允许提交'
)
assert.equal(
canSubmitBorrowScan({ hasEquipment: false, selectedPersonId: 12, loading: false }),
false,
'未识别资产时不应允许提交'
)
assert.equal(
canSubmitBorrowScan({ hasEquipment: true, selectedPersonId: 12, loading: true }),
false,
'提交中不应重复点击'
)
console.log('borrow scan state tests: ok')

View File

@ -0,0 +1,76 @@
import assert from 'node:assert/strict'
import type { BorrowRecord, HrSyncBorrowedAssetPreview } from '../src/types.ts'
import {
buildNotificationQuickReturnAssetKey,
findNotificationQuickReturnRecord
} from '../src/views/notifications/notificationQuickReturn.ts'
const assetWithBorrowRecord: HrSyncBorrowedAssetPreview = {
equipmentCode: 'JS-PC-3001',
equipmentName: '主机',
borrowRecordId: 901
}
const assetWithoutBorrowRecord: HrSyncBorrowedAssetPreview = {
equipmentCode: 'JS-MON-2001',
equipmentName: '显示器'
}
const borrowRecords: BorrowRecord[] = [
{
id: 901,
equipment_id: 3001,
equipment_code: 'JS-PC-3001',
person_id: 11,
person_name: '张亮',
person_nickname: '三藏',
borrow_time: '2026-03-26T09:00:00.000Z',
status: '已借出',
created_at: '2026-03-26T09:00:00.000Z',
updated_at: '2026-03-26T09:00:00.000Z'
},
{
id: 902,
equipment_id: 3001,
equipment_code: 'JS-PC-3001',
person_id: 12,
person_name: '李想',
person_nickname: '小李',
borrow_time: '2026-03-26T10:00:00.000Z',
status: '已借出',
created_at: '2026-03-26T10:00:00.000Z',
updated_at: '2026-03-26T10:00:00.000Z'
}
]
assert.equal(
buildNotificationQuickReturnAssetKey(assetWithBorrowRecord),
'borrow-record:901',
'带借用记录编号的资产应优先用借用记录生成稳定 key'
)
assert.equal(
buildNotificationQuickReturnAssetKey(assetWithoutBorrowRecord),
'equipment:JS-MON-2001',
'没有借用记录编号时应回退到资产编码'
)
assert.equal(
findNotificationQuickReturnRecord(borrowRecords, assetWithBorrowRecord)?.id,
901,
'带借用记录编号时应优先命中对应借用记录'
)
assert.equal(
findNotificationQuickReturnRecord(borrowRecords, assetWithoutBorrowRecord),
null,
'存在多条借用记录且没有指定借用记录编号时,不应擅自自动选中'
)
assert.equal(
findNotificationQuickReturnRecord([borrowRecords[0]], assetWithoutBorrowRecord)?.id,
901,
'只有一条借用记录时,应允许自动选中'
)
console.log('notification quick return tests: ok')

View File

@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import {
filterManualReturnAssetCandidates,
findPreferredManualReturnAssetCandidate,
isManualReturnAssetCandidate
} from '../src/views/borrow/manualReturnSearch.ts'
const sampleList = [
{
equipment_id: 11,
equipment_code: 'JS-PC-1883',
equipment_name: '品牌显示器',
equipment_model: 'U2723QE',
equipment_type: '显示器',
active_borrow_count: 1,
borrower_names: ['凤求凰 · 王静宁'],
borrower_identifiers: ['513701199912094029'],
borrower_departments: ['品牌策划中心'],
search_state: 'returnable',
can_return: true,
storageLocation: { name: '常州-天安' }
},
{
equipment_id: 12,
equipment_code: 'JS-PC-2094',
equipment_name: '常州办公显示器',
equipment_model: 'AOC-34',
equipment_type: '显示器',
active_borrow_count: 0,
search_state: 'borrow_record_missing',
can_return: false,
storageLocation: { name: '常州-天安' }
},
{
equipment_id: 13,
equipment_code: 'JS-MEM-2001',
equipment_name: '内存条',
equipment_model: '32G DDR5',
equipment_type: '内存条',
active_borrow_count: 0,
storageLocation: { name: '仓库' }
}
]
assert.equal(isManualReturnAssetCandidate(sampleList[0]), true, '存在未归还记录的资产应允许出现在归还搜索中')
assert.equal(isManualReturnAssetCandidate(sampleList[1]), true, '借用单缺失但状态仍为已借出的资产也应允许被搜到')
assert.equal(isManualReturnAssetCandidate(sampleList[2]), false, '没有未归还记录的资产不应出现在归还搜索中')
const byCode = filterManualReturnAssetCandidates(sampleList, 'JS-PC-1883')
assert.equal(byCode.length, 1, '按准确编码搜索应只返回对应资产')
assert.equal(byCode[0].equipment_id, 11, '准确编码搜索结果不正确')
const byName = filterManualReturnAssetCandidates(sampleList, '显示器')
assert.equal(byName.length, 2, '按名称搜索应命中对应的借出资产')
const byModel = filterManualReturnAssetCandidates(sampleList, 'AOC')
assert.equal(byModel[0].equipment_id, 12, '型号搜索应支持辅助定位')
const byLocation = filterManualReturnAssetCandidates(sampleList, '天安')
assert.equal(byLocation.length, 2, '地址搜索应支持辅助定位')
const byCodeSuffix = filterManualReturnAssetCandidates(sampleList, '2094')
assert.equal(byCodeSuffix.length, 1, '按资产编码片段搜索时,不应被借用人身份证号误导')
assert.equal(byCodeSuffix[0].equipment_code, 'JS-PC-2094', '资产编码片段搜索应优先命中实际资产')
assert.equal(byCodeSuffix[0].search_state, 'borrow_record_missing', '借用单缺失资产应保留异常标记')
const preferredExact = findPreferredManualReturnAssetCandidate(byCode, 'js-pc-1883')
assert.equal(preferredExact?.equipment_id, 11, '准确编码时应优先自动选中')
const preferredSingle = findPreferredManualReturnAssetCandidate(byCodeSuffix, 'js-pc-2094')
assert.equal(preferredSingle?.equipment_id, 12, '只有一条结果时应允许直接选中')
const multiResults = filterManualReturnAssetCandidates(sampleList, 'JS-')
assert.equal(
findPreferredManualReturnAssetCandidate(multiResults, 'JS-'),
null,
'多条结果时不应擅自自动选中'
)
console.log('return manual asset search tests: ok')

View File

@ -0,0 +1,86 @@
import assert from 'node:assert/strict'
import {
buildManualReturnRecordDisplay,
filterManualReturnRecordCandidates,
findPreferredManualReturnRecordCandidate,
isManualReturnSelectedRecordKeyword,
shouldShowManualReturnRecordResults
} from '../src/views/borrow/manualReturnRecordSearch.ts'
const sampleRecords = [
{
id: 101,
person_id: 1,
person_name: '王杰',
person_nickname: '阿杰',
person_identifier: 'A1001',
borrow_time: '2026-03-25T09:30:00.000Z',
status: '已借出',
person: {
id: 1,
nickname: '阿杰',
real_name: '王杰',
department: '设计中心',
department_names: ['设计中心', '品牌设计部'],
phone: '13800000001',
status: 'active'
}
},
{
id: 102,
person_id: 2,
person_name: '周晴',
person_nickname: '小周',
person_identifier: 'A1002',
borrow_time: '2026-03-25T11:00:00.000Z',
status: '已借出',
person: {
id: 2,
nickname: '小周',
real_name: '周晴',
department: '行政部',
department_names: ['行政部'],
phone: '13800000002',
status: 'active'
}
}
]
const byNickname = filterManualReturnRecordCandidates(sampleRecords as any, '阿杰')
assert.equal(byNickname.length, 1, '花名搜索应返回正确的借用记录')
assert.equal(byNickname[0].id, 101, '花名搜索结果不正确')
const byRealName = filterManualReturnRecordCandidates(sampleRecords as any, '周晴')
assert.equal(byRealName[0].id, 102, '姓名搜索应支持')
const byIdentifier = filterManualReturnRecordCandidates(sampleRecords as any, 'A1001')
assert.equal(byIdentifier[0].id, 101, '工号搜索应支持')
const byDepartment = filterManualReturnRecordCandidates(sampleRecords as any, '设计')
assert.equal(byDepartment[0].id, 101, '部门搜索应支持')
const preferredExact = findPreferredManualReturnRecordCandidate(byNickname as any, '阿杰')
assert.equal(preferredExact?.id, 101, '精确匹配时应优先自动选中')
const preferredSingle = findPreferredManualReturnRecordCandidate(byDepartment as any, '设计')
assert.equal(preferredSingle?.id, 101, '只有一条结果时应支持直接选中')
const display = buildManualReturnRecordDisplay(sampleRecords[0] as any)
assert.equal(display, '阿杰 王杰', '选中后输入框应回填花名和姓名')
assert.equal(
isManualReturnSelectedRecordKeyword(sampleRecords[0] as any, display),
true,
'回填后的文案不应被误判成新的搜索词'
)
assert.equal(
shouldShowManualReturnRecordResults(sampleRecords[0] as any, display),
false,
'选中记录后不应继续展示搜索结果框'
)
assert.equal(
shouldShowManualReturnRecordResults(sampleRecords[0] as any, '行政'),
true,
'改了关键词后应继续展示新搜索结果'
)
console.log('return manual record search tests: ok')

View File

@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import { canSubmitReturnScan, hasSelectedReturnRecord } from '../src/views/borrow/returnScanState.ts'
assert.equal(hasSelectedReturnRecord(null), false, '未选归还记录时不应允许提交')
assert.equal(hasSelectedReturnRecord(Number.NaN), false, '无效归还记录编号不应允许提交')
assert.equal(hasSelectedReturnRecord(101), true, '选中归还记录后应识别为有效选择')
assert.equal(
canSubmitReturnScan({ hasBorrowRecordInfo: true, selectedBorrowRecordId: 101, loading: false }),
true,
'已读取资产且已选归还记录时,应允许确认入库'
)
assert.equal(
canSubmitReturnScan({ hasBorrowRecordInfo: true, selectedBorrowRecordId: null, loading: false }),
false,
'未选归还记录时不应允许提交'
)
assert.equal(
canSubmitReturnScan({ hasBorrowRecordInfo: false, selectedBorrowRecordId: 101, loading: false }),
false,
'未读取资产时不应允许提交'
)
assert.equal(
canSubmitReturnScan({ hasBorrowRecordInfo: true, selectedBorrowRecordId: 101, loading: true }),
false,
'提交中不应重复点击'
)
console.log('return scan state tests: ok')

View File

@ -7,6 +7,13 @@ import type {
StorageLocation StorageLocation
} from '@/types' } from '@/types'
export type BorrowDepartmentAction = 'keep' | 'clear' | 'set'
export interface ReturnDepartmentPayload {
department_action?: BorrowDepartmentAction
department_id?: number | null
}
export interface ScanBorrowResult { export interface ScanBorrowResult {
id: number id: number
equipment_code: string equipment_code: string
@ -19,6 +26,25 @@ export interface ScanBorrowResult {
storageLocation?: StorageLocation | null storageLocation?: StorageLocation | null
} }
export interface ReturnSearchAssetCandidate {
equipment_id: number
equipment_code: string
equipment_name: string
equipment_model?: string | null
equipment_type?: string | null
storage_location_id?: number | null
storageLocation?: StorageLocation | null
active_borrow_count: number
borrower_names?: string[]
borrower_identifiers?: string[]
borrower_departments?: string[]
can_return?: boolean
can_handle_borrow_anomaly?: boolean
borrow_anomaly_blocked_reason?: string | null
search_state?: 'returnable' | 'borrow_record_missing'
state_message?: string
}
// 借用设备 // 借用设备
export const borrowEquipment = async (data: { export const borrowEquipment = async (data: {
equipment_id?: number equipment_id?: number
@ -73,7 +99,7 @@ export const returnEquipment = async (data: {
return_condition?: string return_condition?: string
notes?: string notes?: string
confirm_children_diff?: boolean confirm_children_diff?: boolean
}) => { } & ReturnDepartmentPayload) => {
const response = await api.post<ApiResponse<BorrowRecord>>('/return', data) const response = await api.post<ApiResponse<BorrowRecord>>('/return', data)
return response.data return response.data
} }
@ -86,7 +112,7 @@ export const scanReturn = async (data: {
person_id?: number person_id?: number
borrow_record_id?: number borrow_record_id?: number
confirm_children_diff?: boolean confirm_children_diff?: boolean
}) => { } & ReturnDepartmentPayload) => {
const response = await api.post< const response = await api.post<
ApiResponse<{ ApiResponse<{
id: number id: number
@ -139,6 +165,44 @@ export const getBorrowRecordByCode = async (equipmentCode: string) => {
return response.data return response.data
} }
export const searchReturnAssets = async (params: { keyword?: string }) => {
const response = await api.get<ApiResponse<ReturnSearchAssetCandidate[]>>('/return/search-assets', {
params
})
return response.data
}
export const resolveBorrowAnomalyAsReturned = async (equipmentId: number) => {
const response = await api.post<ApiResponse<{
equipment_id: number
equipment_code: string
equipment_name: string
status: string
remaining_quantity: number
}>>(`/borrow/anomalies/${equipmentId}/mark-returned`)
return response.data
}
export const registerBorrowAnomaly = async (
equipmentId: number,
data: {
person_id: number
borrow_time: string
notes?: string
}
) => {
const response = await api.post<ApiResponse<{
id: number
equipment_id: number
equipment_code: string
equipment_name: string
borrow_time: string
status: string
children_count: number
}>>(`/borrow/anomalies/${equipmentId}/register-borrow`, data)
return response.data
}
// 获取借用记录列表 // 获取借用记录列表
export const getBorrowRecordList = async (params: { export const getBorrowRecordList = async (params: {
page?: number page?: number

View File

@ -1,10 +1,11 @@
import axios from 'axios' import axios from 'axios'
import type { AxiosInstance, AxiosResponse } from 'axios' import type { AxiosInstance, AxiosResponse } from 'axios'
import type { ApiResponse } from '@/types' import type { ApiResponse } from '@/types'
import { resolveApiBaseUrl, resolveAuthMode } from '@/utils/authMode'
// 创建axios实例 // 创建axios实例
const api: AxiosInstance = axios.create({ const api: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api', baseURL: resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL),
timeout: 10000, timeout: 10000,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
@ -28,7 +29,46 @@ api.interceptors.request.use(
} }
) )
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak' const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE)
const getResponseErrorMessage = (error: any) => {
const responseData = error?.response?.data
if (responseData && typeof responseData === 'object') {
if (typeof responseData.message === 'string' && responseData.message.trim()) {
return responseData.message.trim()
}
if (
responseData.data &&
typeof responseData.data === 'object' &&
typeof responseData.data.message === 'string' &&
responseData.data.message.trim()
) {
return responseData.data.message.trim()
}
}
if (typeof error?.message === 'string' && error.message.trim()) {
return error.message.trim()
}
return '请求失败'
}
const createApiRequestError = (error: any) => {
const normalizedError = new Error(getResponseErrorMessage(error)) as Error & {
status?: number
response?: any
originalError?: any
}
normalizedError.name = 'ApiRequestError'
normalizedError.status = error?.response?.status
normalizedError.response = error?.response
normalizedError.originalError = error
return normalizedError
}
// 响应拦截器 // 响应拦截器
api.interceptors.response.use( api.interceptors.response.use(
@ -76,7 +116,7 @@ api.interceptors.response.use(
console.error('请求失败') console.error('请求失败')
} }
} }
return Promise.reject(error) return Promise.reject(createApiRequestError(error))
} }
) )

View File

@ -10,7 +10,11 @@ import type {
export interface InventoryTaskPreviewPayload { export interface InventoryTaskPreviewPayload {
department_id?: number | null department_id?: number | null
department_ids?: number[]
asset_type_id?: number | null asset_type_id?: number | null
asset_type_ids?: number[]
storage_location_id?: number | null
storage_location_ids?: number[]
} }
export interface CreateInventoryTaskPayload extends InventoryTaskPreviewPayload { export interface CreateInventoryTaskPayload extends InventoryTaskPreviewPayload {
@ -48,6 +52,11 @@ export const closeInventoryTask = async (id: number) => {
return response.data return response.data
} }
export const deleteInventoryTask = async (id: number) => {
const response = await api.delete<ApiResponse<null>>(`/inventory/tasks/${id}`)
return response.data
}
export const getInventoryTaskUnassignedItems = async (id: number) => { export const getInventoryTaskUnassignedItems = async (id: number) => {
const response = await api.get<ApiResponse<InventoryTaskItem[]>>(`/inventory/tasks/${id}/unassigned`) const response = await api.get<ApiResponse<InventoryTaskItem[]>>(`/inventory/tasks/${id}/unassigned`)
return response.data return response.data

View File

@ -1,5 +1,6 @@
import axios from 'axios' import axios from 'axios'
import api from './index' import api from './index'
import { resolveApiBaseUrl } from '@/utils/authMode'
import type { import type {
ApiResponse, ApiResponse,
CreateAssetLabelPrintJobPayload, CreateAssetLabelPrintJobPayload,
@ -20,7 +21,7 @@ import type {
UpdatePrintTerminalStatusPayload UpdatePrintTerminalStatusPayload
} from '@/types' } from '@/types'
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api' const apiBaseUrl = resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL)
const downloadBaseUrl = apiBaseUrl.replace(/\/api\/?$/, '') const downloadBaseUrl = apiBaseUrl.replace(/\/api\/?$/, '')
const getAuthHeaders = () => { const getAuthHeaders = () => {
@ -325,8 +326,9 @@ export const getPrintTerminals = async () => {
return normalizeCollection(response.data.data, ['list', 'items', 'terminals'], normalizePrintTerminal) return normalizeCollection(response.data.data, ['list', 'items', 'terminals'], normalizePrintTerminal)
} }
export const createAssetLabelPrintJob = async (equipmentIds: number[]): Promise<CreateAssetLabelPrintJobResult> => { export const createAssetLabelPrintJob = async (
const payload: CreateAssetLabelPrintJobPayload = { equipment_ids: equipmentIds } payload: CreateAssetLabelPrintJobPayload
): Promise<CreateAssetLabelPrintJobResult> => {
const response = await api.post<ApiResponse<unknown>>('/print-jobs/asset-labels', payload) const response = await api.post<ApiResponse<unknown>>('/print-jobs/asset-labels', payload)
return { return {
job: normalizePrintJob(response.data.data), job: normalizePrintJob(response.data.data),

View File

@ -1,5 +1,6 @@
import axios from 'axios' import axios from 'axios'
import type { AxiosResponse } from 'axios' import type { AxiosResponse } from 'axios'
import { resolveApiBaseUrl } from '@/utils/authMode'
import type { import type {
ApiResponse, ApiResponse,
InventoryTaskItem, InventoryTaskItem,
@ -10,7 +11,7 @@ import type {
} from '@/types' } from '@/types'
const resolvePublicInventoryBaseUrl = () => { const resolvePublicInventoryBaseUrl = () => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api' const baseUrl = resolveApiBaseUrl(import.meta.env.VITE_API_BASE_URL)
return `${baseUrl.replace(/\/$/, '')}/public/inventory` return `${baseUrl.replace(/\/$/, '')}/public/inventory`
} }

View File

@ -21,7 +21,9 @@ export const previewHrSync = async () => {
} }
export const runHrSync = async () => { export const runHrSync = async () => {
const response = await api.post<ApiResponse<HrSyncResult>>('/sync/hr/run') const response = await api.post<ApiResponse<HrSyncResult>>('/sync/hr/run', undefined, {
timeout: 180000
})
return response.data return response.data
} }

View File

@ -1,7 +1,7 @@
import Keycloak from 'keycloak-js' import Keycloak from 'keycloak-js'
const keycloak = new Keycloak({ const keycloak = new Keycloak({
url: import.meta.env.VITE_KEYCLOAK_URL || 'http://localhost:8080', url: import.meta.env.VITE_KEYCLOAK_URL || '',
realm: import.meta.env.VITE_KEYCLOAK_REALM || 'equipment-management', realm: import.meta.env.VITE_KEYCLOAK_REALM || 'equipment-management',
clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'equipment-client' clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'equipment-client'
}) })

View File

@ -137,7 +137,7 @@ const LABEL_PRESET = {
headerHeightPx: 82, headerHeightPx: 82,
qrPanelWidthPx: 324, qrPanelWidthPx: 324,
qrCodeSizePx: 286, qrCodeSizePx: 286,
companyName: '常州简尚家具有限公司' companyName: '常州简尚家居有限公司'
} as const } as const
const equipmentList = ref<EquipmentBarcode[]>([]) const equipmentList = ref<EquipmentBarcode[]>([])
@ -724,7 +724,20 @@ const handleRemotePrint = async () => {
remoteSubmitting.value = true remoteSubmitting.value = true
try { try {
const result = await createAssetLabelPrintJob(props.equipmentIds) const images = await ensureImagesGenerated()
if (images.length !== equipmentList.value.length) {
throw new Error('标签图片生成不完整,请稍后重试')
}
const renderedLabels = equipmentList.value.map((equipment, index) => ({
equipment_id: equipment.id,
image_data_url: images[index]
}))
const equipmentIds = equipmentList.value.map((equipment) => equipment.id)
const result = await createAssetLabelPrintJob({
equipment_ids: equipmentIds,
rendered_labels: renderedLabels
})
alert(result.queue_notice || `标签已发送到 ${defaultTerminal.value.name}`) alert(result.queue_notice || `标签已发送到 ${defaultTerminal.value.name}`)

View File

@ -0,0 +1,403 @@
<template>
<div v-if="modelValue && asset" class="sheet-backdrop" @click.self="close">
<div class="sheet-panel max-w-3xl">
<div class="flex items-start justify-between gap-4">
<div>
<p class="page-kicker">Borrow Exception</p>
<h3 class="text-xl font-semibold text-text-primary">异常借出处理</h3>
<p class="mt-2 text-sm text-text-tertiary">
这台资产当前标记为已借出,但系统里没有借用单。请按真实情况处理,不要猜。
</p>
</div>
<button type="button" class="btn-icon" @click="close" aria-label="关闭异常借出处理弹窗">✕</button>
</div>
<div class="mt-6 space-y-4">
<section class="card">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-lg font-semibold text-text-primary">{{ asset.equipmentName }}</h4>
<span class="badge badge-warning">借用单缺失</span>
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-xs text-text-tertiary">
<span class="font-mono">{{ asset.equipmentCode }}</span>
<span>{{ asset.equipmentType || '未分类' }}</span>
<span>{{ asset.equipmentModel || '未填写型号' }}</span>
<span>{{ asset.storageLocationName || '未设置地址' }}</span>
</div>
</div>
<div class="rounded-xl border border-status-warning/30 bg-status-warning/10 px-3 py-2 text-xs text-status-warning">
{{ asset.message || '请根据真实业务情况补处理' }}
</div>
</div>
</section>
<section class="panel space-y-4">
<div class="flex flex-wrap gap-2">
<button
type="button"
class="btn-secondary text-sm"
:class="{ 'btn-secondary--highlight': mode === 'mark-returned' }"
@click="mode = 'mark-returned'"
>
确认已归还
</button>
<button
type="button"
class="btn-secondary text-sm"
:class="{ 'btn-secondary--highlight': mode === 'register-borrow' }"
@click="mode = 'register-borrow'"
>
补登记借出
</button>
</div>
<div
v-if="feedback"
class="rounded-lg border px-4 py-3 text-sm"
:class="feedback.type === 'error'
? 'border-status-error/30 bg-status-error/10 text-status-error'
: 'border-status-warning/30 bg-status-warning/10 text-status-warning'"
>
{{ feedback.message }}
</div>
<template v-if="mode === 'mark-returned'">
<div class="space-y-3">
<p class="text-sm text-text-secondary">
适用于这台资产其实已经回来了,只是系统缺借用单,导致行政没法正常入库。
</p>
<div class="grid gap-3 sm:grid-cols-2">
<div class="rounded-lg border border-border bg-bg-secondary px-4 py-3">
<p class="text-xs uppercase tracking-wider text-text-tertiary">处理后状态</p>
<p class="mt-2 text-sm font-medium text-text-primary">恢复为可用</p>
</div>
<div class="rounded-lg border border-border bg-bg-secondary px-4 py-3">
<p class="text-xs uppercase tracking-wider text-text-tertiary">同步规则</p>
<p class="mt-2 text-sm font-medium text-text-primary">库存与价值按现有系统规则重算</p>
</div>
</div>
</div>
</template>
<template v-else>
<div class="space-y-4">
<div class="field-stack">
<label class="field-label">使用者 *</label>
<div class="space-y-3">
<input
v-model.trim="personKeyword"
type="text"
class="input"
placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索"
@keyup.enter="handlePersonKeywordSubmit"
/>
<p class="text-xs text-text-tertiary">
补登记借出时,只能选择系统里当前启用中的人员。
</p>
<div v-if="showPersonSearchResults" class="space-y-2">
<div class="flex items-center justify-between gap-3 px-1">
<p class="text-xs text-text-tertiary">支持花名、姓名、工号、部门和手机号搜索。</p>
<p class="text-xs text-text-tertiary whitespace-nowrap">
{{ personsLoading ? '搜索中...' : `共 ${filteredPersons.length} 条` }}
</p>
</div>
<div v-if="filteredPersons.length > 0" class="touch-option-list">
<button
v-for="person in filteredPersons"
:key="person.id"
type="button"
class="touch-option"
:class="{ 'is-active': selectedPersonId === person.id }"
@click="selectPerson(person)"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">
{{ person.nickname }} · {{ person.real_name }}
</p>
<p class="mt-1 text-xs text-text-secondary">
{{ person.department || '未填写部门' }}
<span v-if="person.phone"> · {{ person.phone }}</span>
<span v-if="person.id_number"> · {{ person.id_number }}</span>
</p>
</div>
<span v-if="selectedPersonId === person.id" class="badge badge-success">已选中</span>
</div>
</button>
</div>
<p v-else class="py-4 text-center text-sm text-text-tertiary">
当前没有匹配到使用者,请换个关键词再试。
</p>
</div>
</div>
</div>
<div class="field-grid">
<div class="field-stack">
<label class="field-label">借出日期 *</label>
<input
v-model="borrowDate"
type="date"
class="input"
/>
<p class="text-xs text-text-tertiary">
这里必须按真实借出日期手动选择,系统不会自动帮你猜。
</p>
</div>
<div class="field-stack">
<label class="field-label">补登记说明</label>
<textarea
v-model.trim="notes"
class="input"
rows="3"
placeholder="可选填写,例如:历史漏建借用单"
></textarea>
</div>
</div>
</div>
</template>
</section>
</div>
<div class="mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button type="button" class="btn-secondary" @click="close" :disabled="submitting">
取消
</button>
<button
v-if="mode === 'mark-returned'"
type="button"
class="btn-primary"
:disabled="submitting"
@click="submitMarkReturned"
>
{{ submitting ? '处理中...' : '确认已归还' }}
</button>
<button
v-else
type="button"
class="btn-primary"
:disabled="submitting || !canSubmitRegisterBorrow"
@click="submitRegisterBorrow"
>
{{ submitting ? '处理中...' : '确认补登记借出' }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { getPersonOptions } from '@/api/person'
import {
registerBorrowAnomaly,
resolveBorrowAnomalyAsReturned
} from '@/api/borrow'
import type { Person } from '@/types'
import {
buildManualBorrowPersonDisplay,
filterManualBorrowPersons,
findPreferredManualBorrowPerson,
isManualBorrowSelectedPersonKeyword,
shouldShowManualBorrowPersonResults
} from '@/views/borrow/manualBorrowPersonSearch'
interface BorrowAnomalyAssetSummary {
equipmentId: number
equipmentCode: string
equipmentName: string
equipmentModel?: string | null
equipmentType?: string | null
storageLocationName?: string | null
message?: string | null
}
const props = defineProps<{
modelValue: boolean
asset: BorrowAnomalyAssetSummary | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
handled: [
payload: {
action: 'mark-returned' | 'register-borrow'
message: string
}
]
}>()
const mode = ref<'mark-returned' | 'register-borrow'>('mark-returned')
const persons = ref<Person[]>([])
const personsLoading = ref(false)
const selectedPersonId = ref<number | null>(null)
const personKeyword = ref('')
const borrowDate = ref('')
const notes = ref('')
const submitting = ref(false)
const feedback = ref<{ type: 'warning' | 'error'; message: string } | null>(null)
const selectedPerson = computed(() => {
if (selectedPersonId.value === null) {
return null
}
return persons.value.find((person) => person.id === selectedPersonId.value) || null
})
const filteredPersons = computed(() => filterManualBorrowPersons(persons.value, personKeyword.value).slice(0, 8))
const showPersonSearchResults = computed(() =>
shouldShowManualBorrowPersonResults(selectedPerson.value, personKeyword.value)
)
const canSubmitRegisterBorrow = computed(() =>
selectedPersonId.value !== null && Boolean(borrowDate.value)
)
const close = () => {
emit('update:modelValue', false)
}
const resetForm = () => {
mode.value = 'mark-returned'
selectedPersonId.value = null
personKeyword.value = ''
borrowDate.value = ''
notes.value = ''
feedback.value = null
}
const ensurePersonsLoaded = async () => {
if (persons.value.length > 0 || personsLoading.value) {
return
}
personsLoading.value = true
try {
const response = await getPersonOptions()
persons.value = response.data || []
} finally {
personsLoading.value = false
}
}
const selectPerson = (person: Person) => {
selectedPersonId.value = person.id
personKeyword.value = buildManualBorrowPersonDisplay(person)
}
const handlePersonKeywordSubmit = async () => {
await ensurePersonsLoaded()
const preferredPerson = findPreferredManualBorrowPerson(filteredPersons.value, personKeyword.value)
if (preferredPerson) {
selectPerson(preferredPerson)
return
}
if (filteredPersons.value.length > 1) {
feedback.value = {
type: 'warning',
message: '匹配到多位使用者,请从下方结果中点选正确人员。'
}
return
}
if (personKeyword.value.trim()) {
feedback.value = {
type: 'warning',
message: '没有匹配到使用者,请换个关键词再试。'
}
}
}
const submitMarkReturned = async () => {
if (!props.asset) {
return
}
submitting.value = true
feedback.value = null
try {
const response = await resolveBorrowAnomalyAsReturned(props.asset.equipmentId)
emit('handled', {
action: 'mark-returned',
message: response.message || `${props.asset.equipmentCode} 已按异常借出处理为归还完成`
})
close()
resetForm()
} catch (error: any) {
feedback.value = {
type: 'error',
message: error?.message || '异常借出处理失败'
}
} finally {
submitting.value = false
}
}
const submitRegisterBorrow = async () => {
if (!props.asset || !selectedPersonId.value || !borrowDate.value) {
return
}
submitting.value = true
feedback.value = null
try {
const response = await registerBorrowAnomaly(props.asset.equipmentId, {
person_id: selectedPersonId.value,
borrow_time: borrowDate.value,
notes: notes.value || undefined
})
emit('handled', {
action: 'register-borrow',
message: response.message || `${props.asset.equipmentCode} 已补登记借出`
})
close()
resetForm()
} catch (error: any) {
feedback.value = {
type: 'error',
message: error?.message || '补登记借出失败'
}
} finally {
submitting.value = false
}
}
watch(
() => props.modelValue,
async (value) => {
if (!value) {
resetForm()
return
}
await ensurePersonsLoaded()
}
)
watch(
() => props.asset?.equipmentId,
() => {
resetForm()
}
)
watch(
() => personKeyword.value,
(value) => {
const shouldKeepCurrentSelection =
selectedPerson.value !== null &&
isManualBorrowSelectedPersonKeyword(selectedPerson.value, value)
if (selectedPersonId.value !== null && !shouldKeepCurrentSelection) {
selectedPersonId.value = null
}
}
)
</script>

View File

@ -7,6 +7,7 @@ import keycloak from './auth/keycloak'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import api from './api' import api from './api'
import type { User } from './types' import type { User } from './types'
import { resolveAuthMode } from './utils/authMode'
const PUBLIC_INVENTORY_ROUTE_PREFIX = '/inventory/task/' const PUBLIC_INVENTORY_ROUTE_PREFIX = '/inventory/task/'
@ -18,7 +19,7 @@ const bootstrap = async () => {
const authStore = useAuthStore() const authStore = useAuthStore()
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak' const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE)
const isPublicInventoryRoute = window.location.pathname.startsWith(PUBLIC_INVENTORY_ROUTE_PREFIX) const isPublicInventoryRoute = window.location.pathname.startsWith(PUBLIC_INVENTORY_ROUTE_PREFIX)
if (authMode === 'local') { if (authMode === 'local') {

View File

@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import keycloak from '@/auth/keycloak' import keycloak from '@/auth/keycloak'
import { resolveAuthMode } from '@/utils/authMode'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
@ -111,7 +112,7 @@ const router = createRouter({
router.beforeEach((to, _from, next) => { router.beforeEach((to, _from, next) => {
const authStore = useAuthStore() const authStore = useAuthStore()
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak' const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE)
const isAuthenticated = const isAuthenticated =
authMode === 'local' authMode === 'local'
? authStore.isAuthenticated ? authStore.isAuthenticated

View File

@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import type { User } from '@/types' import type { User } from '@/types'
import keycloak from '@/auth/keycloak' import keycloak from '@/auth/keycloak'
import api from '@/api' import api from '@/api'
import { resolveAuthMode } from '@/utils/authMode'
interface AuthState { interface AuthState {
user: User | null user: User | null
@ -34,7 +35,7 @@ export const useAuthStore = defineStore('auth', {
this.isAuthenticated = false this.isAuthenticated = false
localStorage.removeItem('token') localStorage.removeItem('token')
try { try {
const authMode = import.meta.env.VITE_AUTH_MODE || 'keycloak' const authMode = resolveAuthMode(import.meta.env.VITE_AUTH_MODE)
if (authMode === 'local') { if (authMode === 'local') {
await api.post('/auth/logout') await api.post('/auth/logout')
} else if (keycloak.authenticated) { } else if (keycloak.authenticated) {
@ -46,4 +47,3 @@ export const useAuthStore = defineStore('auth', {
} }
} }
}) })

View File

@ -4,6 +4,15 @@ export type EquipmentType = string
// 资产状态 // 资产状态
export type EquipmentStatus = '可用' | '已借出' | '维护中' | '已损坏' | '待报废' | '已报废' export type EquipmentStatus = '可用' | '已借出' | '维护中' | '已损坏' | '待报废' | '已报废'
export interface BorrowAnomalyInfo {
state: 'none' | 'returnable' | 'borrow_record_missing'
can_handle?: boolean
canHandle?: boolean
message?: string | null
blocked_reason?: string | null
blockedReason?: string | null
}
// 编码生成方式 // 编码生成方式
export type CodeGenerationMode = 'auto' | 'manual' export type CodeGenerationMode = 'auto' | 'manual'
@ -140,6 +149,7 @@ export interface Equipment {
unit_price?: number unit_price?: number
} | null } | null
current_borrow?: EquipmentCurrentBorrow | null current_borrow?: EquipmentCurrentBorrow | null
borrow_anomaly?: BorrowAnomalyInfo | null
children?: Array<{ children?: Array<{
id: number id: number
equipment_code: string equipment_code: string
@ -239,6 +249,9 @@ export interface InventoryScopeEntity {
export interface InventoryTaskScopeSnapshot { export interface InventoryTaskScopeSnapshot {
scope_department: InventoryScopeEntity | null scope_department: InventoryScopeEntity | null
scope_asset_type: InventoryScopeEntity | null scope_asset_type: InventoryScopeEntity | null
scope_departments: InventoryScopeEntity[]
scope_asset_types: InventoryScopeEntity[]
scope_storage_locations: InventoryScopeEntity[]
participants_count: number participants_count: number
items_count: number items_count: number
unassigned_count: number unassigned_count: number
@ -288,6 +301,9 @@ export interface InventoryPreviewItemSample {
export interface InventoryTaskPreview { export interface InventoryTaskPreview {
scope_department: InventoryScopeEntity | null scope_department: InventoryScopeEntity | null
scope_asset_type: InventoryScopeEntity | null scope_asset_type: InventoryScopeEntity | null
scope_departments: InventoryScopeEntity[]
scope_asset_types: InventoryScopeEntity[]
scope_storage_locations: InventoryScopeEntity[]
participants_count: number participants_count: number
items_count: number items_count: number
unassigned_count: number unassigned_count: number
@ -317,6 +333,10 @@ export interface InventoryTaskListItem {
closed_at?: string | null closed_at?: string | null
created_at: string created_at: string
updated_at: string updated_at: string
scope_snapshot?: InventoryTaskScopeSnapshot | null
scope_departments?: InventoryScopeEntity[]
scope_asset_types?: InventoryScopeEntity[]
scope_storage_locations?: InventoryScopeEntity[]
} }
export interface InventoryTaskParticipant { export interface InventoryTaskParticipant {
@ -415,6 +435,10 @@ export interface PublicInventoryTaskInfo {
unassigned_count: number unassigned_count: number
created_at: string created_at: string
closed_at?: string | null closed_at?: string | null
scope_snapshot?: InventoryTaskScopeSnapshot | null
scope_departments?: InventoryScopeEntity[]
scope_asset_types?: InventoryScopeEntity[]
scope_storage_locations?: InventoryScopeEntity[]
} }
export interface PublicInventoryParticipantOption { export interface PublicInventoryParticipantOption {
@ -433,6 +457,10 @@ export interface PublicInventorySessionTask {
status: InventoryTaskStatus | string status: InventoryTaskStatus | string
scope_department_name?: string | null scope_department_name?: string | null
scope_asset_type_name?: string | null scope_asset_type_name?: string | null
scope_snapshot?: InventoryTaskScopeSnapshot | null
scope_departments?: InventoryScopeEntity[]
scope_asset_types?: InventoryScopeEntity[]
scope_storage_locations?: InventoryScopeEntity[]
completed_count: number completed_count: number
exception_count: number exception_count: number
items_count: number items_count: number
@ -595,6 +623,7 @@ export interface HrSyncStatus {
complete: boolean complete: boolean
missingFields: string[] missingFields: string[]
scheduleTimes: string[] scheduleTimes: string[]
scheduleTimezone?: string
} }
export interface HrSyncResult { export interface HrSyncResult {
@ -657,6 +686,20 @@ export interface HrSyncChangeItem {
created_at: string created_at: string
} }
export interface HrSyncBorrowedAssetPreview {
equipmentCode: string
equipmentName: string
equipmentId?: number | null
borrowRecordId?: number | null
personId?: number | null
}
export interface HrSyncPersonAfterSnapshot {
displayName?: string | null
assets?: HrSyncBorrowedAssetPreview[]
[key: string]: any
}
export interface HrSyncJobDetail extends HrSyncJobHistoryItem { export interface HrSyncJobDetail extends HrSyncJobHistoryItem {
changes: HrSyncChangeItem[] changes: HrSyncChangeItem[]
} }
@ -775,6 +818,11 @@ export interface PrintJobPayload {
[key: string]: unknown [key: string]: unknown
} }
export interface AssetLabelRenderedImage {
equipment_id: number
image_data_url: string
}
export interface PrintJob { export interface PrintJob {
id: number id: number
job_type: PrintJobType | string job_type: PrintJobType | string
@ -806,6 +854,7 @@ export interface PrintJobListData extends PaginatedResponse<PrintJob> {}
export interface CreateAssetLabelPrintJobPayload { export interface CreateAssetLabelPrintJobPayload {
equipment_ids: number[] equipment_ids: number[]
rendered_labels: AssetLabelRenderedImage[]
} }
export interface CreateAssetLabelPrintJobResult { export interface CreateAssetLabelPrintJobResult {

View File

@ -0,0 +1,11 @@
export type AuthMode = 'local' | 'keycloak'
export const resolveAuthMode = (rawMode?: string | null): AuthMode => {
return rawMode === 'keycloak' ? 'keycloak' : 'local'
}
export const resolveApiBaseUrl = (rawBaseUrl?: string | null): string => {
const normalizedBaseUrl = rawBaseUrl?.trim()
return normalizedBaseUrl || '/api'
}

View File

@ -0,0 +1,15 @@
export interface BorrowAnomalyLike {
state?: string | null
can_handle?: boolean | null
canHandle?: boolean | null
}
export interface EquipmentWithBorrowAnomaly {
borrow_anomaly?: BorrowAnomalyLike | null
}
export const normalizeBorrowAnomalyCanHandle = (borrowAnomaly?: BorrowAnomalyLike | null) =>
Boolean(borrowAnomaly?.canHandle ?? borrowAnomaly?.can_handle)
export const canHandleBorrowAnomaly = (equipment?: EquipmentWithBorrowAnomaly | null) =>
equipment?.borrow_anomaly?.state === 'borrow_record_missing' && normalizeBorrowAnomalyCanHandle(equipment.borrow_anomaly)

View File

@ -85,6 +85,7 @@ import keycloak from '@/auth/keycloak'
import api from '@/api' import api from '@/api'
import type { User } from '@/types' import type { User } from '@/types'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
import { resolveAuthMode } from '@/utils/authMode'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
@ -94,7 +95,7 @@ const password = ref('')
const errorText = ref('') const errorText = ref('')
const siteStore = useSiteStore() const siteStore = useSiteStore()
const authMode = (import.meta.env as any).VITE_AUTH_MODE || 'keycloak' const authMode = resolveAuthMode((import.meta.env as any).VITE_AUTH_MODE)
const isLocalAuth = computed(() => authMode === 'local') const isLocalAuth = computed(() => authMode === 'local')
const siteName = computed(() => siteStore.siteName || '固定资产管理系统') const siteName = computed(() => siteStore.siteName || '固定资产管理系统')

View File

@ -45,21 +45,69 @@
<h4 class="form-panel__title">手动输入</h4> <h4 class="form-panel__title">手动输入</h4>
</div> </div>
<div class="field-stack"> <div class="field-stack">
<label class="field-label">资产编码</label> <label class="field-label">资产搜索</label>
<div class="flex flex-col gap-2 sm:flex-row"> <div class="flex flex-col gap-2 sm:flex-row">
<input <input
ref="scanInputRef" ref="scanInputRef"
v-model="scanCode" v-model.trim="manualKeyword"
type="text" type="text"
class="input" class="input"
placeholder="输入编码后回车" placeholder="输入资产编码 / 名称 / 型号 / 地址搜索"
@keyup.enter="handleScan" @keyup.enter="handleManualSubmit"
@focus="handleFocus"
/> />
<button type="button" class="btn-secondary w-full sm:w-auto sm:min-w-[100px] text-sm" @click="handleScan"> <button
读取 type="button"
class="btn-secondary w-full sm:w-auto sm:min-w-[120px] text-sm"
@click="handleManualSubmit"
>
按编码读取
</button> </button>
</div> </div>
<p class="text-xs text-text-tertiary">支持搜索编码 / 名称 / 型号 / 地址,只显示当前可借资产。</p>
</div>
<div v-if="manualKeyword.trim()" class="panel">
<div class="list-header">
<div>
<p class="text-sm font-medium text-text-primary">匹配结果</p>
<p class="mt-1 text-xs text-text-tertiary">点一条即可带入,不用再手抄资产编码。</p>
</div>
<p class="text-xs text-text-tertiary">
{{ manualSearchLoading ? '搜索中...' : `共 ${manualSearchResults.length} 条` }}
</p>
</div>
<div v-if="manualSearchResults.length > 0" class="mt-3 space-y-2">
<button
v-for="item in manualSearchResults"
:key="item.id"
type="button"
class="touch-option w-full text-left"
@click="selectManualSearchResult(item)"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-text-primary">
{{ item.equipment_name }}
<span class="text-text-tertiary">· {{ item.equipment_code }}</span>
</p>
<p class="mt-1 text-xs text-text-secondary">
{{ item.equipment_type || '未分类' }}
<span v-if="item.equipment_model"> · {{ item.equipment_model }}</span>
<span v-if="item.storageLocation?.name"> · {{ item.storageLocation.name }}</span>
</p>
</div>
<span class="badge badge-success">可借 {{ item.remaining_quantity }}</span>
</div>
</button>
</div>
<p v-else-if="manualSearchLoading" class="py-4 text-center text-sm text-text-tertiary">
正在搜索可借资产...
</p>
<p v-else class="py-4 text-center text-sm text-text-tertiary">
没有匹配到可借资产。若你已经知道准确编码,也可以直接点“按编码读取”。
</p>
</div> </div>
</div> </div>
@ -123,25 +171,10 @@
</p> </p>
</div> </div>
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between pt-3 border-t border-border mt-3"> <div class="pt-3 mt-3 border-t border-border">
<p class="text-xs text-text-secondary"> <p class="text-xs text-text-secondary">
核对无误后再出库 资产信息已带入,确认无误后可直接选择使用者并出库。
</p> </p>
<div class="flex items-center gap-2">
<button
v-if="!equipmentConfirmed"
class="btn-primary text-sm"
@click="equipmentConfirmed = true"
>
确认
</button>
<span
v-else
class="badge badge-success"
>
已确认
</span>
</div>
</div> </div>
</section> </section>
</div> </div>
@ -165,42 +198,45 @@
v-model.trim="personKeyword" v-model.trim="personKeyword"
type="text" type="text"
class="input" class="input"
placeholder="输入花名 / 姓名 / 证件号进行搜索" placeholder="输入花名 / 姓名 / 工号 / 部门 / 手机号搜索"
@keyup.enter="handlePersonKeywordSubmit"
/> />
<select <p class="text-xs text-text-tertiary">
v-model.number="selectedPersonId" 输入后会直接搜索人员,点选即可;如果只匹配到 1 人,按回车也能直接选中。
class="select hidden md:block" </p>
> <div v-if="showPersonSearchResults" class="space-y-2">
<option value="" disabled>请选择使用者</option> <div class="flex items-center justify-between gap-3 px-1">
<option <p class="text-xs text-text-tertiary">支持花名、姓名、工号、部门和手机号搜索。</p>
v-for="person in filteredPersons" <p class="text-xs text-text-tertiary whitespace-nowrap">
:key="person.id" {{ personSearchLoading ? '搜索中...' : `共 ${topFilteredPersons.length} 条` }}
:value="person.id" </p>
> </div>
{{ person.nickname }} · {{ person.real_name }}{{ person.department ? `(${person.department})` : '' }} <div v-if="topFilteredPersons.length > 0" class="touch-option-list">
</option> <button
</select> v-for="person in topFilteredPersons"
<div class="touch-option-list md:hidden"> :key="person.id"
<button type="button"
v-for="person in topFilteredPersons" class="touch-option"
:key="person.id" :class="{ 'is-active': selectedPersonId === person.id }"
type="button" @click="selectPerson(person)"
class="touch-option" >
:class="{ 'is-active': selectedPersonId === person.id }" <div class="flex items-start justify-between gap-3">
@click="selectedPersonId = person.id" <div>
> <p class="text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p>
<div class="flex items-start justify-between gap-3"> <p class="mt-1 text-xs text-text-secondary">
<div> {{ person.department || '未填写部门' }}
<p class="text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p> <span v-if="person.phone"> · {{ person.phone }}</span>
<p class="mt-1 text-xs text-text-secondary"> <span v-if="person.id_number"> · {{ person.id_number }}</span>
{{ person.department || '未填写部门' }} </p>
<span v-if="person.phone"> · {{ person.phone }}</span> </div>
</p> <span v-if="selectedPersonId === person.id" class="badge badge-success">已选中</span>
</div> </div>
<span v-if="selectedPersonId === person.id" class="badge badge-success">已选中</span> </button>
</div> </div>
</button> <p v-else-if="personSearchLoading" class="text-center text-sm text-text-tertiary py-4">
<p v-if="topFilteredPersons.length === 0" class="text-center text-sm text-text-tertiary py-4"> 正在搜索使用者...
</p>
<p v-else class="text-center text-sm text-text-tertiary py-4">
当前没有匹配到使用者,请换个关键词再试。 当前没有匹配到使用者,请换个关键词再试。
</p> </p>
</div> </div>
@ -216,28 +252,6 @@
</p> </p>
</div> </div>
</div> </div>
<div v-if="selectedPerson">
<div class="panel">
<div class="field-grid text-sm">
<div>
<span class="text-text-tertiary">花名:</span>
<span class="text-text-primary">{{ selectedPerson.nickname }}</span>
</div>
<div>
<span class="text-text-tertiary">姓名:</span>
<span class="text-text-primary">{{ selectedPerson.real_name }}</span>
</div>
<div v-if="selectedPerson.department">
<span class="text-text-tertiary">部门:</span>
<span class="text-text-primary">{{ selectedPerson.department }}</span>
</div>
<div v-if="selectedPerson.phone">
<span class="text-text-tertiary">电话:</span>
<span class="text-text-primary">{{ selectedPerson.phone }}</span>
</div>
</div>
</div>
</div>
<div class="field-stack"> <div class="field-stack">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label class="field-label">预计归还时间</label> <label class="field-label">预计归还时间</label>
@ -304,7 +318,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { scanEquipment } from '@/api/equipment' import { getEquipmentList, scanEquipment } from '@/api/equipment'
import { scanBorrow } from '@/api/borrow' import { scanBorrow } from '@/api/borrow'
import { getPersonList } from '@/api/person' import { getPersonList } from '@/api/person'
import { getChildren } from '@/api/mount' import { getChildren } from '@/api/mount'
@ -312,6 +326,18 @@ import type { Equipment, Person } from '@/types'
import type { ChildEquipment } from '@/api/mount' import type { ChildEquipment } from '@/api/mount'
import { formatCurrency, playBeepSound, playErrorSound } from '@/utils/format' import { formatCurrency, playBeepSound, playErrorSound } from '@/utils/format'
import CameraScannerModal from '@/components/CameraScannerModal.vue' import CameraScannerModal from '@/components/CameraScannerModal.vue'
import { canSubmitBorrowScan } from './borrowScanState'
import {
filterManualBorrowCandidates,
findPreferredManualBorrowCandidate
} from './manualBorrowSearch'
import {
buildManualBorrowPersonDisplay,
filterManualBorrowPersons,
findPreferredManualBorrowPerson,
isManualBorrowSelectedPersonKeyword,
shouldShowManualBorrowPersonResults
} from './manualBorrowPersonSearch'
const scanCode = ref('') const scanCode = ref('')
const scanInputRef = ref<HTMLInputElement>() const scanInputRef = ref<HTMLInputElement>()
@ -319,7 +345,6 @@ const route = useRoute()
const showCameraScanner = ref(false) const showCameraScanner = ref(false)
const showManualEntry = ref(window.matchMedia('(pointer: fine)').matches) const showManualEntry = ref(window.matchMedia('(pointer: fine)').matches)
const equipmentInfo = ref<Equipment | null>(null) const equipmentInfo = ref<Equipment | null>(null)
const equipmentConfirmed = ref(false)
const mountedChildren = ref<ChildEquipment[]>([]) const mountedChildren = ref<ChildEquipment[]>([])
const mountedChildrenValue = ref(0) const mountedChildrenValue = ref(0)
const successInfo = ref<{ const successInfo = ref<{
@ -336,33 +361,28 @@ const borrowForm = ref({
is_long_term: false is_long_term: false
}) })
const manualKeyword = ref('')
const manualSearchResults = ref<Equipment[]>([])
const manualSearchLoading = ref(false)
const personKeyword = ref('') const personKeyword = ref('')
const selectedPersonId = ref<number | null>(null) const selectedPersonId = ref<number | null>(null)
const persons = ref<Person[]>([]) const persons = ref<Person[]>([])
let searchTimer: number | null = null let equipmentSearchTimer: number | null = null
let personSearchTimer: number | null = null
let latestEquipmentRequestId = 0
let latestPersonRequestId = 0 let latestPersonRequestId = 0
const personSearchLoading = ref(false)
const canSubmit = computed(() => { const canSubmit = computed(() => {
const hasEquipment = Boolean(equipmentInfo.value) return canSubmitBorrowScan({
const personId = selectedPersonId.value hasEquipment: Boolean(equipmentInfo.value),
const hasPerson = personId !== null && !Number.isNaN(personId) selectedPersonId: selectedPersonId.value,
return hasEquipment && hasPerson && equipmentConfirmed.value && !loading.value loading: loading.value
})
}) })
const filteredPersons = computed(() => { const filteredPersons = computed(() => {
if (!personKeyword.value) { return filterManualBorrowPersons(persons.value, personKeyword.value)
return persons.value
}
const keyword = personKeyword.value.trim().toLowerCase()
return persons.value.filter((person) => {
return (
person.nickname.toLowerCase().includes(keyword) ||
person.real_name.toLowerCase().includes(keyword) ||
(person.id_number && person.id_number.toLowerCase().includes(keyword)) ||
(person.department && person.department.toLowerCase().includes(keyword)) ||
Boolean(person.department_names?.some((name) => name.toLowerCase().includes(keyword)))
)
})
}) })
const topFilteredPersons = computed(() => filteredPersons.value.slice(0, 8)) const topFilteredPersons = computed(() => filteredPersons.value.slice(0, 8))
@ -372,6 +392,10 @@ const selectedPerson = computed(() => {
return persons.value.find((person) => person.id === selectedPersonId.value) || null return persons.value.find((person) => person.id === selectedPersonId.value) || null
}) })
const showPersonSearchResults = computed(() =>
shouldShowManualBorrowPersonResults(selectedPerson.value, personKeyword.value)
)
const getStatusBadgeClass = (status: string) => { const getStatusBadgeClass = (status: string) => {
switch (status) { switch (status) {
case '可用': case '可用':
@ -387,35 +411,46 @@ const getStatusBadgeClass = (status: string) => {
} }
} }
const handleFocus = () => { const resetBorrowDraftState = () => {
scanCode.value = '' mountedChildren.value = []
mountedChildrenValue.value = 0
selectedPersonId.value = null
personKeyword.value = ''
borrowForm.value = {
expected_return_time: '',
notes: '',
is_long_term: false
}
}
const focusManualInput = () => {
nextTick(() => {
scanInputRef.value?.focus()
})
}
const clearManualSearch = () => {
manualKeyword.value = ''
manualSearchResults.value = []
manualSearchLoading.value = false
} }
const handleCameraDetected = async (value: string) => { const handleCameraDetected = async (value: string) => {
scanCode.value = value.trim() await handleScan(value)
await handleScan()
} }
const handleScan = async () => { const handleScan = async (rawCode = scanCode.value) => {
if (!scanCode.value) { const normalizedCode = rawCode.trim().toUpperCase()
if (!normalizedCode) {
return return
} }
try { try {
const response = await scanEquipment(scanCode.value) const response = await scanEquipment(normalizedCode)
if (response.data) { if (response.data) {
equipmentInfo.value = response.data equipmentInfo.value = response.data
equipmentConfirmed.value = false
successInfo.value = null successInfo.value = null
mountedChildren.value = [] resetBorrowDraftState()
mountedChildrenValue.value = 0
selectedPersonId.value = null
personKeyword.value = ''
borrowForm.value = {
expected_return_time: '',
notes: '',
is_long_term: false
}
playBeepSound() playBeepSound()
// 获取已挂载的子资产 // 获取已挂载的子资产
@ -431,9 +466,8 @@ const handleScan = async () => {
// 清空扫码框,准备下次扫码 // 清空扫码框,准备下次扫码
scanCode.value = '' scanCode.value = ''
nextTick(() => { clearManualSearch()
scanInputRef.value?.focus() focusManualInput()
})
} }
} catch (error: any) { } catch (error: any) {
playErrorSound() playErrorSound()
@ -442,6 +476,91 @@ const handleScan = async () => {
} }
} }
const fetchManualSearchResults = async (keyword: string) => {
const normalizedKeyword = keyword.trim()
if (!normalizedKeyword) {
manualSearchResults.value = []
manualSearchLoading.value = false
return
}
const requestId = ++latestEquipmentRequestId
manualSearchLoading.value = true
try {
const response = await getEquipmentList({
page: 1,
pageSize: 12,
status: '可用',
keyword: normalizedKeyword
})
if (requestId !== latestEquipmentRequestId) {
return
}
manualSearchResults.value = filterManualBorrowCandidates(response.data.list, normalizedKeyword) as Equipment[]
} catch (error) {
if (requestId === latestEquipmentRequestId) {
manualSearchResults.value = []
}
console.error('搜索可借资产失败', error)
} finally {
if (requestId === latestEquipmentRequestId) {
manualSearchLoading.value = false
}
}
}
const selectManualSearchResult = async (equipment: Equipment) => {
manualKeyword.value = equipment.equipment_code
await handleScan(equipment.equipment_code)
}
const selectPerson = (person: Person) => {
selectedPersonId.value = person.id
personKeyword.value = buildManualBorrowPersonDisplay(person)
}
const handlePersonKeywordSubmit = () => {
const preferredPerson = findPreferredManualBorrowPerson(topFilteredPersons.value, personKeyword.value)
if (preferredPerson) {
selectPerson(preferredPerson)
return
}
if (topFilteredPersons.value.length > 1) {
alert('匹配到多位使用者,请从下方结果中点选具体人员。')
return
}
if (personKeyword.value.trim()) {
alert('没有匹配到使用者,请换个关键词试试。')
}
}
const handleManualSubmit = async () => {
const keyword = manualKeyword.value.trim()
if (!keyword) {
return
}
const preferredCandidate = findPreferredManualBorrowCandidate(manualSearchResults.value, keyword)
if (preferredCandidate) {
await selectManualSearchResult(preferredCandidate as Equipment)
return
}
if (manualSearchResults.value.length > 1) {
alert('匹配到多条资产,请从下方点选具体资产,或直接输入准确资产编码。')
return
}
scanCode.value = keyword.toUpperCase()
await handleScan(scanCode.value)
}
const handleSubmit = async () => { const handleSubmit = async () => {
if (!equipmentInfo.value || selectedPersonId.value === null || Number.isNaN(selectedPersonId.value)) { if (!equipmentInfo.value || selectedPersonId.value === null || Number.isNaN(selectedPersonId.value)) {
return return
@ -480,19 +599,9 @@ const handleSubmit = async () => {
equipmentInfo.value.status = '已借出' equipmentInfo.value.status = '已借出'
} }
equipmentInfo.value = null equipmentInfo.value = null
mountedChildren.value = [] resetBorrowDraftState()
mountedChildrenValue.value = 0 clearManualSearch()
selectedPersonId.value = null focusManualInput()
personKeyword.value = ''
equipmentConfirmed.value = false
borrowForm.value = {
expected_return_time: '',
notes: '',
is_long_term: false
}
nextTick(() => {
scanInputRef.value?.focus()
})
} }
} catch (error: any) { } catch (error: any) {
alert(error.message || '出库失败') alert(error.message || '出库失败')
@ -509,40 +618,73 @@ const loadEquipmentFromRoute = async () => {
return return
} }
scanCode.value = routeEquipmentCode await handleScan(routeEquipmentCode)
await handleScan()
} }
const fetchPersons = async (keyword = '') => { const fetchPersons = async (keyword = '') => {
const requestId = ++latestPersonRequestId const requestId = ++latestPersonRequestId
personSearchLoading.value = true
try { try {
const response = await getPersonList({ const response = await getPersonList({
page: 1, page: 1,
pageSize: 100, pageSize: keyword ? 20 : 100,
keyword: keyword || undefined, keyword: keyword || undefined,
status: 'active' status: 'active'
}) })
if (response.data && requestId === latestPersonRequestId) { if (response.data && requestId === latestPersonRequestId) {
persons.value = response.data.list persons.value = response.data.list
if (selectedPersonId.value) {
const exists = persons.value.some((person) => person.id === selectedPersonId.value)
if (!exists) {
selectedPersonId.value = null
}
}
} }
} catch (error) { } catch (error) {
console.error('获取使用者列表失败', error) console.error('获取使用者列表失败', error)
} finally {
if (requestId === latestPersonRequestId) {
personSearchLoading.value = false
}
} }
} }
watch(
() => manualKeyword.value,
(value) => {
if (equipmentSearchTimer) {
window.clearTimeout(equipmentSearchTimer)
}
const keyword = value.trim()
if (!keyword) {
manualSearchResults.value = []
manualSearchLoading.value = false
return
}
equipmentSearchTimer = window.setTimeout(() => {
fetchManualSearchResults(keyword)
}, 250)
}
)
watch( watch(
() => personKeyword.value, () => personKeyword.value,
(value) => { (value) => {
if (searchTimer) { const matchedSelectedPerson = selectedPerson.value
window.clearTimeout(searchTimer) const shouldKeepCurrentSelection =
matchedSelectedPerson !== null &&
isManualBorrowSelectedPersonKeyword(matchedSelectedPerson, value)
if (selectedPersonId.value !== null && !shouldKeepCurrentSelection) {
selectedPersonId.value = null
} }
searchTimer = window.setTimeout(() => {
if (personSearchTimer) {
window.clearTimeout(personSearchTimer)
}
if (shouldKeepCurrentSelection) {
personSearchLoading.value = false
return
}
personSearchTimer = window.setTimeout(() => {
fetchPersons(value) fetchPersons(value)
}, 300) }, 300)
} }
@ -557,24 +699,18 @@ watch(
} }
) )
watch(
() => equipmentInfo.value?.equipment_code,
() => {
equipmentConfirmed.value = false
}
)
onMounted(() => { onMounted(() => {
fetchPersons() fetchPersons()
nextTick(() => { focusManualInput()
scanInputRef.value?.focus()
})
loadEquipmentFromRoute() loadEquipmentFromRoute()
}) })
onUnmounted(() => { onUnmounted(() => {
if (searchTimer) { if (equipmentSearchTimer) {
window.clearTimeout(searchTimer) window.clearTimeout(equipmentSearchTimer)
}
if (personSearchTimer) {
window.clearTimeout(personSearchTimer)
} }
}) })

View File

@ -1,6 +1,5 @@
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<!-- 页面头部 -->
<section class="page-header"> <section class="page-header">
<div> <div>
<p class="page-kicker">Quick Return</p> <p class="page-kicker">Quick Return</p>
@ -8,7 +7,6 @@
</div> </div>
</section> </section>
<!-- Scan Input Area -->
<div class="scanner-hero"> <div class="scanner-hero">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between"> <div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div> <div>
@ -61,25 +59,78 @@
<h4 class="form-panel__title">手动输入</h4> <h4 class="form-panel__title">手动输入</h4>
</div> </div>
<div class="field-stack"> <div class="field-stack">
<label class="field-label">资产编码</label> <label class="field-label">资产搜索</label>
<div class="flex flex-col gap-2 sm:flex-row"> <div class="flex flex-col gap-2 sm:flex-row">
<input <input
ref="scanInputRef" ref="scanInputRef"
v-model="scanCode" v-model.trim="manualAssetKeyword"
type="text" type="text"
class="input" class="input"
placeholder="输入编码后回车" placeholder="输入资产编码 / 名称 / 型号 / 地址搜索"
@keyup.enter="handleScan" @keyup.enter="handleManualAssetSubmit"
@focus="handleFocus" @focus="handleFocus"
/> />
<button type="button" class="btn-secondary w-full sm:w-auto sm:min-w-[100px] text-sm" @click="handleScan"> <button
读取 type="button"
class="btn-secondary w-full sm:w-auto sm:min-w-[120px] text-sm"
@click="handleManualAssetSubmit"
>
按搜索读取
</button> </button>
</div> </div>
<p class="text-xs text-text-tertiary">
第一步只按资产信息搜索。搜到资产后,再在下一步选择实际使用者。
</p>
</div>
<div v-if="manualAssetKeyword.trim()" class="panel">
<div class="list-header">
<div>
<p class="text-sm font-medium text-text-primary">匹配结果</p>
<p class="mt-1 text-xs text-text-tertiary">支持搜索编码 / 名称 / 型号 / 地址。已借出但借用单缺失的资产也会单独提示。</p>
</div>
<p class="text-xs text-text-tertiary">
{{ manualAssetSearchLoading ? '搜索中...' : `共 ${manualAssetSearchResults.length} 条` }}
</p>
</div>
<div v-if="manualAssetSearchResults.length > 0" class="mt-3 space-y-2">
<button
v-for="item in manualAssetSearchResults"
:key="item.equipment_id"
type="button"
class="touch-option w-full text-left"
@click="selectManualAsset(item)"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-text-primary">
{{ item.equipment_name }}
<span class="text-text-tertiary">· {{ item.equipment_code }}</span>
</p>
<p class="mt-1 text-xs text-text-secondary">
{{ item.equipment_type || '未分类' }}
<span v-if="item.equipment_model"> · {{ item.equipment_model }}</span>
<span v-if="item.storageLocation?.name"> · {{ item.storageLocation.name }}</span>
<span v-if="item.search_state === 'borrow_record_missing'"> · {{ item.state_message }}</span>
<span v-else-if="item.borrower_names?.length"> · 借用人 {{ item.borrower_names.slice(0, 2).join('、') }}</span>
</p>
</div>
<span class="badge" :class="getManualAssetBadgeClass(item)">
{{ getManualAssetBadgeText(item) }}
</span>
</div>
</button>
</div>
<p v-else-if="manualAssetSearchLoading" class="py-4 text-center text-sm text-text-tertiary">
正在搜索待归还资产...
</p>
<p v-else class="py-4 text-center text-sm text-text-tertiary">
没有匹配到需要处理的借出资产。可尝试资产编码、资产名称、型号或地址。
</p>
</div> </div>
</div> </div>
<!-- Equipment and Borrow Record Info -->
<section v-if="borrowRecordInfo" class="card mt-6"> <section v-if="borrowRecordInfo" class="card mt-6">
<div class="list-header"> <div class="list-header">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
@ -122,88 +173,68 @@
<h3 class="section-title mb-4">借用记录</h3> <h3 class="section-title mb-4">借用记录</h3>
<div class="space-y-4"> <div class="space-y-4">
<div class="field-stack"> <div class="field-stack">
<label class="field-label">选择归还使用者</label> <label class="field-label">归还使用者 *</label>
<select v-model="selectedBorrowRecordId" class="select hidden md:block" @change="handleRecordSelect"> <div class="space-y-3">
<option value="" disabled>请选择归还使用者</option> <input
<option v-model.trim="recordKeyword"
v-for="record in borrowRecordInfo.borrow_records" type="text"
:key="record.id" class="input"
:value="String(record.id)" placeholder="输入花名 / 姓名 / 工号 / 部门搜索"
> @keyup.enter="handleRecordKeywordSubmit"
{{ record.person_nickname }} · {{ record.person_name }} />
(借用时间:{{ formatDateTime(record.borrow_time) }}) <p class="text-xs text-text-tertiary">
</option> {{ borrowRecordInfo.borrow_records.length > 1
</select> ? '这台资产存在多条未归还记录,请搜索并点选正确使用者。'
<div class="mobile-card-list md:hidden"> : '这台资产当前只有 1 条未归还记录,系统已自动帮你选中。' }}
<article </p>
v-for="record in borrowRecordInfo.borrow_records" <div v-if="showRecordSearchResults" class="space-y-2">
:key="record.id" <div class="flex items-center justify-between gap-3 px-1">
class="mobile-card" <p class="text-xs text-text-tertiary">支持花名、姓名、工号、部门搜索。</p>
:class="{ 'border-accent-primary/30': selectedBorrowRecordId === String(record.id) }" <p class="text-xs text-text-tertiary whitespace-nowrap">
@click="selectBorrowRecord(record.id)" 共 {{ filteredReturnRecords.length }} 条
> </p>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">
{{ record.person_nickname }} · {{ record.person_name }}
</p>
<p class="mt-1 text-xs text-text-secondary">
{{ record.is_long_term ? '在职长期' : (record.expected_return_time ? formatDateTime(record.expected_return_time) : '未设置归还时间') }}
</p>
</div>
<span v-if="selectedBorrowRecordId === String(record.id)" class="badge badge-success">已选中</span>
</div> </div>
</article> <div v-if="filteredReturnRecords.length > 0" class="touch-option-list">
<button
v-for="record in filteredReturnRecords"
:key="record.id"
type="button"
class="touch-option"
:class="{ 'is-active': selectedBorrowRecordId === record.id }"
@click="selectBorrowRecord(record)"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">
{{ record.person_nickname }} · {{ record.person_name }}
</p>
<p class="mt-1 text-xs text-text-secondary">
{{ record.person?.department || '未填写部门' }}
<span v-if="record.person_identifier"> · {{ record.person_identifier }}</span>
<span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span>
<span> · 借用于 {{ formatDateTime(record.borrow_time) }}</span>
</p>
</div>
<span v-if="selectedBorrowRecordId === record.id" class="badge badge-success">已选中</span>
</div>
</button>
</div>
<p v-else class="text-center text-sm text-text-tertiary py-4">
当前没有匹配到使用者,请换个关键词再试。
</p>
</div>
</div> </div>
<p <p
v-if="borrowRecordInfo.borrow_records.length > 1" v-if="borrowRecordInfo.borrow_records.length > 1"
class="mt-2 text-xs text-text-tertiary" class="mt-2 text-xs text-text-tertiary"
> >
该资产有多位使用者,请选择正确归还人。 该资产有多位使用者,请确认选中的是本次实际归还人。
</p> </p>
</div> </div>
<div v-if="selectedBorrowRecord" class="panel field-grid">
<div class="field-stack">
<label class="field-label">使用者</label>
<p class="text-sm text-text-primary">
{{ selectedBorrowRecord.person_nickname }} · {{ selectedBorrowRecord.person_name }}
</p>
</div>
<div v-if="selectedBorrowRecord.person?.department" class="field-stack">
<label class="field-label">部门</label>
<p class="text-sm text-text-secondary">{{ selectedBorrowRecord.person?.department }}</p>
</div>
<div class="field-stack">
<label class="field-label">借用时间</label>
<p class="text-sm text-text-secondary">{{ formatDateTime(selectedBorrowRecord.borrow_time) }}</p>
</div>
<div class="field-stack">
<label class="field-label">预计归还时间</label>
<p class="text-sm text-text-secondary">
{{
selectedBorrowRecord.is_long_term
? '在职长期'
: selectedBorrowRecord.expected_return_time
? formatDateTime(selectedBorrowRecord.expected_return_time)
: '未设置'
}}
</p>
</div>
<div class="field-stack">
<label class="field-label">状态</label>
<p class="text-sm">
<span class="badge" :class="getStatusBadgeClass(selectedBorrowRecord.status)">
{{ selectedBorrowRecord.status }}
</span>
</p>
</div>
</div>
<!-- 配件快照对比 -->
<div v-if="childrenSnapshot.length > 0 || currentChildren.length > 0" class="mt-3 border-t border-border pt-3"> <div v-if="childrenSnapshot.length > 0 || currentChildren.length > 0" class="mt-3 border-t border-border pt-3">
<h4 class="mb-2 text-sm font-medium text-text-primary">配件对比</h4> <h4 class="mb-2 text-sm font-medium text-text-primary">配件对比</h4>
<!-- 借出时配件 -->
<div v-if="childrenSnapshot.length > 0" class="mb-3"> <div v-if="childrenSnapshot.length > 0" class="mb-3">
<p class="text-xs text-text-tertiary uppercase tracking-wider mb-2">借出时配件:</p> <p class="text-xs text-text-tertiary uppercase tracking-wider mb-2">借出时配件:</p>
<div class="space-y-1.5"> <div class="space-y-1.5">
@ -226,7 +257,6 @@
</div> </div>
</div> </div>
<!-- 当前配件 -->
<div v-if="currentChildren.length > 0"> <div v-if="currentChildren.length > 0">
<p class="text-xs text-text-tertiary uppercase tracking-wider mb-2">当前配件:</p> <p class="text-xs text-text-tertiary uppercase tracking-wider mb-2">当前配件:</p>
<div class="space-y-1.5"> <div class="space-y-1.5">
@ -249,7 +279,6 @@
</div> </div>
</div> </div>
<!-- 差异提示 -->
<div v-if="hasChildrenDiff" class="mt-3 rounded-md border border-status-warning/30 bg-status-warning/10 p-2.5"> <div v-if="hasChildrenDiff" class="mt-3 rounded-md border border-status-warning/30 bg-status-warning/10 p-2.5">
<p class="flex items-center gap-2 text-sm text-status-warning"> <p class="flex items-center gap-2 text-sm text-status-warning">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -272,7 +301,12 @@
@detected="handleCameraDetected" @detected="handleCameraDetected"
/> />
<!-- Return Info Form --> <BorrowAnomalyHandler
v-model="showBorrowAnomalyHandler"
:asset="borrowAnomalyTarget"
@handled="handleBorrowAnomalyHandled"
/>
<section v-if="borrowRecordInfo" class="card"> <section v-if="borrowRecordInfo" class="card">
<h3 class="section-title mb-4">归还信息</h3> <h3 class="section-title mb-4">归还信息</h3>
<div class="field-grid"> <div class="field-grid">
@ -297,7 +331,7 @@
<div class="mobile-sticky-actions md:static md:m-0 md:border-0 md:bg-transparent md:p-0 mt-4"> <div class="mobile-sticky-actions md:static md:m-0 md:border-0 md:bg-transparent md:p-0 mt-4">
<button <button
@click="handleSubmit" @click="handleSubmit"
:disabled="loading || !selectedBorrowRecord" :disabled="!canSubmit"
class="btn-primary w-full text-sm" class="btn-primary w-full text-sm"
> >
{{ loading ? '提交中...' : '确认入库' }} {{ loading ? '提交中...' : '确认入库' }}
@ -308,14 +342,28 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, nextTick, computed } from 'vue' import { ref, onMounted, nextTick, computed, watch } from 'vue'
import { getBorrowRecordByCode } from '@/api/borrow' import { useRoute } from 'vue-router'
import { scanReturn } from '@/api/borrow' import { getBorrowRecordByCode, scanReturn, searchReturnAssets } from '@/api/borrow'
import { getChildren } from '@/api/mount' import { getChildren } from '@/api/mount'
import { formatCurrency, formatDateTime, playBeepSound, playErrorSound } from '@/utils/format' import { formatCurrency, formatDateTime, playBeepSound, playErrorSound } from '@/utils/format'
import type { BorrowRecord, StorageLocation } from '@/types' import type { BorrowRecord, StorageLocation } from '@/types'
import type { ChildEquipment } from '@/api/mount' import type { ChildEquipment } from '@/api/mount'
import BorrowAnomalyHandler from '@/components/BorrowAnomalyHandler.vue'
import CameraScannerModal from '@/components/CameraScannerModal.vue' import CameraScannerModal from '@/components/CameraScannerModal.vue'
import {
type ManualReturnAssetCandidate,
filterManualReturnAssetCandidates,
findPreferredManualReturnAssetCandidate
} from './manualReturnSearch'
import {
buildManualReturnRecordDisplay,
filterManualReturnRecordCandidates,
findPreferredManualReturnRecordCandidate,
isManualReturnSelectedRecordKeyword,
shouldShowManualReturnRecordResults
} from './manualReturnRecordSearch'
import { canSubmitReturnScan } from './returnScanState'
interface ChildSnapshot { interface ChildSnapshot {
id: number id: number
@ -338,8 +386,8 @@ interface BorrowRecordInfo {
message: string message: string
} }
const scanCode = ref('')
const scanInputRef = ref<HTMLInputElement>() const scanInputRef = ref<HTMLInputElement>()
const route = useRoute()
const showCameraScanner = ref(false) const showCameraScanner = ref(false)
const showManualEntry = ref(window.matchMedia('(pointer: fine)').matches) const showManualEntry = ref(window.matchMedia('(pointer: fine)').matches)
const borrowRecordInfo = ref<BorrowRecordInfo | null>(null) const borrowRecordInfo = ref<BorrowRecordInfo | null>(null)
@ -348,29 +396,58 @@ const childrenSnapshot = ref<ChildSnapshot[]>([])
const currentChildren = ref<ChildEquipment[]>([]) const currentChildren = ref<ChildEquipment[]>([])
const pageNotice = ref<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null) const pageNotice = ref<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null)
const returnSuccessCount = ref(0) const returnSuccessCount = ref(0)
const showBorrowAnomalyHandler = ref(false)
const borrowAnomalyTarget = ref<{
equipmentId: number
equipmentCode: string
equipmentName: string
equipmentModel?: string | null
equipmentType?: string | null
storageLocationName?: string | null
message?: string | null
} | null>(null)
const returnInfo = ref({ const returnInfo = ref({
return_condition: '正常', return_condition: '正常',
notes: '' notes: ''
}) })
const selectedBorrowRecordId = ref<string>('') const manualAssetKeyword = ref('')
const manualAssetSearchResults = ref<ManualReturnAssetCandidate[]>([])
const manualAssetSearchLoading = ref(false)
const recordKeyword = ref('')
const selectedBorrowRecordId = ref<number | null>(null)
const appliedRouteQuickReturnKey = ref('')
let assetSearchTimer: number | null = null
let latestAssetRequestId = 0
const canSubmit = computed(() =>
canSubmitReturnScan({
hasBorrowRecordInfo: Boolean(borrowRecordInfo.value),
selectedBorrowRecordId: selectedBorrowRecordId.value,
loading: loading.value
})
)
const selectedBorrowRecord = computed(() => { const selectedBorrowRecord = computed(() => {
if ( if (!borrowRecordInfo.value || selectedBorrowRecordId.value === null) {
!borrowRecordInfo.value ||
!selectedBorrowRecordId.value ||
Number.isNaN(Number(selectedBorrowRecordId.value))
) {
return null return null
} }
return ( return (
borrowRecordInfo.value.borrow_records.find( borrowRecordInfo.value.borrow_records.find(
(record) => record.id === Number(selectedBorrowRecordId.value) (record) => record.id === selectedBorrowRecordId.value
) || null ) || null
) )
}) })
const filteredReturnRecords = computed(() =>
filterManualReturnRecordCandidates(borrowRecordInfo.value?.borrow_records || [], recordKeyword.value)
)
const showRecordSearchResults = computed(() =>
shouldShowManualReturnRecordResults(selectedBorrowRecord.value, recordKeyword.value)
)
const hasChildrenDiff = computed(() => { const hasChildrenDiff = computed(() => {
if (childrenSnapshot.value.length === 0 && currentChildren.value.length === 0) return false if (childrenSnapshot.value.length === 0 && currentChildren.value.length === 0) return false
const snapshotIds = new Set(childrenSnapshot.value.map(c => c.id)) const snapshotIds = new Set(childrenSnapshot.value.map(c => c.id))
@ -390,35 +467,18 @@ const isChildNew = (childId: number) => {
return !childrenSnapshot.value.some(c => c.id === childId) return !childrenSnapshot.value.some(c => c.id === childId)
} }
const getStatusBadgeClass = (status: string) => { const isBorrowRecordMissingAsset = (asset: ManualReturnAssetCandidate) =>
switch (status) { asset.search_state === 'borrow_record_missing' || asset.can_return === false
case '已借出':
return 'badge-warning'
case '已归还':
return 'badge-success'
case '逾期':
return 'badge-error'
default:
return 'badge-neutral'
}
}
const handleRecordSelect = () => { const getManualAssetBadgeClass = (asset: ManualReturnAssetCandidate) =>
// 解析选中记录的配件快照 isBorrowRecordMissingAsset(asset) ? 'badge-error' : 'badge-warning'
if (selectedBorrowRecord.value && (selectedBorrowRecord.value as any).children_snapshot) {
try {
childrenSnapshot.value = JSON.parse((selectedBorrowRecord.value as any).children_snapshot)
} catch {
childrenSnapshot.value = []
}
} else {
childrenSnapshot.value = []
}
}
const selectBorrowRecord = (recordId: number) => { const getManualAssetBadgeText = (asset: ManualReturnAssetCandidate) =>
selectedBorrowRecordId.value = String(recordId) isBorrowRecordMissingAsset(asset) ? '借用单缺失' : `待归还 ${asset.active_borrow_count}`
handleRecordSelect()
const selectBorrowRecord = (record: BorrowRecord) => {
selectedBorrowRecordId.value = record.id
recordKeyword.value = buildManualReturnRecordDisplay(record)
} }
const focusScanInput = () => { const focusScanInput = () => {
@ -436,37 +496,199 @@ const resetReturnForm = () => {
const resetCurrentFlow = () => { const resetCurrentFlow = () => {
borrowRecordInfo.value = null borrowRecordInfo.value = null
selectedBorrowRecordId.value = '' selectedBorrowRecordId.value = null
childrenSnapshot.value = [] childrenSnapshot.value = []
currentChildren.value = [] currentChildren.value = []
scanCode.value = '' recordKeyword.value = ''
resetReturnForm() resetReturnForm()
} }
const handleFocus = () => { const handleFocus = () => {
scanCode.value = '' pageNotice.value = null
} }
const handleCameraDetected = async (value: string) => { const handleCameraDetected = async (value: string) => {
scanCode.value = value.trim() await handleScan(value.trim())
await handleScan()
} }
const handleScan = async () => { const clearManualAssetSearch = () => {
if (!scanCode.value) { manualAssetKeyword.value = ''
manualAssetSearchResults.value = []
manualAssetSearchLoading.value = false
}
const openBorrowAnomalyHandler = (asset: ManualReturnAssetCandidate) => {
borrowAnomalyTarget.value = {
equipmentId: asset.equipment_id,
equipmentCode: asset.equipment_code,
equipmentName: asset.equipment_name,
equipmentModel: asset.equipment_model,
equipmentType: asset.equipment_type,
storageLocationName: asset.storageLocation?.name || null,
message: asset.state_message || null
}
showBorrowAnomalyHandler.value = true
}
const handleBorrowAnomalyHandled = async (payload: {
action: 'mark-returned' | 'register-borrow'
message: string
}) => {
resetCurrentFlow()
pageNotice.value = {
type: 'success',
message: payload.message
}
showBorrowAnomalyHandler.value = false
borrowAnomalyTarget.value = null
const keyword = manualAssetKeyword.value.trim()
if (keyword) {
await fetchManualAssetSearchResults(keyword)
}
}
const fetchManualAssetSearchResults = async (keyword: string) => {
const normalizedKeyword = keyword.trim()
if (!normalizedKeyword) {
manualAssetSearchResults.value = []
manualAssetSearchLoading.value = false
return return
} }
const requestId = ++latestAssetRequestId
manualAssetSearchLoading.value = true
try { try {
const response = await getBorrowRecordByCode(scanCode.value) const response = await searchReturnAssets({ keyword: normalizedKeyword })
if (requestId !== latestAssetRequestId) {
return
}
manualAssetSearchResults.value = filterManualReturnAssetCandidates(
response.data || [],
normalizedKeyword
)
} catch (error) {
if (requestId === latestAssetRequestId) {
manualAssetSearchResults.value = []
}
console.error('搜索待归还资产失败', error)
} finally {
if (requestId === latestAssetRequestId) {
manualAssetSearchLoading.value = false
}
}
}
const selectManualAsset = async (asset: ManualReturnAssetCandidate) => {
manualAssetKeyword.value = asset.equipment_code
if (isBorrowRecordMissingAsset(asset)) {
resetCurrentFlow()
if (asset.can_handle_borrow_anomaly) {
openBorrowAnomalyHandler(asset)
pageNotice.value = {
type: 'warning',
message: `${asset.equipment_code} 不能直接归还,已为你打开异常借出处理。`
}
return
}
pageNotice.value = {
type: 'warning',
message:
[asset.state_message, asset.borrow_anomaly_blocked_reason].filter(Boolean).join(';') ||
`${asset.equipment_code} 当前标记为已借出,但系统里没有对应借用记录,不能直接归还,请先核对。`
}
return
}
await handleScan(asset.equipment_code)
}
const handleManualAssetSubmit = async () => {
const keyword = manualAssetKeyword.value.trim()
if (!keyword) {
return
}
const preferredCandidate = findPreferredManualReturnAssetCandidate(
manualAssetSearchResults.value,
keyword
)
if (preferredCandidate) {
await selectManualAsset(preferredCandidate)
return
}
if (manualAssetSearchResults.value.length > 1) {
pageNotice.value = {
type: 'warning',
message: '匹配到多条待归还资产,请从下方结果中点选具体资产。'
}
return
}
await handleScan(keyword.toUpperCase())
}
const handleRecordKeywordSubmit = () => {
const preferredRecord = findPreferredManualReturnRecordCandidate(
filteredReturnRecords.value,
recordKeyword.value
)
if (preferredRecord) {
selectBorrowRecord(preferredRecord)
return
}
if (filteredReturnRecords.value.length > 1) {
pageNotice.value = {
type: 'warning',
message: '匹配到多条借用记录,请从下方结果中点选正确使用者。'
}
return
}
if (recordKeyword.value.trim()) {
pageNotice.value = {
type: 'warning',
message: '没有匹配到可归还的使用者,请换个关键词再试。'
}
}
}
const normalizeQueryString = (value: unknown) => {
const rawValue = Array.isArray(value) ? value[0] : value
return typeof rawValue === 'string' ? rawValue.trim() : ''
}
const normalizeQueryNumber = (value: unknown) => {
const text = normalizeQueryString(value)
if (!text) {
return null
}
const parsed = Number(text)
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
}
const handleScan = async (rawCode: string) => {
const normalizedCode = rawCode.trim().toUpperCase()
if (!normalizedCode) {
return false
}
try {
const response = await getBorrowRecordByCode(normalizedCode)
if (response.data) { if (response.data) {
borrowRecordInfo.value = response.data borrowRecordInfo.value = response.data
childrenSnapshot.value = [] childrenSnapshot.value = []
currentChildren.value = [] currentChildren.value = []
selectedBorrowRecordId.value = null
recordKeyword.value = ''
resetReturnForm() resetReturnForm()
playBeepSound() playBeepSound()
// 从借用记录中获取 equipment_id 来查询当前挂载的子资产
const equipmentId = response.data.borrow_records?.[0]?.equipment_id const equipmentId = response.data.borrow_records?.[0]?.equipment_id
if (equipmentId) { if (equipmentId) {
try { try {
@ -475,37 +697,72 @@ const handleScan = async () => {
currentChildren.value = childrenResponse.data.children currentChildren.value = childrenResponse.data.children
} }
} catch { } catch {
// 忽略错误 // 忽略查询当前子资产失败
} }
} }
if (response.data.borrow_records.length === 1) { if (response.data.borrow_records.length === 1) {
selectedBorrowRecordId.value = String(response.data.borrow_records[0].id) selectBorrowRecord(response.data.borrow_records[0])
handleRecordSelect()
pageNotice.value = { pageNotice.value = {
type: 'success', type: 'success',
message: `已读取 ${response.data.equipment_name} 的借用记录,可以直接确认归还。` message: `已读取 ${response.data.equipment_name} 的借用记录,可以直接确认归还。`
} }
} else { } else {
selectedBorrowRecordId.value = ''
pageNotice.value = { pageNotice.value = {
type: 'warning', type: 'warning',
message: `已读取 ${response.data.equipment_name},请先选择正确的归还使用者。` message: `已读取 ${response.data.equipment_name},请先选择正确的归还使用者。`
} }
} }
scanCode.value = '' clearManualAssetSearch()
focusScanInput() focusScanInput()
return true
} }
} catch (error: any) { } catch (error: any) {
playErrorSound() playErrorSound()
if (error?.status === 409) {
try {
const anomalySearch = await searchReturnAssets({ keyword: normalizedCode })
const preferredAsset = findPreferredManualReturnAssetCandidate(
filterManualReturnAssetCandidates(anomalySearch.data || [], normalizedCode),
normalizedCode
)
if (preferredAsset && isBorrowRecordMissingAsset(preferredAsset)) {
manualAssetKeyword.value = preferredAsset.equipment_code
if (preferredAsset.can_handle_borrow_anomaly) {
openBorrowAnomalyHandler(preferredAsset)
pageNotice.value = {
type: 'warning',
message: `${preferredAsset.equipment_code} 当前没有借用单,已为你打开异常借出处理。`
}
focusScanInput()
return false
}
pageNotice.value = {
type: 'warning',
message:
[preferredAsset.state_message, preferredAsset.borrow_anomaly_blocked_reason]
.filter(Boolean)
.join(';') || error.message || '扫码失败,请检查资产编码是否正确'
}
focusScanInput()
return false
}
} catch {
// 忽略异常搜索补偿失败,沿用默认报错提示
}
}
pageNotice.value = { pageNotice.value = {
type: 'error', type: 'error',
message: error.message || '扫码失败,请检查资产编码是否正确' message: error.message || '扫码失败,请检查资产编码是否正确'
} }
scanCode.value = ''
focusScanInput() focusScanInput()
} }
return false
} }
const handleSubmit = async () => { const handleSubmit = async () => {
@ -567,6 +824,7 @@ const handleSubmit = async () => {
message: `${borrowRecordInfo.value.equipment_name} 已完成归还,可以继续扫码下一件。` message: `${borrowRecordInfo.value.equipment_name} 已完成归还,可以继续扫码下一件。`
} }
resetCurrentFlow() resetCurrentFlow()
clearManualAssetSearch()
focusScanInput() focusScanInput()
} catch (error: any) { } catch (error: any) {
playErrorSound() playErrorSound()
@ -580,6 +838,99 @@ const handleSubmit = async () => {
} }
} }
watch(
() => manualAssetKeyword.value,
(value) => {
if (assetSearchTimer) {
window.clearTimeout(assetSearchTimer)
}
const keyword = value.trim()
if (!keyword) {
manualAssetSearchResults.value = []
manualAssetSearchLoading.value = false
return
}
assetSearchTimer = window.setTimeout(() => {
fetchManualAssetSearchResults(keyword)
}, 250)
}
)
watch(
() => recordKeyword.value,
(value) => {
const matchedSelectedRecord = selectedBorrowRecord.value
const shouldKeepCurrentSelection =
matchedSelectedRecord !== null &&
isManualReturnSelectedRecordKeyword(matchedSelectedRecord, value)
if (selectedBorrowRecordId.value !== null && !shouldKeepCurrentSelection) {
selectedBorrowRecordId.value = null
}
}
)
watch(
() => selectedBorrowRecord.value,
(record) => {
if (record && (record as any).children_snapshot) {
try {
childrenSnapshot.value = JSON.parse((record as any).children_snapshot)
} catch {
childrenSnapshot.value = []
}
return
}
childrenSnapshot.value = []
},
{ immediate: true }
)
watch(
() => [route.query.equipmentCode, route.query.borrowRecordId],
async ([equipmentCodeQuery, borrowRecordIdQuery]) => {
const equipmentCode = normalizeQueryString(equipmentCodeQuery)
const borrowRecordId = normalizeQueryNumber(borrowRecordIdQuery)
if (!equipmentCode) {
appliedRouteQuickReturnKey.value = ''
return
}
const quickReturnKey = `${equipmentCode}#${borrowRecordId || ''}`
if (appliedRouteQuickReturnKey.value === quickReturnKey) {
return
}
const loaded = await handleScan(equipmentCode)
if (!loaded || !borrowRecordInfo.value) {
return
}
if (borrowRecordId) {
const matchedRecord = borrowRecordInfo.value.borrow_records.find((record) => record.id === borrowRecordId)
if (matchedRecord) {
selectBorrowRecord(matchedRecord)
pageNotice.value = {
type: 'success',
message: `已为 ${borrowRecordInfo.value.equipment_name} 选中 ${matchedRecord.person_nickname} 的归还记录,可以直接确认入库。`
}
} else {
pageNotice.value = {
type: 'warning',
message: `已打开 ${borrowRecordInfo.value.equipment_name},但没有找到指定的借用记录,请手动选择归还使用者。`
}
}
}
appliedRouteQuickReturnKey.value = quickReturnKey
},
{ immediate: true }
)
onMounted(() => { onMounted(() => {
focusScanInput() focusScanInput()
}) })

View File

@ -0,0 +1,8 @@
export const hasSelectedBorrowPerson = (selectedPersonId: number | null) =>
selectedPersonId !== null && !Number.isNaN(selectedPersonId)
export const canSubmitBorrowScan = (options: {
hasEquipment: boolean
selectedPersonId: number | null
loading: boolean
}) => options.hasEquipment && hasSelectedBorrowPerson(options.selectedPersonId) && !options.loading

View File

@ -0,0 +1,148 @@
export interface ManualBorrowSearchPerson {
id: number
nickname: string
real_name: string
id_number?: string | null
department?: string | null
department_names?: string[]
phone?: string | null
status: string
}
const normalize = (value?: string | null) => (value || '').trim().toLowerCase()
export const buildManualBorrowPersonDisplay = (
person: Pick<ManualBorrowSearchPerson, 'nickname' | 'real_name'>
) => [person.nickname, person.real_name].filter(Boolean).join(' ').trim()
const includesKeyword = (value: string | undefined | null, keyword: string) =>
normalize(value).includes(keyword)
const buildDepartmentText = (
person: Pick<ManualBorrowSearchPerson, 'department' | 'department_names'>
) => {
const departmentNames = Array.isArray(person.department_names) ? person.department_names : []
return [person.department || '', ...departmentNames].filter(Boolean).join(' ')
}
const getMatchScore = (
person: Pick<ManualBorrowSearchPerson, 'nickname' | 'real_name' | 'id_number'>,
keyword: string
) => {
const nickname = normalize(person.nickname)
const realName = normalize(person.real_name)
const idNumber = normalize(person.id_number)
if (nickname === keyword) return 0
if (realName === keyword) return 1
if (idNumber === keyword) return 2
if (nickname.startsWith(keyword)) return 3
if (realName.startsWith(keyword)) return 4
if (idNumber.startsWith(keyword)) return 5
if (nickname.includes(keyword)) return 6
if (realName.includes(keyword)) return 7
return 8
}
export const filterManualBorrowPersons = <T extends ManualBorrowSearchPerson>(
persons: T[],
keyword: string
) => {
const normalizedKeyword = normalize(keyword)
const list = persons.filter((person) => {
if (person.status !== 'active') {
return false
}
if (!normalizedKeyword) {
return true
}
return (
includesKeyword(person.nickname, normalizedKeyword) ||
includesKeyword(person.real_name, normalizedKeyword) ||
includesKeyword(person.id_number, normalizedKeyword) ||
includesKeyword(buildDepartmentText(person), normalizedKeyword) ||
includesKeyword(person.phone, normalizedKeyword)
)
})
return list.sort((left, right) => {
if (!normalizedKeyword) {
return `${left.nickname}${left.real_name}`.localeCompare(
`${right.nickname}${right.real_name}`,
'zh-CN'
)
}
const scoreDiff = getMatchScore(left, normalizedKeyword) - getMatchScore(right, normalizedKeyword)
if (scoreDiff !== 0) {
return scoreDiff
}
return `${left.nickname}${left.real_name}`.localeCompare(
`${right.nickname}${right.real_name}`,
'zh-CN'
)
})
}
export const findPreferredManualBorrowPerson = <T extends ManualBorrowSearchPerson>(
persons: T[],
keyword: string
) => {
const normalizedKeyword = normalize(keyword)
if (!normalizedKeyword) {
return null
}
const exactMatch = persons.find((person) => {
return (
normalize(person.nickname) === normalizedKeyword ||
normalize(person.real_name) === normalizedKeyword ||
normalize(person.id_number) === normalizedKeyword
)
})
if (exactMatch) {
return exactMatch
}
return persons.length === 1 ? persons[0] : null
}
export const isManualBorrowSelectedPersonKeyword = <T extends ManualBorrowSearchPerson>(
person: T,
keyword: string
) => {
const normalizedKeyword = normalize(keyword)
if (!normalizedKeyword) {
return false
}
const displayName = normalize(buildManualBorrowPersonDisplay(person))
return [
normalize(person.nickname),
normalize(person.real_name),
normalize(person.id_number),
displayName
]
.filter(Boolean)
.some((value) => value === normalizedKeyword)
}
export const shouldShowManualBorrowPersonResults = <T extends ManualBorrowSearchPerson>(
person: T | null,
keyword: string
) => {
const normalizedKeyword = normalize(keyword)
if (!normalizedKeyword) {
return false
}
if (!person) {
return true
}
return !isManualBorrowSelectedPersonKeyword(person, keyword)
}

View File

@ -0,0 +1,103 @@
export interface ManualBorrowSearchCandidate {
equipment_code: string
equipment_name: string
equipment_model?: string | null
equipment_type?: string | null
status: string
remaining_quantity: number
parent_id?: number | null
storageLocation?: {
name?: string | null
} | null
}
const normalize = (value?: string | null) => (value || '').trim().toLowerCase()
const includesKeyword = (value: string | undefined | null, keyword: string) =>
normalize(value).includes(keyword)
const getManualBorrowSearchScore = (
equipment: ManualBorrowSearchCandidate,
keyword: string
) => {
const normalizedCode = normalize(equipment.equipment_code)
const normalizedName = normalize(equipment.equipment_name)
const normalizedModel = normalize(equipment.equipment_model)
const normalizedType = normalize(equipment.equipment_type)
if (normalizedCode === keyword) return 0
if (normalizedCode.startsWith(keyword)) return 1
if (normalizedName.startsWith(keyword)) return 2
if (normalizedModel.startsWith(keyword)) return 3
if (normalizedType.startsWith(keyword)) return 4
if (includesKeyword(equipment.equipment_code, keyword)) return 5
if (includesKeyword(equipment.equipment_name, keyword)) return 6
if (includesKeyword(equipment.equipment_model, keyword)) return 7
return 8
}
export const isManualBorrowCandidate = (equipment: ManualBorrowSearchCandidate) =>
equipment.parent_id == null &&
equipment.status === '可用' &&
Number(equipment.remaining_quantity) > 0
export const filterManualBorrowCandidates = (
list: ManualBorrowSearchCandidate[],
keyword: string
) => {
const normalizedKeyword = normalize(keyword)
const filtered = list.filter((equipment) => {
if (!isManualBorrowCandidate(equipment)) {
return false
}
if (!normalizedKeyword) {
return true
}
return (
includesKeyword(equipment.equipment_code, normalizedKeyword) ||
includesKeyword(equipment.equipment_name, normalizedKeyword) ||
includesKeyword(equipment.equipment_model, normalizedKeyword) ||
includesKeyword(equipment.equipment_type, normalizedKeyword) ||
includesKeyword(equipment.storageLocation?.name, normalizedKeyword)
)
})
return filtered.sort((left, right) => {
if (!normalizedKeyword) {
return left.equipment_code.localeCompare(right.equipment_code, 'zh-CN')
}
const scoreDiff =
getManualBorrowSearchScore(left, normalizedKeyword) -
getManualBorrowSearchScore(right, normalizedKeyword)
if (scoreDiff !== 0) {
return scoreDiff
}
return left.equipment_code.localeCompare(right.equipment_code, 'zh-CN')
})
}
export const findPreferredManualBorrowCandidate = (
list: ManualBorrowSearchCandidate[],
keyword: string
) => {
const normalizedKeyword = normalize(keyword)
if (!normalizedKeyword) {
return null
}
const exactCodeMatch = list.find(
(equipment) => normalize(equipment.equipment_code) === normalizedKeyword
)
if (exactCodeMatch) {
return exactCodeMatch
}
return list.length === 1 ? list[0] : null
}

View File

@ -0,0 +1,147 @@
export interface ManualReturnRecordPerson {
nickname?: string | null
real_name?: string | null
department?: string | null
department_names?: string[]
phone?: string | null
status?: string | null
}
export interface ManualReturnRecordCandidate {
id: number
person_name: string
person_nickname: string
person_identifier?: string | null
person?: ManualReturnRecordPerson | null
}
const normalize = (value?: string | null) => (value || '').trim().toLowerCase()
const includesKeyword = (value: string | undefined | null, keyword: string) =>
normalize(value).includes(keyword)
export const buildManualReturnRecordDisplay = (
record: Pick<ManualReturnRecordCandidate, 'person_nickname' | 'person_name'>
) => [record.person_nickname, record.person_name].filter(Boolean).join(' ').trim()
const buildDepartmentText = (record: ManualReturnRecordCandidate) => {
const person = record.person
const departmentNames = Array.isArray(person?.department_names) ? person?.department_names : []
return [person?.department || '', ...departmentNames].filter(Boolean).join(' ')
}
const getMatchScore = (record: ManualReturnRecordCandidate, keyword: string) => {
const nickname = normalize(record.person_nickname)
const realName = normalize(record.person_name)
const identifier = normalize(record.person_identifier)
if (nickname === keyword) return 0
if (realName === keyword) return 1
if (identifier === keyword) return 2
if (nickname.startsWith(keyword)) return 3
if (realName.startsWith(keyword)) return 4
if (identifier.startsWith(keyword)) return 5
if (nickname.includes(keyword)) return 6
if (realName.includes(keyword)) return 7
return 8
}
export const filterManualReturnRecordCandidates = <T extends ManualReturnRecordCandidate>(
records: T[],
keyword: string
) => {
const normalizedKeyword = normalize(keyword)
const filtered = records.filter((record) => {
if (!normalizedKeyword) {
return true
}
return (
includesKeyword(record.person_nickname, normalizedKeyword) ||
includesKeyword(record.person_name, normalizedKeyword) ||
includesKeyword(record.person_identifier, normalizedKeyword) ||
includesKeyword(buildDepartmentText(record), normalizedKeyword) ||
includesKeyword(record.person?.phone, normalizedKeyword)
)
})
return filtered.sort((left, right) => {
if (!normalizedKeyword) {
return buildManualReturnRecordDisplay(left).localeCompare(
buildManualReturnRecordDisplay(right),
'zh-CN'
)
}
const scoreDiff = getMatchScore(left, normalizedKeyword) - getMatchScore(right, normalizedKeyword)
if (scoreDiff !== 0) {
return scoreDiff
}
return buildManualReturnRecordDisplay(left).localeCompare(
buildManualReturnRecordDisplay(right),
'zh-CN'
)
})
}
export const findPreferredManualReturnRecordCandidate = <T extends ManualReturnRecordCandidate>(
records: T[],
keyword: string
) => {
const normalizedKeyword = normalize(keyword)
if (!normalizedKeyword) {
return null
}
const exactMatch = records.find((record) => {
return (
normalize(record.person_nickname) === normalizedKeyword ||
normalize(record.person_name) === normalizedKeyword ||
normalize(record.person_identifier) === normalizedKeyword
)
})
if (exactMatch) {
return exactMatch
}
return records.length === 1 ? records[0] : null
}
export const isManualReturnSelectedRecordKeyword = <T extends ManualReturnRecordCandidate>(
record: T,
keyword: string
) => {
const normalizedKeyword = normalize(keyword)
if (!normalizedKeyword) {
return false
}
const displayName = normalize(buildManualReturnRecordDisplay(record))
return [
normalize(record.person_nickname),
normalize(record.person_name),
normalize(record.person_identifier),
displayName
]
.filter(Boolean)
.some((value) => value === normalizedKeyword)
}
export const shouldShowManualReturnRecordResults = <T extends ManualReturnRecordCandidate>(
record: T | null,
keyword: string
) => {
const normalizedKeyword = normalize(keyword)
if (!normalizedKeyword) {
return false
}
if (!record) {
return true
}
return !isManualReturnSelectedRecordKeyword(record, keyword)
}

View File

@ -0,0 +1,112 @@
export interface ManualReturnAssetCandidate {
equipment_id: number
equipment_code: string
equipment_name: string
equipment_model?: string | null
equipment_type?: string | null
active_borrow_count: number
borrower_names?: string[]
borrower_identifiers?: string[]
borrower_departments?: string[]
can_return?: boolean
can_handle_borrow_anomaly?: boolean
borrow_anomaly_blocked_reason?: string | null
search_state?: 'returnable' | 'borrow_record_missing'
state_message?: string
storageLocation?: {
name?: string | null
} | null
}
const normalize = (value?: string | null) => (value || '').trim().toLowerCase()
const includesKeyword = (value: string | undefined | null, keyword: string) =>
normalize(value).includes(keyword)
const getManualReturnAssetSearchScore = (
equipment: ManualReturnAssetCandidate,
keyword: string
) => {
const normalizedCode = normalize(equipment.equipment_code)
const normalizedName = normalize(equipment.equipment_name)
const normalizedModel = normalize(equipment.equipment_model)
const normalizedType = normalize(equipment.equipment_type)
const normalizedLocation = normalize(equipment.storageLocation?.name)
if (normalizedCode === keyword) return 0
if (normalizedCode.startsWith(keyword)) return 1
if (normalizedName.startsWith(keyword)) return 2
if (normalizedModel.startsWith(keyword)) return 3
if (normalizedType.startsWith(keyword)) return 4
if (normalizedLocation.startsWith(keyword)) return 5
if (includesKeyword(equipment.equipment_code, keyword)) return 6
if (includesKeyword(equipment.equipment_name, keyword)) return 7
if (includesKeyword(equipment.equipment_model, keyword)) return 8
if (includesKeyword(equipment.equipment_type, keyword)) return 9
if (includesKeyword(equipment.storageLocation?.name, keyword)) return 10
return 11
}
export const isManualReturnAssetCandidate = (equipment: ManualReturnAssetCandidate) =>
Number(equipment.active_borrow_count) > 0 || equipment.search_state === 'borrow_record_missing'
export const filterManualReturnAssetCandidates = (
list: ManualReturnAssetCandidate[],
keyword: string
) => {
const normalizedKeyword = normalize(keyword)
const filtered = list.filter((equipment) => {
if (!isManualReturnAssetCandidate(equipment)) {
return false
}
if (!normalizedKeyword) {
return true
}
return (
includesKeyword(equipment.equipment_code, normalizedKeyword) ||
includesKeyword(equipment.equipment_name, normalizedKeyword) ||
includesKeyword(equipment.equipment_model, normalizedKeyword) ||
includesKeyword(equipment.equipment_type, normalizedKeyword) ||
includesKeyword(equipment.storageLocation?.name, normalizedKeyword)
)
})
return filtered.sort((left, right) => {
if (!normalizedKeyword) {
return left.equipment_code.localeCompare(right.equipment_code, 'zh-CN')
}
const scoreDiff =
getManualReturnAssetSearchScore(left, normalizedKeyword) -
getManualReturnAssetSearchScore(right, normalizedKeyword)
if (scoreDiff !== 0) {
return scoreDiff
}
return left.equipment_code.localeCompare(right.equipment_code, 'zh-CN')
})
}
export const findPreferredManualReturnAssetCandidate = (
list: ManualReturnAssetCandidate[],
keyword: string
) => {
const normalizedKeyword = normalize(keyword)
if (!normalizedKeyword) {
return null
}
const exactCodeMatch = list.find(
(equipment) => normalize(equipment.equipment_code) === normalizedKeyword
)
if (exactCodeMatch) {
return exactCodeMatch
}
return list.length === 1 ? list[0] : null
}

View File

@ -0,0 +1,11 @@
export const hasSelectedReturnRecord = (selectedBorrowRecordId: number | null) =>
selectedBorrowRecordId !== null && !Number.isNaN(selectedBorrowRecordId)
export const canSubmitReturnScan = (options: {
hasBorrowRecordInfo: boolean
selectedBorrowRecordId: number | null
loading: boolean
}) =>
options.hasBorrowRecordInfo &&
hasSelectedReturnRecord(options.selectedBorrowRecordId) &&
!options.loading

View File

@ -395,7 +395,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, reactive, watch } from 'vue' import { ref, computed, onMounted, reactive, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { createEquipment, updateEquipment, getEquipmentDetail, generateEquipmentCode, type EquipmentCreatePayload, type EquipmentUpdatePayload } from '@/api/equipment' import { createEquipment, updateEquipment, getEquipmentDetail, generateEquipmentCode, deleteEquipment, type EquipmentCreatePayload, type EquipmentUpdatePayload } from '@/api/equipment'
import { getStorageLocationOptions } from '@/api/storageLocation' import { getStorageLocationOptions } from '@/api/storageLocation'
import { getAssetTypeOptions } from '@/api/assetType' import { getAssetTypeOptions } from '@/api/assetType'
import { getDepartmentOptions } from '@/api/department' import { getDepartmentOptions } from '@/api/department'
@ -830,8 +830,20 @@ const handleSubmit = async () => {
const handleCancel = () => router.push('/equipment') const handleCancel = () => router.push('/equipment')
const handleDelete = async () => { const handleDelete = async () => {
if (!confirm('确定要删除这条资产记录吗?')) return if (!isEdit.value) return
// 删除逻辑已在原文件中,这里简化
const confirmed = confirm(
'仅当这条资产只是刚录错编码、还没进入借出、挂载、维修、报废或盘点流程时,才适合删除后重建。确定继续删除吗?'
)
if (!confirmed) return
try {
await deleteEquipment(Number(route.params.id))
alert('资产已删除,请按正确编码重新新建并打印标签。')
router.push('/equipment')
} catch (error: any) {
alert(error?.message || '删除失败')
}
} }
const handlePrintBarcode = () => { const handlePrintBarcode = () => {

View File

@ -102,6 +102,7 @@
<div v-if="selectedIds.length > 0" class="mt-3 flex flex-col gap-2 border-t border-border pt-3 lg:flex-row lg:items-center lg:justify-between"> <div v-if="selectedIds.length > 0" class="mt-3 flex flex-col gap-2 border-t border-border pt-3 lg:flex-row lg:items-center lg:justify-between">
<div class="text-sm text-text-secondary">已选 {{ selectedIds.length }} 项</div> <div class="text-sm text-text-secondary">已选 {{ selectedIds.length }} 项</div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button @click="handleBatchPrintBarcode" class="btn-secondary btn-secondary--highlight text-sm">批量打印标签</button>
<button @click="showBatchBorrowModal = true" class="btn-secondary text-sm">批量借出</button> <button @click="showBatchBorrowModal = true" class="btn-secondary text-sm">批量借出</button>
<button @click="showBatchReturnModal = true" class="btn-secondary text-sm">批量归还</button> <button @click="showBatchReturnModal = true" class="btn-secondary text-sm">批量归还</button>
<button @click="showBatchTransferModal = true" class="btn-secondary text-sm">批量转移</button> <button @click="showBatchTransferModal = true" class="btn-secondary text-sm">批量转移</button>
@ -158,6 +159,13 @@
<button v-if="canManageMountAsset(equipment)" @click="toggleExpand(equipment.id)" class="btn-secondary text-xs"> <button v-if="canManageMountAsset(equipment)" @click="toggleExpand(equipment.id)" class="btn-secondary text-xs">
{{ expandedIds.has(equipment.id) ? '收起配件' : hasMountedChildren(equipment) ? `管理配件(${equipment.children?.length || 0})` : '管理配件' }} {{ expandedIds.has(equipment.id) ? '收起配件' : hasMountedChildren(equipment) ? `管理配件(${equipment.children?.length || 0})` : '管理配件' }}
</button> </button>
<button
v-if="canHandleBorrowAnomaly(equipment)"
@click="openBorrowAnomalyHandler(equipment)"
class="btn-secondary text-xs"
>
异常处理
</button>
<button @click="handleEdit(equipment.id)" class="btn-secondary text-xs">编辑</button> <button @click="handleEdit(equipment.id)" class="btn-secondary text-xs">编辑</button>
<button @click="handlePrintBarcode(equipment.id)" class="btn-secondary btn-secondary--highlight text-xs">标签</button> <button @click="handlePrintBarcode(equipment.id)" class="btn-secondary btn-secondary--highlight text-xs">标签</button>
<button @click="handleDelete(equipment.id)" class="btn-secondary text-xs !text-status-error">删除</button> <button @click="handleDelete(equipment.id)" class="btn-secondary text-xs !text-status-error">删除</button>
@ -242,6 +250,13 @@
<button v-if="canManageMountAsset(equipment)" @click.stop="toggleExpand(equipment.id)" class="table-action table-action--primary"> <button v-if="canManageMountAsset(equipment)" @click.stop="toggleExpand(equipment.id)" class="table-action table-action--primary">
{{ expandedIds.has(equipment.id) ? '收起配件' : '管理配件' }} {{ expandedIds.has(equipment.id) ? '收起配件' : '管理配件' }}
</button> </button>
<button
v-if="canHandleBorrowAnomaly(equipment)"
@click.stop="openBorrowAnomalyHandler(equipment)"
class="table-action"
>
异常处理
</button>
<button @click.stop="handleEdit(equipment.id)" class="table-action table-action--primary">编辑</button> <button @click.stop="handleEdit(equipment.id)" class="table-action table-action--primary">编辑</button>
<button @click.stop="handlePrintBarcode(equipment.id)" class="table-action table-action--highlight">标签</button> <button @click.stop="handlePrintBarcode(equipment.id)" class="table-action table-action--highlight">标签</button>
<button @click.stop="handleDelete(equipment.id)" class="table-action table-action--danger">删除</button> <button @click.stop="handleDelete(equipment.id)" class="table-action table-action--danger">删除</button>
@ -284,13 +299,13 @@
<CameraScannerModal v-model="showAssetScanner" title="扫码找资产" manual-placeholder="请输入资产编码" @detected="handleAssetDetected" /> <CameraScannerModal v-model="showAssetScanner" title="扫码找资产" manual-placeholder="请输入资产编码" @detected="handleAssetDetected" />
<!-- 条码打印弹窗 --> <!-- 条码打印弹窗 -->
<div v-if="showBarcodePrint && selectedEquipmentId" class="sheet-backdrop"> <div v-if="showBarcodePrint && barcodePrintIds.length > 0" class="sheet-backdrop">
<div class="sheet-panel max-w-4xl"> <div class="sheet-panel max-w-4xl">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-semibold text-text-primary">打印条码</h3> <h3 class="text-xl font-semibold text-text-primary">{{ barcodePrintTitle }}</h3>
<button @click="showBarcodePrint = false" class="text-text-tertiary hover:text-text-primary text-xl">✕</button> <button @click="closeBarcodePrint" class="text-text-tertiary hover:text-text-primary text-xl">✕</button>
</div> </div>
<BarcodePrint :equipment-ids="[selectedEquipmentId]" /> <BarcodePrint :equipment-ids="barcodePrintIds" />
</div> </div>
</div> </div>
@ -366,6 +381,12 @@
</div> </div>
</div> </div>
</div> </div>
<BorrowAnomalyHandler
v-model="showBorrowAnomalyHandler"
:asset="borrowAnomalyTarget"
@handled="handleBorrowAnomalyHandled"
/>
</div> </div>
</template> </template>
@ -383,7 +404,9 @@ import { batchBorrow, batchReturn, batchTransfer } from '@/api/batch'
import { exportEquipment, downloadFile } from '@/api/export' import { exportEquipment, downloadFile } from '@/api/export'
import { batchImportEquipments } from '@/api/batchImport' import { batchImportEquipments } from '@/api/batchImport'
import { formatCurrencyNoFraction, formatNumber } from '@/utils/format' import { formatCurrencyNoFraction, formatNumber } from '@/utils/format'
import { canHandleBorrowAnomaly } from '@/utils/borrowAnomaly'
import BarcodePrint from '@/components/BarcodePrint.vue' import BarcodePrint from '@/components/BarcodePrint.vue'
import BorrowAnomalyHandler from '@/components/BorrowAnomalyHandler.vue'
import CameraScannerModal from '@/components/CameraScannerModal.vue' import CameraScannerModal from '@/components/CameraScannerModal.vue'
import MountHistory from '@/components/MountHistory.vue' import MountHistory from '@/components/MountHistory.vue'
import MountManager from '@/components/MountManager.vue' import MountManager from '@/components/MountManager.vue'
@ -427,18 +450,29 @@ const isAllSelected = computed(() => selectableIds.value.length > 0 && selectabl
const showBarcodePrint = ref(false) const showBarcodePrint = ref(false)
const showAssetScanner = ref(false) const showAssetScanner = ref(false)
const selectedEquipmentId = ref<number | null>(null) const barcodePrintIds = ref<number[]>([])
const showBatchBorrowModal = ref(false) const showBatchBorrowModal = ref(false)
const showBatchReturnModal = ref(false) const showBatchReturnModal = ref(false)
const showBatchTransferModal = ref(false) const showBatchTransferModal = ref(false)
const showMoreActions = ref(false) const showMoreActions = ref(false)
const showAdvancedFilters = ref(false) const showAdvancedFilters = ref(false)
const showBorrowAnomalyHandler = ref(false)
const borrowAnomalyTarget = ref<{
equipmentId: number
equipmentCode: string
equipmentName: string
equipmentModel?: string | null
equipmentType?: string | null
storageLocationName?: string | null
message?: string | null
} | null>(null)
const batchBorrowForm = ref({ person_id: '', is_long_term: false, expected_return_time: '' }) const batchBorrowForm = ref({ person_id: '', is_long_term: false, expected_return_time: '' })
const batchReturnForm = ref({ return_condition: '正常', notes: '' }) const batchReturnForm = ref({ return_condition: '正常', notes: '' })
const batchTransferForm = ref({ department_id: '' }) const batchTransferForm = ref({ department_id: '' })
const hasAdvancedFilters = computed(() => Boolean(filters.value.mountStatus || filters.value.storageLocationId || filters.value.departmentId)) const hasAdvancedFilters = computed(() => Boolean(filters.value.mountStatus || filters.value.storageLocationId || filters.value.departmentId))
const barcodePrintTitle = computed(() => barcodePrintIds.value.length > 1 ? `批量打印标签(${barcodePrintIds.value.length} 项)` : '打印条码')
const fetchData = async () => { const fetchData = async () => {
const params = { const params = {
@ -493,10 +527,44 @@ const handleDelete = async (id: number) => {
} }
const handlePrintBarcode = (id: number) => { const handlePrintBarcode = (id: number) => {
selectedEquipmentId.value = id barcodePrintIds.value = [id]
showBarcodePrint.value = true showBarcodePrint.value = true
} }
const handleBatchPrintBarcode = () => {
if (selectedIds.value.length === 0) {
return
}
barcodePrintIds.value = Array.from(new Set(selectedIds.value))
showBarcodePrint.value = true
}
const closeBarcodePrint = () => {
showBarcodePrint.value = false
barcodePrintIds.value = []
}
const openBorrowAnomalyHandler = (equipment: Equipment) => {
borrowAnomalyTarget.value = {
equipmentId: equipment.id,
equipmentCode: equipment.equipment_code,
equipmentName: equipment.equipment_name,
equipmentModel: equipment.equipment_model,
equipmentType: equipment.equipment_type,
storageLocationName: equipment.storageLocation?.name || null,
message: equipment.borrow_anomaly?.message || null
}
showBorrowAnomalyHandler.value = true
}
const handleBorrowAnomalyHandled = async (payload: { action: 'mark-returned' | 'register-borrow'; message: string }) => {
alert(payload.message)
showBorrowAnomalyHandler.value = false
borrowAnomalyTarget.value = null
await fetchData()
}
const handleAssetDetected = async (value: string) => { const handleAssetDetected = async (value: string) => {
try { try {
const response = await scanEquipment(value.trim()) const response = await scanEquipment(value.trim())
@ -646,7 +714,7 @@ function hasMountedChildren(equipment: Equipment) {
const getCurrentBorrowerLabel = (equipment: { status?: string; current_borrow?: Equipment['current_borrow'] | null }) => { const getCurrentBorrowerLabel = (equipment: { status?: string; current_borrow?: Equipment['current_borrow'] | null }) => {
const currentBorrow = equipment.current_borrow const currentBorrow = equipment.current_borrow
if (!currentBorrow) { if (!currentBorrow) {
return equipment.status === '已借出' ? '已借出,待核对' : '未借出' return equipment.status === '已借出' ? '借用单缺失' : '未借出'
} }
const nickname = currentBorrow.person?.nickname || currentBorrow.person_nickname const nickname = currentBorrow.person?.nickname || currentBorrow.person_nickname
@ -656,13 +724,22 @@ const getCurrentBorrowerLabel = (equipment: { status?: string; current_borrow?:
return `${nickname}(${realName})` return `${nickname}(${realName})`
} }
return nickname || realName || '待核对' return nickname || realName || currentBorrow.person_identifier || '使用者待补充'
} }
const getCurrentBorrowerMeta = (equipment: { current_borrow?: Equipment['current_borrow'] | null }) => { const getCurrentBorrowerMeta = (equipment: {
status?: string
current_borrow?: Equipment['current_borrow'] | null
}) => {
const currentBorrow = equipment.current_borrow const currentBorrow = equipment.current_borrow
if (!currentBorrow) {
return equipment.status === '已借出'
? '资产状态为已借出,但系统中没有对应借用记录'
: '暂无借用记录'
}
if (!currentBorrow?.borrow_time) { if (!currentBorrow?.borrow_time) {
return '暂无借用记录' return '借出时间待补充'
} }
const borrowDate = currentBorrow.borrow_time.includes('T') const borrowDate = currentBorrow.borrow_time.includes('T')

View File

@ -19,12 +19,12 @@
<div class="page-meta"> <div class="page-meta">
<p class="page-meta__label">当前范围</p> <p class="page-meta__label">当前范围</p>
<p class="page-meta__value text-base">{{ currentScopeLabel }}</p> <p class="page-meta__value text-base">{{ currentScopeLabel }}</p>
<p class="page-meta__subvalue">部门/类型留空表示全量</p> <p class="page-meta__subvalue">同维度多选并集,跨维度自动取交集</p>
</div> </div>
<div class="page-meta"> <div class="page-meta">
<p class="page-meta__label">预估参与人</p> <p class="page-meta__label">预估参与人</p>
<p class="page-meta__value">{{ formatNumber(preview?.participants_count || 0) }}</p> <p class="page-meta__value">{{ formatNumber(preview?.participants_count || 0) }}</p>
<p class="page-meta__subvalue">来自借用关系快照</p> <p class="page-meta__subvalue">仅纳入能明确挂到 1 位当前使用人的资产</p>
</div> </div>
<div class="page-meta"> <div class="page-meta">
<p class="page-meta__label">预估资产数</p> <p class="page-meta__label">预估资产数</p>
@ -47,27 +47,9 @@
v-model.trim="form.title" v-model.trim="form.title"
type="text" type="text"
class="input" class="input"
placeholder="例如:2026 年 3 月办公区资产盘点" placeholder="例如:2026 年 3 月在用资产盘点"
/> />
</div> </div>
<div class="field-stack">
<label class="field-label">业务部门</label>
<select v-model="form.department_id" class="select">
<option value="">全部业务部门</option>
<option v-for="department in departments" :key="department.id" :value="String(department.id)">
{{ department.name }}
</option>
</select>
</div>
<div class="field-stack">
<label class="field-label">资产类型</label>
<select v-model="form.asset_type_id" class="select">
<option value="">全部资产类型</option>
<option v-for="assetType in assetTypes" :key="assetType.id" :value="String(assetType.id)">
{{ assetType.name }}
</option>
</select>
</div>
<div class="field-stack lg:col-span-2"> <div class="field-stack lg:col-span-2">
<label class="field-label">任务说明</label> <label class="field-label">任务说明</label>
<textarea <textarea
@ -80,12 +62,176 @@
</div> </div>
</section> </section>
<section class="card space-y-6">
<div class="list-header">
<div>
<h3 class="section-title">盘点范围</h3>
<p class="mt-1 text-sm text-text-secondary">留空表示该维度不限;同一维度可以多选。</p>
</div>
</div>
<div class="grid gap-6 xl:grid-cols-3">
<section class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-semibold text-text-primary">业务部门</p>
<p class="mt-1 text-xs text-text-tertiary">{{ selectedDepartmentLabel }}</p>
</div>
<button
type="button"
class="btn-secondary text-xs"
:disabled="form.department_ids.length === 0"
@click="clearSelection('department_ids')"
>
清空
</button>
</div>
<div v-if="selectedDepartments.length > 0" class="flex flex-wrap gap-2">
<span
v-for="department in selectedDepartments"
:key="department.id"
class="badge badge-neutral"
>
{{ department.name }}
</span>
</div>
<div class="panel max-h-72 space-y-2 overflow-auto">
<label
v-for="department in departments"
:key="department.id"
class="flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-bg-secondary px-3 py-3 transition hover:border-border-hover hover:bg-bg-hover"
>
<input
:checked="form.department_ids.includes(department.id)"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-border bg-bg-elevated"
@change="toggleSelection('department_ids', department.id)"
/>
<span class="min-w-0 flex-1">
<span class="block text-sm text-text-primary">{{ department.name }}</span>
<span v-if="department.parent_name" class="mt-1 block text-xs text-text-tertiary">
上级:{{ department.parent_name }}
</span>
</span>
</label>
<p v-if="departments.length === 0" class="py-6 text-center text-sm text-text-tertiary">
暂无可选业务部门
</p>
</div>
</section>
<section class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-semibold text-text-primary">资产类型</p>
<p class="mt-1 text-xs text-text-tertiary">{{ selectedAssetTypeLabel }}</p>
</div>
<button
type="button"
class="btn-secondary text-xs"
:disabled="form.asset_type_ids.length === 0"
@click="clearSelection('asset_type_ids')"
>
清空
</button>
</div>
<div v-if="selectedAssetTypes.length > 0" class="flex flex-wrap gap-2">
<span
v-for="assetType in selectedAssetTypes"
:key="assetType.id"
class="badge badge-neutral"
>
{{ assetType.name }}
</span>
</div>
<div class="panel max-h-72 space-y-2 overflow-auto">
<label
v-for="assetType in assetTypes"
:key="assetType.id"
class="flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-bg-secondary px-3 py-3 transition hover:border-border-hover hover:bg-bg-hover"
>
<input
:checked="form.asset_type_ids.includes(assetType.id)"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-border bg-bg-elevated"
@change="toggleSelection('asset_type_ids', assetType.id)"
/>
<span class="min-w-0 flex-1">
<span class="block text-sm text-text-primary">{{ assetType.name }}</span>
<span v-if="assetType.short_name" class="mt-1 block text-xs text-text-tertiary">
编码缩写:{{ assetType.short_name }}
</span>
</span>
</label>
<p v-if="assetTypes.length === 0" class="py-6 text-center text-sm text-text-tertiary">
暂无可选资产类型
</p>
</div>
</section>
<section class="space-y-3">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-semibold text-text-primary">地区</p>
<p class="mt-1 text-xs text-text-tertiary">{{ selectedStorageLocationLabel }}</p>
</div>
<button
type="button"
class="btn-secondary text-xs"
:disabled="form.storage_location_ids.length === 0"
@click="clearSelection('storage_location_ids')"
>
清空
</button>
</div>
<div v-if="selectedStorageLocations.length > 0" class="flex flex-wrap gap-2">
<span
v-for="location in selectedStorageLocations"
:key="location.id"
class="badge badge-neutral"
>
{{ location.name }}
</span>
</div>
<div class="panel max-h-72 space-y-2 overflow-auto">
<label
v-for="location in storageLocations"
:key="location.id"
class="flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-bg-secondary px-3 py-3 transition hover:border-border-hover hover:bg-bg-hover"
>
<input
:checked="form.storage_location_ids.includes(location.id)"
type="checkbox"
class="mt-0.5 h-4 w-4 rounded border-border bg-bg-elevated"
@change="toggleSelection('storage_location_ids', location.id)"
/>
<span class="min-w-0 flex-1">
<span class="block text-sm text-text-primary">{{ location.name }}</span>
<span v-if="location.code" class="mt-1 block text-xs text-text-tertiary">
编码:{{ location.code }}
</span>
</span>
</label>
<p v-if="storageLocations.length === 0" class="py-6 text-center text-sm text-text-tertiary">
暂无可选地区
</p>
</div>
</section>
</div>
</section>
<section class="card"> <section class="card">
<div class="list-header"> <div class="list-header">
<h3 class="section-title">范围预览</h3> <h3 class="section-title">范围预览</h3>
<button type="button" class="btn-secondary text-sm" :disabled="previewLoading" @click="handlePreview"> <button type="button" class="btn-secondary text-sm" :disabled="previewLoading" @click="handlePreview">
{{ previewLoading ? '刷新中...' : '刷新' }} {{ previewLoading ? '刷新中...' : '刷新' }}
</button> </button>
</div> </div>
<div v-if="previewLoading" class="empty-state">正在根据当前范围生成预览...</div> <div v-if="previewLoading" class="empty-state">正在根据当前范围生成预览...</div>
@ -95,17 +241,17 @@
<div class="panel"> <div class="panel">
<p class="mobile-card__label">参与人</p> <p class="mobile-card__label">参与人</p>
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.participants_count) }}</p> <p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.participants_count) }}</p>
<p class="mt-1 text-xs text-text-secondary">自动生成名单</p> <p class="mt-1 text-xs text-text-secondary">只纳入有明确当前使用人的资产</p>
</div> </div>
<div class="panel"> <div class="panel">
<p class="mobile-card__label">待盘点资产</p> <p class="mobile-card__label">待盘点资产</p>
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.items_count) }}</p> <p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.items_count) }}</p>
<p class="mt-1 text-xs text-text-secondary">按当前快照生成</p> <p class="mt-1 text-xs text-text-secondary">按当前借用快照生成</p>
</div> </div>
<div class="panel"> <div class="panel">
<p class="mobile-card__label">无人归属</p> <p class="mobile-card__label">无人归属</p>
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.unassigned_count) }}</p> <p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.unassigned_count) }}</p>
<p class="mt-1 text-xs text-text-secondary">创建后尽快分配</p> <p class="mt-1 text-xs text-text-secondary">新规则下应为 0</p>
</div> </div>
</div> </div>
@ -185,13 +331,11 @@
<p class="text-sm font-medium text-text-primary">{{ item.equipment_name }}</p> <p class="text-sm font-medium text-text-primary">{{ item.equipment_name }}</p>
<p class="mt-1 text-xs font-mono text-text-tertiary">{{ item.equipment_code }}</p> <p class="mt-1 text-xs font-mono text-text-tertiary">{{ item.equipment_code }}</p>
<p class="mt-2 text-xs text-text-secondary"> <p class="mt-2 text-xs text-text-secondary">
{{ item.snapshot_person_nickname || '待分配人员' }} {{ item.snapshot_person_nickname || '未纳入' }}
<span v-if="item.snapshot_department_name"> · {{ item.snapshot_department_name }}</span> <span v-if="item.snapshot_department_name"> · {{ item.snapshot_department_name }}</span>
</p> </p>
</div> </div>
<span class="badge" :class="item.is_unassigned ? 'badge-warning' : 'badge-neutral'"> <span class="badge badge-neutral">已纳入</span>
{{ item.is_unassigned ? '待分配' : '已归属' }}
</span>
</div> </div>
</div> </div>
</div> </div>
@ -210,13 +354,16 @@ import { useRouter } from 'vue-router'
import { getAssetTypeOptions } from '@/api/assetType' import { getAssetTypeOptions } from '@/api/assetType'
import { getDepartmentOptions } from '@/api/department' import { getDepartmentOptions } from '@/api/department'
import { createInventoryTask, previewInventoryTask } from '@/api/inventory' import { createInventoryTask, previewInventoryTask } from '@/api/inventory'
import type { AssetType, Department, InventoryTaskPreview } from '@/types' import { getStorageLocationOptions } from '@/api/storageLocation'
import type { AssetType, Department, InventoryTaskPreview, StorageLocation } from '@/types'
import { formatNumber } from '@/utils/format' import { formatNumber } from '@/utils/format'
import { buildInventoryScopeLabel } from './inventoryScopeSummary'
const router = useRouter() const router = useRouter()
const departments = ref<Department[]>([]) const departments = ref<Department[]>([])
const assetTypes = ref<AssetType[]>([]) const assetTypes = ref<AssetType[]>([])
const storageLocations = ref<StorageLocation[]>([])
const preview = ref<InventoryTaskPreview | null>(null) const preview = ref<InventoryTaskPreview | null>(null)
const previewLoading = ref(false) const previewLoading = ref(false)
const creating = ref(false) const creating = ref(false)
@ -225,41 +372,78 @@ const previewDirty = ref(true)
const form = reactive({ const form = reactive({
title: '', title: '',
description: '', description: '',
department_id: '', department_ids: [] as number[],
asset_type_id: '' asset_type_ids: [] as number[],
storage_location_ids: [] as number[]
}) })
const selectedDepartment = computed( const selectedDepartments = computed(() =>
() => departments.value.find((department) => String(department.id) === form.department_id) || null departments.value.filter((department) => form.department_ids.includes(department.id))
) )
const selectedAssetType = computed( const selectedAssetTypes = computed(() =>
() => assetTypes.value.find((assetType) => String(assetType.id) === form.asset_type_id) || null assetTypes.value.filter((assetType) => form.asset_type_ids.includes(assetType.id))
)
const selectedStorageLocations = computed(() =>
storageLocations.value.filter((location) => form.storage_location_ids.includes(location.id))
)
const currentScopeLabel = computed(() =>
buildInventoryScopeLabel({
scope_departments: selectedDepartments.value,
scope_asset_types: selectedAssetTypes.value,
scope_storage_locations: selectedStorageLocations.value
})
)
const selectedDepartmentLabel = computed(() =>
form.department_ids.length > 0 ? `已选 ${form.department_ids.length} 个业务部门` : '留空表示全部业务部门'
)
const selectedAssetTypeLabel = computed(() =>
form.asset_type_ids.length > 0 ? `已选 ${form.asset_type_ids.length} 个资产类型` : '留空表示全部资产类型'
)
const selectedStorageLocationLabel = computed(() =>
form.storage_location_ids.length > 0 ? `已选 ${form.storage_location_ids.length} 个地区` : '留空表示全部地区'
) )
const currentScopeLabel = computed(() => {
const segments = []
if (selectedDepartment.value) {
segments.push(`部门:${selectedDepartment.value.name}`)
}
if (selectedAssetType.value) {
segments.push(`资产类型:${selectedAssetType.value.name}`)
}
return segments.length > 0 ? segments.join(' / ') : '全量资产范围'
})
const canCreate = computed(() => form.title.length > 0 && !creating.value) const canCreate = computed(() => form.title.length > 0 && !creating.value)
const buildPreviewPayload = () => ({ const buildPreviewPayload = () => ({
department_id: form.department_id ? Number(form.department_id) : null, department_ids: [...form.department_ids],
asset_type_id: form.asset_type_id ? Number(form.asset_type_id) : null asset_type_ids: [...form.asset_type_ids],
storage_location_ids: [...form.storage_location_ids]
}) })
const toggleSelection = (
key: 'department_ids' | 'asset_type_ids' | 'storage_location_ids',
id: number
) => {
const currentValues = form[key]
const nextValues = currentValues.includes(id)
? currentValues.filter((currentId) => currentId !== id)
: [...currentValues, id]
form[key] = nextValues.sort((left, right) => left - right) as never
}
const clearSelection = (key: 'department_ids' | 'asset_type_ids' | 'storage_location_ids') => {
form[key] = [] as never
}
const loadOptions = async () => { const loadOptions = async () => {
try { try {
const [departmentResponse, assetTypeResponse] = await Promise.all([ const [departmentResponse, assetTypeResponse, storageLocationResponse] = await Promise.all([
getDepartmentOptions(), getDepartmentOptions(),
getAssetTypeOptions() getAssetTypeOptions(),
getStorageLocationOptions()
]) ])
departments.value = departmentResponse.data.filter((department) => department.status === 'active')
assetTypes.value = assetTypeResponse.data.filter((assetType) => assetType.status === 'active') departments.value = (departmentResponse.data || [])
.filter((department) => department.status === 'active')
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
assetTypes.value = (assetTypeResponse.data || [])
.filter((assetType) => assetType.status === 'active')
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
storageLocations.value = (storageLocationResponse.data || [])
.filter((location) => location.status === 'active')
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
} catch (error: any) { } catch (error: any) {
alert(error?.message || '加载盘点范围选项失败') alert(error?.message || '加载盘点范围选项失败')
} }
@ -316,7 +500,7 @@ const handleSubmit = async () => {
} }
watch( watch(
() => [form.department_id, form.asset_type_id], () => [form.department_ids.join(','), form.asset_type_ids.join(','), form.storage_location_ids.join(',')],
() => { () => {
previewDirty.value = true previewDirty.value = true
} }

View File

@ -22,7 +22,15 @@
:disabled="closing" :disabled="closing"
@click="handleCloseTask" @click="handleCloseTask"
> >
{{ closing ? '关闭中...' : '关闭任务' }} {{ closing ? '结束中...' : '结束任务' }}
</button>
<button
type="button"
class="btn-secondary text-sm text-status-error"
:disabled="deleting"
@click="handleDeleteTask"
>
{{ deleting ? '删除中...' : '删除任务' }}
</button> </button>
</div> </div>
</div> </div>
@ -435,6 +443,7 @@ import { getPersonOptions } from '@/api/person'
import { import {
assignInventoryTaskUnassignedItem, assignInventoryTaskUnassignedItem,
closeInventoryTask, closeInventoryTask,
deleteInventoryTask,
getInventoryTaskDetail, getInventoryTaskDetail,
getInventoryTaskUnassignedItems getInventoryTaskUnassignedItems
} from '@/api/inventory' } from '@/api/inventory'
@ -446,6 +455,7 @@ import type {
Person Person
} from '@/types' } from '@/types'
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format' import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
import { buildInventoryScopeLabel } from './inventoryScopeSummary'
type ItemFilter = 'all' | 'pending' | 'checked' | 'exception' type ItemFilter = 'all' | 'pending' | 'checked' | 'exception'
@ -464,6 +474,7 @@ const unassignedItems = ref<InventoryTaskItem[]>([])
const personOptions = ref<Person[]>([]) const personOptions = ref<Person[]>([])
const loading = ref(false) const loading = ref(false)
const closing = ref(false) const closing = ref(false)
const deleting = ref(false)
const assigningItemId = ref<number | null>(null) const assigningItemId = ref<number | null>(null)
const itemFilter = ref<ItemFilter>('all') const itemFilter = ref<ItemFilter>('all')
const participantFilter = ref('all') const participantFilter = ref('all')
@ -474,17 +485,7 @@ const taskId = computed(() => Number(route.params.id))
const publicLink = computed(() => const publicLink = computed(() =>
task.value ? `${window.location.origin}/inventory/task/${task.value.public_token}` : '' task.value ? `${window.location.origin}/inventory/task/${task.value.public_token}` : ''
) )
const scopeLabel = computed(() => { const scopeLabel = computed(() => (task.value ? buildInventoryScopeLabel(task.value) : '-'))
if (!task.value) return '-'
const segments = []
if (task.value.scope_department_name) {
segments.push(`部门:${task.value.scope_department_name}`)
}
if (task.value.scope_asset_type_name) {
segments.push(`资产类型:${task.value.scope_asset_type_name}`)
}
return segments.length > 0 ? segments.join(' / ') : '全量资产范围'
})
const taskProgressText = computed(() => { const taskProgressText = computed(() => {
if (!task.value || task.value.items_count === 0) return '暂无资产' if (!task.value || task.value.items_count === 0) return '暂无资产'
return `${formatPercent((task.value.completed_count / task.value.items_count) * 100)}` return `${formatPercent((task.value.completed_count / task.value.items_count) * 100)}`
@ -688,21 +689,39 @@ const openPublicLink = () => {
const handleCloseTask = async () => { const handleCloseTask = async () => {
if (!task.value) return if (!task.value) return
const shouldClose = window.confirm('关闭后手机端将不能继续提交盘点结果,确定关闭吗?') const shouldClose = window.confirm('结束后手机端将不能继续提交盘点结果,确定结束吗?')
if (!shouldClose) return if (!shouldClose) return
closing.value = true closing.value = true
try { try {
await closeInventoryTask(task.value.id) await closeInventoryTask(task.value.id)
alert('盘点任务已关闭') alert('盘点任务已结束')
loadTask() loadTask()
} catch (error: any) { } catch (error: any) {
alert(error?.message || '关闭盘点任务失败') alert(error?.message || '结束盘点任务失败')
} finally { } finally {
closing.value = false closing.value = false
} }
} }
const handleDeleteTask = async () => {
if (!task.value) return
const shouldDelete = window.confirm('删除后该任务、参与人、资产明细和动作记录都会一起移除,确定删除吗?')
if (!shouldDelete) return
deleting.value = true
try {
await deleteInventoryTask(task.value.id)
alert('盘点任务已删除')
router.push('/inventory')
} catch (error: any) {
alert(error?.message || '删除盘点任务失败')
} finally {
deleting.value = false
}
}
const assignItem = async (itemId: number) => { const assignItem = async (itemId: number) => {
if (!task.value) return if (!task.value) return
const personId = Number(assignPersonIdMap[itemId]) const personId = Number(assignPersonIdMap[itemId])

View File

@ -21,7 +21,7 @@
<div class="page-meta"> <div class="page-meta">
<p class="page-meta__label">当前页进行中</p> <p class="page-meta__label">当前页进行中</p>
<p class="page-meta__value">{{ formatNumber(activeCount) }}</p> <p class="page-meta__value">{{ formatNumber(activeCount) }}</p>
<p class="page-meta__subvalue">已关闭 {{ formatNumber(closedCount) }} 个</p> <p class="page-meta__subvalue">已结束 {{ formatNumber(closedCount) }} 个</p>
</div> </div>
<div class="page-meta"> <div class="page-meta">
<p class="page-meta__label">当前页完成率</p> <p class="page-meta__label">当前页完成率</p>
@ -87,12 +87,26 @@
</div> </div>
</div> </div>
<div class="mt-4 flex flex-col gap-2"> <div class="mt-4 grid gap-2 sm:grid-cols-2">
<router-link :to="`/inventory/${task.id}`" class="btn-primary w-full text-sm">查看详情</router-link> <router-link :to="`/inventory/${task.id}`" class="btn-primary w-full text-sm">查看详情</router-link>
<div class="grid grid-cols-2 gap-2"> <button
<button type="button" class="btn-secondary text-xs" @click="copyTaskLink(task)">复制手机链接</button> type="button"
<button type="button" class="btn-secondary text-xs" @click="openTaskLink(task)">打开手机页</button> class="btn-secondary w-full text-sm"
</div> :disabled="task.status !== 'active' || actionLoadingId === task.id"
@click="handleCloseTask(task)"
>
{{ actionLoadingId === task.id && actionType === 'close' ? '结束中...' : '结束任务' }}
</button>
<button type="button" class="btn-secondary text-sm" @click="copyTaskLink(task)">复制手机链接</button>
<button type="button" class="btn-secondary text-sm" @click="openTaskLink(task)">打开手机页</button>
<button
type="button"
class="btn-secondary text-sm text-status-error sm:col-span-2"
:disabled="actionLoadingId === task.id && actionType === 'delete'"
@click="handleDeleteTask(task)"
>
{{ actionLoadingId === task.id && actionType === 'delete' ? '删除中...' : '删除任务' }}
</button>
</div> </div>
</article> </article>
@ -150,13 +164,29 @@
<td> <td>
<p class="text-sm text-text-primary">{{ task.created_by_name || `用户 ${task.created_by}` }}</p> <p class="text-sm text-text-primary">{{ task.created_by_name || `用户 ${task.created_by}` }}</p>
<p class="mt-1 text-xs text-text-secondary">{{ formatDateTime(task.created_at) }}</p> <p class="mt-1 text-xs text-text-secondary">{{ formatDateTime(task.created_at) }}</p>
<p v-if="task.closed_at" class="mt-1 text-xs text-text-tertiary">关闭于 {{ formatDateTime(task.closed_at) }}</p> <p v-if="task.closed_at" class="mt-1 text-xs text-text-tertiary">结束于 {{ formatDateTime(task.closed_at) }}</p>
</td> </td>
<td> <td>
<div class="table-action-group"> <div class="table-action-group">
<router-link :to="`/inventory/${task.id}`" class="table-action table-action--primary">详情</router-link> <router-link :to="`/inventory/${task.id}`" class="table-action table-action--primary">详情</router-link>
<button
type="button"
class="table-action"
:disabled="task.status !== 'active' || actionLoadingId === task.id"
@click="handleCloseTask(task)"
>
{{ actionLoadingId === task.id && actionType === 'close' ? '结束中' : '结束' }}
</button>
<button type="button" class="table-action" @click="copyTaskLink(task)">复制链接</button> <button type="button" class="table-action" @click="copyTaskLink(task)">复制链接</button>
<button type="button" class="table-action" @click="openTaskLink(task)">打开手机页</button> <button type="button" class="table-action" @click="openTaskLink(task)">手机页</button>
<button
type="button"
class="table-action text-status-error"
:disabled="actionLoadingId === task.id && actionType === 'delete'"
@click="handleDeleteTask(task)"
>
{{ actionLoadingId === task.id && actionType === 'delete' ? '删除中' : '删除' }}
</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -183,15 +213,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { getInventoryTaskList } from '@/api/inventory' import { closeInventoryTask, deleteInventoryTask, getInventoryTaskList } from '@/api/inventory'
import type { InventoryTaskListItem } from '@/types' import type { InventoryTaskListItem } from '@/types'
import { formatDateTime, formatNumber, formatPercent } from '@/utils/format' import { formatDateTime, formatNumber, formatPercent } from '@/utils/format'
import { buildInventoryScopeLabel } from './inventoryScopeSummary'
const tasks = ref<InventoryTaskListItem[]>([]) const tasks = ref<InventoryTaskListItem[]>([])
const loading = ref(false) const loading = ref(false)
const total = ref(0) const total = ref(0)
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const actionLoadingId = ref<number | null>(null)
const actionType = ref<'close' | 'delete' | null>(null)
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value))) const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const activeCount = computed(() => tasks.value.filter((task) => task.status === 'active').length) const activeCount = computed(() => tasks.value.filter((task) => task.status === 'active').length)
@ -226,18 +259,9 @@ const fetchTasks = async () => {
} }
} }
const resolveScopeLabel = (task: InventoryTaskListItem) => { const resolveScopeLabel = (task: InventoryTaskListItem) => buildInventoryScopeLabel(task)
const segments = []
if (task.scope_department_name) {
segments.push(`部门:${task.scope_department_name}`)
}
if (task.scope_asset_type_name) {
segments.push(`资产类型:${task.scope_asset_type_name}`)
}
return segments.length > 0 ? segments.join(' / ') : '全量资产范围'
}
const getTaskStatusText = (status: string) => (status === 'closed' ? '已关闭' : '进行中') const getTaskStatusText = (status: string) => (status === 'closed' ? '已结束' : '进行中')
const getTaskStatusClass = (status: string) => (status === 'closed' ? 'badge-neutral' : 'badge-success') const getTaskStatusClass = (status: string) => (status === 'closed' ? 'badge-neutral' : 'badge-success')
@ -263,6 +287,46 @@ const openTaskLink = (task: InventoryTaskListItem) => {
window.open(buildTaskPublicUrl(task), '_blank', 'noopener,noreferrer') window.open(buildTaskPublicUrl(task), '_blank', 'noopener,noreferrer')
} }
const handleCloseTask = async (task: InventoryTaskListItem) => {
if (task.status !== 'active') {
return
}
const shouldClose = window.confirm('结束后手机端将不能继续提交盘点结果,确定结束吗?')
if (!shouldClose) return
actionLoadingId.value = task.id
actionType.value = 'close'
try {
await closeInventoryTask(task.id)
alert('盘点任务已结束')
await fetchTasks()
} catch (error: any) {
alert(error?.message || '结束盘点任务失败')
} finally {
actionLoadingId.value = null
actionType.value = null
}
}
const handleDeleteTask = async (task: InventoryTaskListItem) => {
const shouldDelete = window.confirm('删除后该任务、参与人、资产明细和动作记录都会一起移除,确定删除吗?')
if (!shouldDelete) return
actionLoadingId.value = task.id
actionType.value = 'delete'
try {
await deleteInventoryTask(task.id)
alert('盘点任务已删除')
await fetchTasks()
} catch (error: any) {
alert(error?.message || '删除盘点任务失败')
} finally {
actionLoadingId.value = null
actionType.value = null
}
}
const handlePageSizeChange = () => { const handlePageSizeChange = () => {
currentPage.value = 1 currentPage.value = 1
fetchTasks() fetchTasks()

View File

@ -433,6 +433,7 @@ import type {
PublicInventoryTaskInfo PublicInventoryTaskInfo
} from '@/types' } from '@/types'
import { formatDateTime, formatNumber, formatPercent, playBeepSound, playErrorSound } from '@/utils/format' import { formatDateTime, formatNumber, formatPercent, playBeepSound, playErrorSound } from '@/utils/format'
import { buildInventoryScopeLabel } from './inventoryScopeSummary'
type ItemFilter = 'all' | 'pending' | 'checked' | 'exception' type ItemFilter = 'all' | 'pending' | 'checked' | 'exception'
type FeedbackType = 'success' | 'error' | 'info' type FeedbackType = 'success' | 'error' | 'info'
@ -492,14 +493,7 @@ const displayedTask = computed(() => {
const isTaskClosed = computed(() => displayedTask.value?.status === 'closed') const isTaskClosed = computed(() => displayedTask.value?.status === 'closed')
const scopeLabel = computed(() => { const scopeLabel = computed(() => {
if (!displayedTask.value) return '等待加载任务信息' if (!displayedTask.value) return '等待加载任务信息'
const segments = [] return buildInventoryScopeLabel(displayedTask.value)
if (displayedTask.value.scope_department_name) {
segments.push(`部门:${displayedTask.value.scope_department_name}`)
}
if (displayedTask.value.scope_asset_type_name) {
segments.push(`资产类型:${displayedTask.value.scope_asset_type_name}`)
}
return segments.length > 0 ? segments.join(' / ') : '全量资产范围'
}) })
const myProgressPercent = computed(() => { const myProgressPercent = computed(() => {
if (summary.value.total === 0) return 0 if (summary.value.total === 0) return 0

View File

@ -0,0 +1,60 @@
import type { InventoryScopeEntity } from '@/types'
type InventoryScopeLike = {
scope_departments?: InventoryScopeEntity[] | null
scope_asset_types?: InventoryScopeEntity[] | null
scope_storage_locations?: InventoryScopeEntity[] | null
scope_department_name?: string | null
scope_asset_type_name?: string | null
}
const normalizeScopeEntities = (
values?: InventoryScopeEntity[] | null,
fallbackName?: string | null
) => {
if (Array.isArray(values) && values.length > 0) {
return values
}
if (fallbackName) {
return [
{
id: 0,
name: fallbackName
}
]
}
return []
}
const formatScopeSegment = (label: string, values: InventoryScopeEntity[]) => {
if (values.length === 0) {
return ''
}
if (values.length === 1) {
return `${label}:${values[0].name}`
}
return `${label}:已选 ${values.length} 项`
}
export const buildInventoryScopeLabel = (scope: InventoryScopeLike) => {
const segments = [
formatScopeSegment(
'部门',
normalizeScopeEntities(scope.scope_departments, scope.scope_department_name)
),
formatScopeSegment(
'资产类型',
normalizeScopeEntities(scope.scope_asset_types, scope.scope_asset_type_name)
),
formatScopeSegment(
'地区',
normalizeScopeEntities(scope.scope_storage_locations)
)
].filter(Boolean)
return segments.length > 0 ? segments.join(' / ') : '全量资产范围'
}

View File

@ -105,22 +105,6 @@
{{ pair.label }} {{ pair.count }} {{ pair.label }} {{ pair.count }}
</span> </span>
</div> </div>
<div
v-if="item.metadata?.preview_changes?.length"
class="mt-4 panel"
>
<p class="text-sm font-medium text-text-primary">变化预览</p>
<div class="mt-3 space-y-3">
<div
v-for="change in item.metadata.preview_changes"
:key="`${item.id}-${change.title}`"
>
<p class="text-sm font-medium text-text-primary">{{ change.title }}</p>
<p class="mt-1 text-sm text-text-secondary">{{ change.detail }}</p>
</div>
</div>
</div>
</div> </div>
<div class="flex shrink-0 flex-wrap gap-3 lg:w-[220px] lg:flex-col"> <div class="flex shrink-0 flex-wrap gap-3 lg:w-[220px] lg:flex-col">
@ -163,31 +147,413 @@
</div> </div>
</div> </div>
</section> </section>
<div v-if="syncDetailOpen" class="sheet-backdrop" @click.self="closeSyncDetail">
<div class="sheet-panel max-w-5xl">
<div class="form-panel__header">
<div>
<h3 class="form-panel__title">同步详情</h3>
<p class="form-panel__desc">
这里只看这一条同步消息对应的完整变更明细,不再跳到设置页。
</p>
</div>
<button type="button" class="btn-secondary text-sm" @click="closeSyncDetail">
关闭
</button>
</div>
<div v-if="syncDetailLoading" class="panel mt-4 text-sm text-text-tertiary">
正在读取同步详情...
</div>
<div v-else-if="selectedSyncJob" class="mt-4 space-y-4">
<div class="grid gap-3 md:grid-cols-4">
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">执行状态</p>
<p class="mt-2 text-base font-semibold text-text-primary">
{{ getJobStatusLabel(selectedSyncJob.status) }}
</p>
</div>
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">执行方式</p>
<p class="mt-2 text-base font-semibold text-text-primary">
{{ getTriggerModeLabel(selectedSyncJob.trigger_mode) }}
</p>
</div>
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">执行人</p>
<p class="mt-2 text-base font-semibold text-text-primary">
{{ selectedSyncJob.operator || 'system-sync' }}
</p>
</div>
<div class="panel">
<p class="text-xs uppercase tracking-wider text-text-tertiary">变化数量</p>
<p class="mt-2 text-base font-semibold text-text-primary">
{{ selectedSyncJob.changes_count }}
</p>
</div>
</div>
<div class="panel">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm font-medium text-text-primary">同步摘要</p>
<span class="text-xs text-text-tertiary">{{ formatDateTime(selectedSyncJob.created_at) }}</span>
</div>
<p class="mt-3 whitespace-pre-line text-sm leading-6 text-text-secondary">
{{ selectedSyncJob.summary || '本次同步没有额外摘要。' }}
</p>
<p v-if="selectedSyncJob.error_message" class="mt-3 text-sm text-status-error">
错误信息:{{ selectedSyncJob.error_message }}
</p>
</div>
<div v-if="summaryPairs(selectedSyncJob).length > 0" class="flex flex-wrap gap-2">
<span
v-for="pair in summaryPairs(selectedSyncJob)"
:key="`detail-${pair.label}`"
class="badge badge-neutral"
>
{{ pair.label }} {{ pair.count }}
</span>
</div>
<div class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_380px]">
<div class="order-2 xl:order-1">
<div class="panel">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-medium text-text-primary">完整变更</p>
<span class="badge badge-neutral">{{ selectedSyncJob.changes.length }} 条</span>
</div>
<div v-if="selectedSyncJob.changes.length > 0" class="mt-4 space-y-3">
<div
v-for="change in selectedSyncJob.changes"
:key="change.id"
class="rounded-lg border border-border bg-bg-secondary px-4 py-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-text-primary">{{ getChangeDisplayTitle(change) }}</p>
<p class="mt-2 whitespace-pre-line text-sm leading-6 text-text-secondary">
{{ change.detail || '无详细说明' }}
</p>
<div
v-if="getChangeAssets(change).length > 0"
class="mt-4 rounded-xl border border-border bg-bg-primary/40 p-3"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-xs uppercase tracking-wider text-text-tertiary">待处理资产</p>
<span class="text-xs text-text-tertiary">点资产即可在当前弹窗内直接入库</span>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button
v-for="asset in getChangeAssets(change)"
:key="`${change.id}-${buildQuickReturnAssetKey(asset)}`"
type="button"
class="inline-flex min-h-[44px] items-center gap-2 rounded-full border px-3 py-2 text-left transition"
:class="isQuickReturnAssetProcessed(asset)
? 'cursor-not-allowed border-status-success/30 bg-status-success/10 text-status-success'
: 'border-border bg-bg-elevated hover:border-accent-primary/40 hover:bg-bg-hover'"
:disabled="isQuickReturnAssetProcessed(asset)"
@click="openQuickReturn(asset)"
>
<span class="text-xs font-semibold" :class="isQuickReturnAssetProcessed(asset) ? 'text-status-success' : 'text-text-primary'">
{{ asset.equipmentCode }}
</span>
<span class="text-xs" :class="isQuickReturnAssetProcessed(asset) ? 'text-status-success/90' : 'text-text-secondary'">
{{ asset.equipmentName }}
</span>
<span class="badge" :class="isQuickReturnAssetProcessed(asset) ? 'badge-success' : 'badge-neutral'">
{{ isQuickReturnAssetProcessed(asset) ? '已入库' : '快捷入库' }}
</span>
</button>
</div>
</div>
</div>
<span class="badge badge-neutral">
{{ CHANGE_TYPE_LABELS[change.change_type] || change.change_type }}
</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-text-tertiary">
<span class="badge" :class="getChangeProcessingBadgeClass(change.processing_status)">
{{ getChangeProcessingLabel(change.processing_status) }}
</span>
<span>处理人:{{ change.processed_by_name || change.processed_by || '未处理' }}</span>
<span>处理时间:{{ change.processed_at ? formatDateTime(change.processed_at) : '未处理' }}</span>
</div>
</div>
</div>
<div v-else class="empty-state mt-4">
本次同步没有变化明细。
</div>
</div>
</div>
<aside class="order-1 xl:order-2">
<div class="panel space-y-4 xl:sticky xl:top-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">快捷入库</p>
<p class="mt-1 text-xs leading-5 text-text-tertiary">
在当前消息里直接处理,不会跳走,处理完可以继续点下一件。
</p>
</div>
<button
v-if="quickReturnAsset"
type="button"
class="btn-secondary text-sm"
@click="closeQuickReturnPanel"
>
收起
</button>
</div>
<div
v-if="quickReturnNotice"
class="rounded-lg border px-4 py-3 text-sm"
:class="getQuickReturnNoticeClass(quickReturnNotice.type)"
>
{{ quickReturnNotice.message }}
</div>
<div v-if="quickReturnLoading" class="rounded-lg border border-border bg-bg-primary/40 px-4 py-6 text-sm text-text-tertiary">
正在读取这件资产的借用记录...
</div>
<div v-else-if="quickReturnBorrowInfo && quickReturnAsset" class="space-y-4">
<div class="rounded-lg border border-border bg-bg-primary/40 p-4">
<p class="text-xs uppercase tracking-wider text-text-tertiary">当前资产</p>
<p class="mt-2 text-base font-semibold text-text-primary">{{ quickReturnBorrowInfo.equipment_name }}</p>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-text-secondary">
<span class="badge badge-neutral">{{ quickReturnBorrowInfo.equipment_code }}</span>
<span v-if="quickReturnBorrowInfo.storageLocation?.name">地址:{{ quickReturnBorrowInfo.storageLocation.name }}</span>
<span>未归还记录 {{ quickReturnBorrowInfo.borrow_records.length }} 条</span>
</div>
</div>
<div class="field-stack">
<label class="field-label">归还使用者</label>
<input
v-model.trim="quickReturnRecordKeyword"
type="text"
class="input"
placeholder="输入花名 / 姓名 / 工号 / 部门搜索"
@keyup.enter="handleQuickReturnRecordKeywordSubmit"
/>
<p class="text-xs text-text-tertiary">
{{ quickReturnBorrowInfo.borrow_records.length > 1
? '这件资产有多条未归还记录,请点选正确使用者。'
: '当前只有 1 条未归还记录,系统已自动帮你选中。' }}
</p>
<div v-if="showQuickReturnRecordResults" class="space-y-2">
<button
v-for="record in quickReturnFilteredRecords"
:key="record.id"
type="button"
class="touch-option w-full text-left"
:class="{ 'is-active': quickReturnSelectedBorrowRecordId === record.id }"
@click="selectQuickReturnBorrowRecord(record)"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-medium text-text-primary">
{{ record.person_nickname }} · {{ record.person_name }}
</p>
<p class="mt-1 text-xs text-text-secondary">
{{ record.person?.department || '未填写部门' }}
<span v-if="record.person_identifier"> · {{ record.person_identifier }}</span>
<span v-if="record.person?.status === 'inactive'" class="text-status-warning"> · 已离职/停用</span>
</p>
</div>
<span v-if="quickReturnSelectedBorrowRecordId === record.id" class="badge badge-success">已选中</span>
</div>
</button>
<p v-if="quickReturnFilteredRecords.length === 0" class="py-3 text-center text-sm text-text-tertiary">
没有匹配到使用者,请换个关键词再试。
</p>
</div>
</div>
<div class="field-stack">
<label class="field-label">归还状态</label>
<select v-model="quickReturnForm.return_condition" class="select">
<option value="正常">正常</option>
<option value="损坏">损坏</option>
<option value="丢失">丢失</option>
</select>
</div>
<div class="field-stack">
<label class="field-label">部门归属处理</label>
<div class="grid gap-2 sm:grid-cols-3">
<button
type="button"
class="btn-secondary text-sm"
:class="quickReturnForm.department_action === 'keep' ? '!border-accent-primary !text-text-primary' : ''"
@click="quickReturnForm.department_action = 'keep'"
>
保留当前归属
</button>
<button
type="button"
class="btn-secondary text-sm"
:class="quickReturnForm.department_action === 'clear' ? '!border-accent-primary !text-text-primary' : ''"
@click="quickReturnForm.department_action = 'clear'"
>
清空归属
</button>
<button
type="button"
class="btn-secondary text-sm"
:class="quickReturnForm.department_action === 'set' ? '!border-accent-primary !text-text-primary' : ''"
@click="quickReturnForm.department_action = 'set'"
>
改为指定部门
</button>
</div>
<select
v-if="quickReturnForm.department_action === 'set'"
v-model="quickReturnForm.department_id"
class="select"
>
<option :value="null">请选择业务部门</option>
<option
v-for="department in activeDepartments"
:key="department.id"
:value="department.id"
>
{{ department.name }}
</option>
</select>
<p class="text-xs text-text-tertiary">
有些资产归还后需要保留部门归属,有些则应清空,这里可以直接一起处理。
</p>
</div>
<div class="field-stack">
<label class="field-label">备注</label>
<textarea
v-model="quickReturnForm.notes"
class="input"
rows="3"
placeholder="可选,补充这次归还情况"
></textarea>
</div>
<div class="flex flex-col gap-2 sm:flex-row">
<button
type="button"
class="btn-primary flex-1 text-sm"
:disabled="!canSubmitQuickReturn"
@click="submitQuickReturn"
>
{{ quickReturnSubmitting ? '处理中...' : '确认入库' }}
</button>
<button
type="button"
class="btn-secondary text-sm"
:disabled="quickReturnSubmitting"
@click="closeQuickReturnPanel"
>
暂不处理
</button>
</div>
</div>
<div v-else class="rounded-lg border border-dashed border-border bg-bg-primary/20 px-4 py-6 text-sm leading-6 text-text-tertiary">
点左侧的资产胶囊后,这里会直接出现该资产的快捷入库面板。处理完当前这件后,你可以继续点下一件,不需要离开站内消息。
</div>
</div>
</aside>
</div>
</div>
<div v-else class="panel mt-4 text-sm text-text-tertiary">
暂时没有读取到这条消息对应的同步详情。
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { formatDateTime } from '@/utils/format' import { formatDateTime } from '@/utils/format'
import type { NotificationItem } from '@/types' import type {
BorrowRecord,
Department,
HrSyncBorrowedAssetPreview,
HrSyncChangeItem,
HrSyncChangeProcessingStatus,
HrSyncJobDetail,
HrSyncPersonAfterSnapshot,
NotificationItem,
StorageLocation
} from '@/types'
import { getBorrowRecordByCode, scanReturn } from '@/api/borrow'
import { getDepartmentOptions } from '@/api/department'
import { import {
getNotificationList, getNotificationList,
markAllNotificationsAsRead, markAllNotificationsAsRead,
markNotificationAsRead markNotificationAsRead
} from '@/api/notifications' } from '@/api/notifications'
import { getHrSyncJobDetail } from '@/api/sync'
import {
buildManualReturnRecordDisplay,
filterManualReturnRecordCandidates,
findPreferredManualReturnRecordCandidate,
isManualReturnSelectedRecordKeyword,
shouldShowManualReturnRecordResults
} from '@/views/borrow/manualReturnRecordSearch'
import { canSubmitReturnScan } from '@/views/borrow/returnScanState'
import { useNotificationStore } from '@/stores/notifications' import { useNotificationStore } from '@/stores/notifications'
import {
buildNotificationQuickReturnAssetKey,
findNotificationQuickReturnRecord
} from './notificationQuickReturn'
const router = useRouter()
const notificationStore = useNotificationStore() const notificationStore = useNotificationStore()
const notifications = ref<NotificationItem[]>([]) const notifications = ref<NotificationItem[]>([])
const activeDepartments = ref<Department[]>([])
const total = ref(0) const total = ref(0)
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = 12 const pageSize = 12
const statusFilter = ref<'all' | 'unread'>('all') const statusFilter = ref<'all' | 'unread'>('all')
const loading = ref(false) const loading = ref(false)
const markAllLoading = ref(false) const markAllLoading = ref(false)
const syncDetailOpen = ref(false)
const syncDetailLoading = ref(false)
const selectedSyncJob = ref<HrSyncJobDetail | null>(null)
const quickReturnAsset = ref<HrSyncBorrowedAssetPreview | null>(null)
const quickReturnLoading = ref(false)
const quickReturnSubmitting = ref(false)
const quickReturnBorrowInfo = ref<{
equipment_code: string
equipment_name: string
unit_price: number
equipment_value: number
storage_location_id?: number | null
storageLocation?: StorageLocation | null
borrow_records: BorrowRecord[]
can_return: boolean
message: string
} | null>(null)
const quickReturnRecordKeyword = ref('')
const quickReturnSelectedBorrowRecordId = ref<number | null>(null)
const quickReturnForm = ref({
return_condition: '正常',
department_action: 'keep' as 'keep' | 'clear' | 'set',
department_id: null as number | null,
notes: ''
})
const quickReturnNotice = ref<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null)
const processedQuickReturnAssetKeys = ref<string[]>([])
const CHANGE_TYPE_LABELS: Record<string, string> = { const CHANGE_TYPE_LABELS: Record<string, string> = {
person_created: '新人入职', person_created: '新人入职',
@ -204,7 +570,48 @@ const CHANGE_TYPE_LABELS: Record<string, string> = {
equipment_department_unresolved: '资产部门待处理' equipment_department_unresolved: '资产部门待处理'
} }
const CHANGE_PROCESSING_LABELS: Record<HrSyncChangeProcessingStatus, string> = {
pending: '待处理',
processed: '已处理',
ignored: '已忽略'
}
const CHANGE_PROCESSING_BADGES: Record<HrSyncChangeProcessingStatus, string> = {
pending: 'badge-warning',
processed: 'badge-success',
ignored: 'badge-neutral'
}
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))) const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)))
const quickReturnSelectedRecord = computed(() => {
if (!quickReturnBorrowInfo.value || quickReturnSelectedBorrowRecordId.value === null) {
return null
}
return (
quickReturnBorrowInfo.value.borrow_records.find(
(record) => record.id === quickReturnSelectedBorrowRecordId.value
) || null
)
})
const quickReturnFilteredRecords = computed(() =>
filterManualReturnRecordCandidates(quickReturnBorrowInfo.value?.borrow_records || [], quickReturnRecordKeyword.value)
)
const showQuickReturnRecordResults = computed(() =>
shouldShowManualReturnRecordResults(quickReturnSelectedRecord.value, quickReturnRecordKeyword.value)
)
const canSubmitQuickReturn = computed(() =>
canSubmitReturnScan({
hasBorrowRecordInfo: Boolean(quickReturnBorrowInfo.value),
selectedBorrowRecordId: quickReturnSelectedBorrowRecordId.value,
loading:
quickReturnSubmitting.value ||
(quickReturnForm.value.department_action === 'set' && !quickReturnForm.value.department_id)
})
)
const getLevelLabel = (level?: string) => { const getLevelLabel = (level?: string) => {
if (level === 'error') return '异常' if (level === 'error') return '异常'
@ -218,8 +625,122 @@ const getLevelBadgeClass = (level?: string) => {
return 'badge-neutral' return 'badge-neutral'
} }
const summaryPairs = (item: NotificationItem) => const normalizeChangeProcessingStatus = (
Object.entries(item.metadata?.counts || {}) status?: HrSyncChangeProcessingStatus | string | null
): HrSyncChangeProcessingStatus => {
if (status === 'processed' || status === 'ignored') {
return status
}
return 'pending'
}
const getJobStatusLabel = (status?: string) => (status === 'failed' ? '失败' : '成功')
const getTriggerModeLabel = (mode?: string) => (mode === 'scheduler' ? '定时执行' : '手动执行')
const getChangeProcessingLabel = (status?: HrSyncChangeProcessingStatus | string | null) =>
CHANGE_PROCESSING_LABELS[normalizeChangeProcessingStatus(status)]
const getChangeProcessingBadgeClass = (status?: HrSyncChangeProcessingStatus | string | null) =>
CHANGE_PROCESSING_BADGES[normalizeChangeProcessingStatus(status)]
const getQuickReturnNoticeClass = (type: 'success' | 'warning' | 'error') => {
if (type === 'success') {
return 'border-status-success/30 bg-status-success/10 text-status-success'
}
if (type === 'warning') {
return 'border-status-warning/30 bg-status-warning/10 text-status-warning'
}
return 'border-status-error/30 bg-status-error/10 text-status-error'
}
const buildQuickReturnAssetKey = (asset: HrSyncBorrowedAssetPreview) =>
buildNotificationQuickReturnAssetKey(asset)
const isQuickReturnAssetProcessed = (asset: HrSyncBorrowedAssetPreview) =>
processedQuickReturnAssetKeys.value.includes(buildQuickReturnAssetKey(asset))
const getChangeDisplayTitle = (change: HrSyncChangeItem) => {
const personChangeTitleMap: Record<string, string> = {
person_created: '新人入职',
person_deactivated: '人员离职/停用',
person_reactivated: '人员重新启用',
person_main_department_changed: '主部门变动',
person_departments_changed: '关联部门变动',
person_position_changed: '岗位变动'
}
const titlePrefix = personChangeTitleMap[change.change_type]
if (!titlePrefix) {
return change.title
}
const afterSnapshot = change.after_snapshot as HrSyncPersonAfterSnapshot | null
const fallbackSnapshot = (change.before_snapshot || {}) as HrSyncPersonAfterSnapshot
const displayName =
afterSnapshot?.displayName ||
afterSnapshot?.nickname ||
afterSnapshot?.realName ||
fallbackSnapshot?.displayName ||
fallbackSnapshot?.nickname ||
fallbackSnapshot?.realName
return displayName ? `${titlePrefix}:${displayName}` : change.title
}
const extractDetailAssets = (detail?: string | null): HrSyncBorrowedAssetPreview[] => {
if (!detail) {
return []
}
const matched = detail.match(/名下(?:待处理)?资产:([^。]+)/)
if (!matched?.[1]) {
return []
}
return matched[1]
.split('、')
.map((rawItem) => {
const normalizedItem = rawItem.replace(/\s*等\s*\d+\s*项$/, '').trim()
const separatorIndex = normalizedItem.indexOf('/')
if (separatorIndex <= 0) {
return null
}
const equipmentCode = normalizedItem.slice(0, separatorIndex).trim()
const equipmentName = normalizedItem.slice(separatorIndex + 1).trim()
if (!equipmentCode || !equipmentName) {
return null
}
return {
equipmentCode,
equipmentName
}
})
.filter((asset): asset is HrSyncBorrowedAssetPreview => Boolean(asset))
}
const getChangeAssets = (change: HrSyncChangeItem): HrSyncBorrowedAssetPreview[] => {
const afterSnapshot = change.after_snapshot as HrSyncPersonAfterSnapshot | null
if (afterSnapshot && Array.isArray(afterSnapshot.assets) && afterSnapshot.assets.length > 0) {
return afterSnapshot.assets.filter(
(asset) => Boolean(asset?.equipmentCode) && Boolean(asset?.equipmentName)
)
}
return extractDetailAssets(change.detail)
}
const summaryPairs = (item: {
counts?: Record<string, number>
metadata?: {
counts?: Record<string, number>
} | null
}) =>
Object.entries(item.counts || item.metadata?.counts || {})
.filter(([, count]) => Number(count) > 0) .filter(([, count]) => Number(count) > 0)
.map(([key, count]) => ({ .map(([key, count]) => ({
label: CHANGE_TYPE_LABELS[key] || key, label: CHANGE_TYPE_LABELS[key] || key,
@ -279,23 +800,228 @@ const handleMarkAllRead = async () => {
} }
} }
const loadDepartmentOptions = async () => {
try {
const response = await getDepartmentOptions()
activeDepartments.value = (response.data || [])
.filter((department) => department.status === 'active')
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
} catch (error) {
console.error('获取业务部门失败', error)
}
}
const closeSyncDetail = () => {
syncDetailOpen.value = false
selectedSyncJob.value = null
quickReturnNotice.value = null
processedQuickReturnAssetKeys.value = []
closeQuickReturnPanel()
}
const resetQuickReturnForm = () => {
quickReturnRecordKeyword.value = ''
quickReturnSelectedBorrowRecordId.value = null
quickReturnForm.value = {
return_condition: '正常',
department_action: 'keep',
department_id: null,
notes: ''
}
}
const closeQuickReturnPanel = () => {
quickReturnAsset.value = null
quickReturnBorrowInfo.value = null
quickReturnLoading.value = false
quickReturnSubmitting.value = false
resetQuickReturnForm()
}
const selectQuickReturnBorrowRecord = (record: BorrowRecord) => {
quickReturnSelectedBorrowRecordId.value = record.id
quickReturnRecordKeyword.value = buildManualReturnRecordDisplay(record)
}
const markQuickReturnAssetProcessed = (asset: HrSyncBorrowedAssetPreview) => {
const assetKey = buildQuickReturnAssetKey(asset)
if (!processedQuickReturnAssetKeys.value.includes(assetKey)) {
processedQuickReturnAssetKeys.value = [...processedQuickReturnAssetKeys.value, assetKey]
}
}
const handleQuickReturnRecordKeywordSubmit = () => {
const preferredRecord = findPreferredManualReturnRecordCandidate(
quickReturnFilteredRecords.value,
quickReturnRecordKeyword.value
)
if (preferredRecord) {
selectQuickReturnBorrowRecord(preferredRecord)
return
}
if (quickReturnFilteredRecords.value.length > 1) {
quickReturnNotice.value = {
type: 'warning',
message: '匹配到多条借用记录,请先点选正确的使用者。'
}
return
}
if (quickReturnRecordKeyword.value.trim()) {
quickReturnNotice.value = {
type: 'warning',
message: '没有匹配到使用者,请换个关键词再试。'
}
}
}
const openQuickReturn = async (asset: HrSyncBorrowedAssetPreview) => {
if (isQuickReturnAssetProcessed(asset)) {
return
}
quickReturnAsset.value = asset
quickReturnBorrowInfo.value = null
quickReturnNotice.value = null
resetQuickReturnForm()
quickReturnLoading.value = true
try {
const response = await getBorrowRecordByCode(asset.equipmentCode)
const borrowInfo = response.data || null
quickReturnBorrowInfo.value = borrowInfo
if (!borrowInfo) {
quickReturnNotice.value = {
type: 'error',
message: '没有读取到这件资产的借用记录。'
}
return
}
const preferredRecord = findNotificationQuickReturnRecord(borrowInfo.borrow_records, asset)
if (preferredRecord) {
selectQuickReturnBorrowRecord(preferredRecord)
quickReturnNotice.value = {
type: 'success',
message: `已定位到 ${borrowInfo.equipment_name} 的归还记录,可以直接确认入库。`
}
return
}
quickReturnNotice.value = {
type: 'warning',
message: `已读取 ${borrowInfo.equipment_name},请先选择正确的归还使用者。`
}
} catch (error: any) {
quickReturnBorrowInfo.value = null
quickReturnNotice.value = {
type: 'error',
message: error?.message || '读取借用记录失败,请稍后再试。'
}
} finally {
quickReturnLoading.value = false
}
}
const submitQuickReturn = async () => {
if (!quickReturnBorrowInfo.value || !quickReturnAsset.value) {
return
}
const record = quickReturnSelectedRecord.value
if (!record) {
quickReturnNotice.value = {
type: 'warning',
message: '请先选中正确的归还使用者。'
}
return
}
if (quickReturnForm.value.department_action === 'set' && !quickReturnForm.value.department_id) {
quickReturnNotice.value = {
type: 'warning',
message: '请选择归还后要归属到的业务部门。'
}
return
}
const payload = {
equipment_code: quickReturnBorrowInfo.value.equipment_code,
return_condition: quickReturnForm.value.return_condition,
notes: quickReturnForm.value.notes || undefined,
person_id: record.person_id,
borrow_record_id: record.id,
department_action: quickReturnForm.value.department_action,
department_id:
quickReturnForm.value.department_action === 'set'
? quickReturnForm.value.department_id
: undefined
}
quickReturnSubmitting.value = true
try {
const response = await scanReturn(payload)
if (response.data?.need_confirm) {
const confirmed = window.confirm('系统检测到配件与借出时不一致,确认继续入库吗?')
if (!confirmed) {
quickReturnSubmitting.value = false
return
}
await scanReturn({
...payload,
confirm_children_diff: true
})
}
markQuickReturnAssetProcessed(quickReturnAsset.value)
quickReturnNotice.value = {
type: 'success',
message: `${quickReturnBorrowInfo.value.equipment_name} 已为 ${record.person_nickname} 完成入库。你现在可以继续处理下一件。`
}
closeQuickReturnPanel()
} catch (error: any) {
quickReturnNotice.value = {
type: 'error',
message: error?.message || '快捷入库失败,请稍后再试。'
}
} finally {
quickReturnSubmitting.value = false
}
}
const openSyncDetail = async (syncJobId: number) => {
syncDetailOpen.value = true
syncDetailLoading.value = true
quickReturnNotice.value = null
processedQuickReturnAssetKeys.value = []
closeQuickReturnPanel()
try {
const response = await getHrSyncJobDetail(syncJobId)
selectedSyncJob.value = response.data || null
} catch (error: any) {
selectedSyncJob.value = null
alert(error?.message || '读取同步详情失败')
} finally {
syncDetailLoading.value = false
}
}
const openAction = async (item: NotificationItem) => { const openAction = async (item: NotificationItem) => {
if (!item.is_read) { if (!item.is_read) {
await handleMarkRead(item) await handleMarkRead(item)
} }
if (item.related_sync_job_id) { if (item.related_sync_job_id) {
await router.push({ await openSyncDetail(item.related_sync_job_id)
path: '/settings',
query: {
tab: 'sync',
syncJobId: String(item.related_sync_job_id)
}
})
return return
} }
await router.push(item.action_url || '/settings?tab=sync') window.location.href = item.action_url || '/settings?tab=sync'
} }
const handlePrevPage = () => { const handlePrevPage = () => {
@ -310,7 +1036,31 @@ const handleNextPage = () => {
fetchNotifications() fetchNotifications()
} }
watch(
() => quickReturnRecordKeyword.value,
(value) => {
const matchedSelectedRecord = quickReturnSelectedRecord.value
const shouldKeepCurrentSelection =
matchedSelectedRecord !== null &&
isManualReturnSelectedRecordKeyword(matchedSelectedRecord, value)
if (quickReturnSelectedBorrowRecordId.value !== null && !shouldKeepCurrentSelection) {
quickReturnSelectedBorrowRecordId.value = null
}
}
)
watch(
() => quickReturnForm.value.department_action,
(value) => {
if (value !== 'set') {
quickReturnForm.value.department_id = null
}
}
)
onMounted(async () => { onMounted(async () => {
await loadDepartmentOptions()
await notificationStore.fetchUnreadCount() await notificationStore.fetchUnreadCount()
await fetchNotifications() await fetchNotifications()
}) })

View File

@ -0,0 +1,22 @@
import type { BorrowRecord, HrSyncBorrowedAssetPreview } from '../../types'
export const buildNotificationQuickReturnAssetKey = (
asset: Pick<HrSyncBorrowedAssetPreview, 'borrowRecordId' | 'equipmentCode'>
) => {
if (asset.borrowRecordId) {
return `borrow-record:${asset.borrowRecordId}`
}
return `equipment:${(asset.equipmentCode || '').trim().toUpperCase()}`
}
export const findNotificationQuickReturnRecord = (
borrowRecords: BorrowRecord[],
asset: Pick<HrSyncBorrowedAssetPreview, 'borrowRecordId'>
) => {
if (asset.borrowRecordId) {
return borrowRecords.find((record) => record.id === asset.borrowRecordId) || null
}
return borrowRecords.length === 1 ? borrowRecords[0] : null
}

View File

@ -563,11 +563,12 @@ const statusBadgeClass = computed(() => {
const scheduleSummary = computed(() => { const scheduleSummary = computed(() => {
const scheduleTimes = syncStatus.value?.scheduleTimes || [] const scheduleTimes = syncStatus.value?.scheduleTimes || []
const scheduleTimezone = syncStatus.value?.scheduleTimezone || 'Asia/Shanghai'
if (!syncStatus.value?.enabled) { if (!syncStatus.value?.enabled) {
return '建议先确认源库连通后,再决定是否开启自动同步。' return '建议先确认源库连通后,再决定是否开启自动同步。'
} }
return scheduleTimes.length > 0 return scheduleTimes.length > 0
? `计划时间:${scheduleTimes.join('、')}` ? `计划时间(${scheduleTimezone}):${scheduleTimes.join('、')}`
: '已启用定时计划,但还没有读取到具体时间。' : '已启用定时计划,但还没有读取到具体时间。'
}) })
@ -729,7 +730,8 @@ const fetchStatus = async () => {
enabled: false, enabled: false,
complete: false, complete: false,
missingFields: [], missingFields: [],
scheduleTimes: [] scheduleTimes: [],
scheduleTimezone: 'Asia/Shanghai'
} }
previewErrorMessage.value = resolveErrorMessage(error, '读取同步状态失败') previewErrorMessage.value = resolveErrorMessage(error, '读取同步状态失败')
} finally { } finally {

View File

@ -150,7 +150,7 @@
:disabled="busyTerminalId === terminal.id" :disabled="busyTerminalId === terminal.id"
@click="handleToggleStatus(terminal)" @click="handleToggleStatus(terminal)"
> >
{{ busyTerminalId === terminal.id ? '处理中...' : terminal.status === 'disabled' ? '重新启用' : '停用' }} {{ busyTerminalId === terminal.id ? '处理中...' : getToggleStatusText(terminal) }}
</button> </button>
<button <button
v-if="!terminal.is_default" v-if="!terminal.is_default"
@ -483,6 +483,14 @@ const getDispatchNotice = () => {
return '默认终端离线时,系统只排队不改派,等它重新上线后继续自动打印。' return '默认终端离线时,系统只排队不改派,等它重新上线后继续自动打印。'
} }
const getToggleStatusText = (terminal: PrintTerminal) => {
if (terminal.status === 'disabled') {
return '重新启用'
}
return '停用'
}
const loadTerminals = async () => { const loadTerminals = async () => {
loading.value = true loading.value = true
try { try {
@ -570,7 +578,9 @@ const handleToggleStatus = async (terminal: PrintTerminal) => {
const nextStatus = terminal.status === 'disabled' ? 'offline' : 'disabled' const nextStatus = terminal.status === 'disabled' ? 'offline' : 'disabled'
const confirmText = const confirmText =
nextStatus === 'disabled' nextStatus === 'disabled'
? '停用后新的远程打印任务不会再发给这台电脑,确定继续吗?' ? terminal.is_default
? '这台电脑当前是默认打印终端。停用后远程标签会暂停自动派发,直到你重新启用它或改设其他默认电脑。确定继续吗?'
: '停用后新的远程打印任务不会再发给这台电脑,确定继续吗?'
: '重新启用后,这台电脑会在助手下一次心跳后恢复接单状态,确定继续吗?' : '重新启用后,这台电脑会在助手下一次心跳后恢复接单状态,确定继续吗?'
if (!window.confirm(confirmText)) { if (!window.confirm(confirmText)) {

View File

@ -6,9 +6,9 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms> <UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Version>0.2.0</Version> <Version>0.2.5</Version>
<AssemblyVersion>0.2.0.0</AssemblyVersion> <AssemblyVersion>0.2.5.0</AssemblyVersion>
<FileVersion>0.2.0.0</FileVersion> <FileVersion>0.2.5.0</FileVersion>
<Company>常州简尚家具有限公司</Company> <Company>常州简尚家具有限公司</Company>
<Product>固定资产打印助手</Product> <Product>固定资产打印助手</Product>
<DebugType>none</DebugType> <DebugType>none</DebugType>

View File

@ -59,6 +59,28 @@ internal sealed class LabelPrintService
}); });
} }
public Task<int> PrintRenderedLabelImagesAsync(
string printerName,
IReadOnlyList<AssetLabelRenderedImage> renderedLabels)
{
return Task.Run(() =>
{
var pages = renderedLabels.Select(RenderRenderedLabelImage).ToList();
try
{
PrintBitmaps(printerName, pages, "固定资产标签打印");
return pages.Count;
}
finally
{
foreach (var page in pages)
{
page.Dispose();
}
}
});
}
public Task PrintTestLabelAsync(string printerName) public Task PrintTestLabelAsync(string printerName)
{ {
return Task.Run(() => return Task.Run(() =>
@ -79,6 +101,30 @@ internal sealed class LabelPrintService
}); });
} }
private static Bitmap RenderRenderedLabelImage(AssetLabelRenderedImage renderedLabel)
{
const string dataUrlPrefix = "data:image/png;base64,";
if (string.IsNullOrWhiteSpace(renderedLabel.ImageDataUrl) ||
!renderedLabel.ImageDataUrl.StartsWith(dataUrlPrefix, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("当前打印任务缺少有效的 PNG 成品标签图。");
}
try
{
var base64 = renderedLabel.ImageDataUrl[dataUrlPrefix.Length..];
var imageBytes = Convert.FromBase64String(base64);
using var stream = new MemoryStream(imageBytes);
using var image = Image.FromStream(stream, true, true);
return new Bitmap(image);
}
catch (Exception ex)
{
throw new InvalidOperationException($"成品标签图解析失败:{ex.Message}", ex);
}
}
private static Bitmap RenderAssetLabel(AssetLabelItem item) private static Bitmap RenderAssetLabel(AssetLabelItem item)
{ {
var bitmap = new Bitmap(CanvasWidth, CanvasHeight); var bitmap = new Bitmap(CanvasWidth, CanvasHeight);
@ -303,7 +349,13 @@ internal sealed class LabelPrintService
graphics.PageUnit = GraphicsUnit.Display; graphics.PageUnit = GraphicsUnit.Display;
graphics.Clear(Color.White); graphics.Clear(Color.White);
graphics.DrawImage(page, eventArgs.PageBounds); graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
graphics.CompositingQuality = CompositingQuality.HighQuality;
graphics.SmoothingMode = SmoothingMode.HighQuality;
var destinationRect = CalculateContainRectangle(eventArgs.PageBounds, page.Size);
graphics.DrawImage(page, destinationRect);
pageIndex += 1; pageIndex += 1;
eventArgs.HasMorePages = pageIndex < pages.Count; eventArgs.HasMorePages = pageIndex < pages.Count;
}; };
@ -311,6 +363,25 @@ internal sealed class LabelPrintService
document.Print(); document.Print();
} }
private static Rectangle CalculateContainRectangle(Rectangle bounds, Size imageSize)
{
if (imageSize.Width <= 0 || imageSize.Height <= 0)
{
return bounds;
}
var widthScale = bounds.Width / (double)imageSize.Width;
var heightScale = bounds.Height / (double)imageSize.Height;
var scale = Math.Min(widthScale, heightScale);
var drawWidth = Math.Max(1, (int)Math.Round(imageSize.Width * scale));
var drawHeight = Math.Max(1, (int)Math.Round(imageSize.Height * scale));
var offsetX = bounds.X + (bounds.Width - drawWidth) / 2;
var offsetY = bounds.Y + (bounds.Height - drawHeight) / 2;
return new Rectangle(offsetX, offsetY, drawWidth, drawHeight);
}
private static string NormalizeText(string? value, string fallback) private static string NormalizeText(string? value, string fallback)
{ {
var normalized = string.IsNullOrWhiteSpace(value) var normalized = string.IsNullOrWhiteSpace(value)

File diff suppressed because it is too large Load Diff

View File

@ -102,10 +102,22 @@ internal sealed class AssetLabelItem
public DepartmentInfo? Department { get; set; } public DepartmentInfo? Department { get; set; }
} }
internal sealed class AssetLabelRenderedImage
{
[JsonPropertyName("equipment_id")]
public int EquipmentId { get; set; }
[JsonPropertyName("image_data_url")]
public string ImageDataUrl { get; set; } = string.Empty;
}
internal sealed class AssetLabelPayload internal sealed class AssetLabelPayload
{ {
[JsonPropertyName("items")] [JsonPropertyName("items")]
public List<AssetLabelItem> Items { get; set; } = []; public List<AssetLabelItem> Items { get; set; } = [];
[JsonPropertyName("rendered_labels")]
public List<AssetLabelRenderedImage> RenderedLabels { get; set; } = [];
} }
internal sealed class PrintJobDto internal sealed class PrintJobDto

View File

@ -324,7 +324,7 @@ internal sealed class PrintAgentRuntime : IDisposable
} }
var payload = ResolvePayload(job); var payload = ResolvePayload(job);
if (payload?.Items is null || payload.Items.Count == 0) if (payload is null)
{ {
const string message = "当前打印任务缺少可用的资产标签数据,已阻止实际出纸。"; const string message = "当前打印任务缺少可用的资产标签数据,已阻止实际出纸。";
await _apiClient.ReportJobStatusAsync( await _apiClient.ReportJobStatusAsync(
@ -342,11 +342,29 @@ internal sealed class PrintAgentRuntime : IDisposable
return; return;
} }
if (payload.RenderedLabels is null || payload.RenderedLabels.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 try
{ {
var printedCount = await _labelPrintService.PrintAssetLabelsAsync( var printedCount = await _labelPrintService.PrintRenderedLabelImagesAsync(
CurrentState.Configuration.SelectedPrinterName, CurrentState.Configuration.SelectedPrinterName,
payload.Items); payload.RenderedLabels);
await _apiClient.ReportJobStatusAsync( await _apiClient.ReportJobStatusAsync(
CurrentState.Configuration.ServerUrl, CurrentState.Configuration.ServerUrl,

View File

@ -1,5 +1,5 @@
param( param(
[string]$Version = '0.2.0', [string]$Version = '0.2.5',
[string]$PublishedAt = '', [string]$PublishedAt = '',
[string]$DotnetPath = '', [string]$DotnetPath = '',
[string]$IsccPath = '' [string]$IsccPath = ''
@ -39,11 +39,19 @@ $publishDir = Join-Path $scriptRoot 'dist\publish\win-x64'
$installerDir = Join-Path $scriptRoot 'dist\installer' $installerDir = Join-Path $scriptRoot 'dist\installer'
$releaseDir = Join-Path $repoRoot "downloads\print-agent\releases\$Version" $releaseDir = Join-Path $repoRoot "downloads\print-agent\releases\$Version"
$latestDir = Join-Path $repoRoot 'downloads\print-agent\latest' $latestDir = Join-Path $repoRoot 'downloads\print-agent\latest'
$projectFile = Join-Path $scriptRoot 'FixedAssetPrintAgent\FixedAssetPrintAgent.csproj'
$installerScript = Join-Path $scriptRoot 'installer\FixedAssetPrintAgent.iss'
New-Item -ItemType Directory -Force -Path $publishDir, $installerDir, $releaseDir, $latestDir | Out-Null New-Item -ItemType Directory -Force -Path $publishDir, $installerDir, $releaseDir, $latestDir | Out-Null
Remove-Item -Path (Join-Path $publishDir '*') -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path (Join-Path $installerDir '*') -Recurse -Force -ErrorAction SilentlyContinue
& $DotnetPath restore `
$projectFile `
-r win-x64
& $DotnetPath publish ` & $DotnetPath publish `
(Join-Path $scriptRoot 'FixedAssetPrintAgent\FixedAssetPrintAgent.csproj') ` $projectFile `
-c Release ` -c Release `
-r win-x64 ` -r win-x64 `
--self-contained true ` --self-contained true `
@ -51,7 +59,7 @@ New-Item -ItemType Directory -Force -Path $publishDir, $installerDir, $releaseDi
-p:EnableCompressionInSingleFile=true ` -p:EnableCompressionInSingleFile=true `
-o $publishDir -o $publishDir
& $IsccPath (Join-Path $scriptRoot 'installer\FixedAssetPrintAgent.iss') & $IsccPath "/DMyAppVersion=$Version" $installerScript
$installerSource = Join-Path $installerDir "print-agent-setup-$Version.exe" $installerSource = Join-Path $installerDir "print-agent-setup-$Version.exe"
if (-not (Test-Path $installerSource)) { if (-not (Test-Path $installerSource)) {
@ -68,7 +76,7 @@ $releaseManifest = [ordered]@{
version = $Version version = $Version
published_at = $PublishedAt published_at = $PublishedAt
filename = "print-agent-setup-$Version.exe" filename = "print-agent-setup-$Version.exe"
notes = '打印助手 v0.2.0,已切换为 .NET 8 原生 Windows 托盘版,带正式安装器、控制面板卸载入口、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。' notes = "打印助手 v$Version,已切回安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。"
source_type = 'server_release' source_type = 'server_release'
relative_path = "downloads/print-agent/releases/$Version/print-agent-setup-$Version.exe" relative_path = "downloads/print-agent/releases/$Version/print-agent-setup-$Version.exe"
relativePath = "downloads/print-agent/releases/$Version/print-agent-setup-$Version.exe" relativePath = "downloads/print-agent/releases/$Version/print-agent-setup-$Version.exe"
@ -78,14 +86,15 @@ $latestManifest = [ordered]@{
version = $Version version = $Version
published_at = $PublishedAt published_at = $PublishedAt
filename = 'print-agent-setup.exe' filename = 'print-agent-setup.exe'
notes = '打印助手最新稳定版,当前为 .NET 8 原生 Windows 托盘安装包,支持正式安装、卸载、配对接入、心跳、任务轮询、驱动帮助、测试打印与资产标签静默打印。' notes = '打印助手最新稳定版,当前为安装即用的大包中文安装器,内置 .NET 8 运行时,行政电脑无需额外安装运行环境;远程打印继续直出网页成品标签图。'
source_type = 'server_release' source_type = 'server_release'
relative_path = 'downloads/print-agent/latest/print-agent-setup.exe' relative_path = 'downloads/print-agent/latest/print-agent-setup.exe'
relativePath = 'downloads/print-agent/latest/print-agent-setup.exe' relativePath = 'downloads/print-agent/latest/print-agent-setup.exe'
} | ConvertTo-Json -Depth 5 } | ConvertTo-Json -Depth 5
Set-Content -Path (Join-Path $releaseDir 'manifest.json') -Value $releaseManifest -Encoding UTF8 $utf8NoBom = New-Object System.Text.UTF8Encoding($false)
Set-Content -Path (Join-Path $latestDir 'manifest.json') -Value $latestManifest -Encoding UTF8 [System.IO.File]::WriteAllText((Join-Path $releaseDir 'manifest.json'), $releaseManifest, $utf8NoBom)
[System.IO.File]::WriteAllText((Join-Path $latestDir 'manifest.json'), $latestManifest, $utf8NoBom)
$installerSizeMB = [Math]::Round((Get-Item $installerSource).Length / 1MB, 2) $installerSizeMB = [Math]::Round((Get-Item $installerSource).Length / 1MB, 2)
Write-Host "安装包已生成:$installerSource ($installerSizeMB MB)" Write-Host "安装包已生成:$installerSource ($installerSizeMB MB)"

View File

@ -0,0 +1,418 @@
; *** Inno Setup version 6.5.0+ Chinese Simplified messages ***
;
; To download user-contributed translations of this file, go to:
; https://jrsoftware.org/files/istrans/
;
; Note: When translating this text, do not add periods (.) to the end of
; messages that didn't have them already, because on those messages Inno
; Setup adds the periods automatically (appending a period would result in
; two periods being displayed).
;
; Maintained by Zhenghan Yang
; Email: 847320916@QQ.com
; Translation based on network resource
; The latest Translation is on https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
;
[LangOptions]
; The following three entries are very important. Be sure to read and
; understand the '[LangOptions] section' topic in the help file.
LanguageName=简体中文
; If Language Name display incorrect, uncomment next line
; LanguageName=<7B80><4F53><4E2D><6587>
; About LanguageID, to reference link:
; https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-lcid/a9eac961-e77d-41a6-90a5-ce1a8b0cdb9c
LanguageID=$0804
; About CodePage, to reference link:
; https://docs.microsoft.com/en-us/windows/win32/intl/code-page-identifiers
LanguageCodePage=936
; If the language you are translating to requires special font faces or
; sizes, uncomment any of the following entries and change them accordingly.
;DialogFontName=
;DialogFontSize=9
;DialogFontBaseScaleWidth=7
;DialogFontBaseScaleHeight=15
;WelcomeFontName=Segoe UI
;WelcomeFontSize=14
[Messages]
; *** 应用程序标题
SetupAppTitle=安装
SetupWindowTitle=安装 - %1
UninstallAppTitle=卸载
UninstallAppFullTitle=%1 卸载
; *** Misc. common
InformationTitle=信息
ConfirmTitle=确认
ErrorTitle=错误
; *** SetupLdr messages
SetupLdrStartupMessage=现在将安装 %1。您想要继续吗?
LdrCannotCreateTemp=无法创建临时文件。安装程序已中止
LdrCannotExecTemp=无法执行临时目录中的文件。安装程序已中止
HelpTextNote=
; *** 启动错误消息
LastErrorMessage=%1。%n%n错误 %2: %3
SetupFileMissing=安装目录中缺少文件 %1。请修正这个问题或者获取程序的新副本。
SetupFileCorrupt=安装文件已损坏。请获取程序的新副本。
SetupFileCorruptOrWrongVer=安装文件已损坏,或是与这个安装程序的版本不兼容。请修正这个问题或获取新的程序副本。
InvalidParameter=无效的命令行参数:%n%n%1
SetupAlreadyRunning=安装程序正在运行。
WindowsVersionNotSupported=此程序不支持当前计算机运行的 Windows 版本。
WindowsServicePackRequired=此程序需要 %1 服务包 %2 或更高版本。
NotOnThisPlatform=此程序不能在 %1 上运行。
OnlyOnThisPlatform=此程序只能在 %1 上运行。
OnlyOnTheseArchitectures=此程序只能安装到为下列处理器架构设计的 Windows 版本中:%n%n%1
WinVersionTooLowError=此程序需要 %1 版本 %2 或更高。
WinVersionTooHighError=此程序不能安装于 %1 版本 %2 或更高。
AdminPrivilegesRequired=在安装此程序时您必须以管理员身份登录。
PowerUserPrivilegesRequired=在安装此程序时您必须以管理员身份或有权限的用户组身份登录。
SetupAppRunningError=安装程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
UninstallAppRunningError=卸载程序发现 %1 当前正在运行。%n%n请先关闭正在运行的程序,然后点击“确定”继续,或点击“取消”退出。
; *** 启动问题
PrivilegesRequiredOverrideTitle=选择安装程序模式
PrivilegesRequiredOverrideInstruction=选择安装模式
PrivilegesRequiredOverrideText1=%1 可以为所有用户安装(需要管理员权限),或仅为您安装。
PrivilegesRequiredOverrideText2=%1 可以仅为您安装,或为所有用户安装(需要管理员权限)。
PrivilegesRequiredOverrideAllUsers=为所有用户安装(&A)
PrivilegesRequiredOverrideAllUsersRecommended=为所有用户安装(&A) (建议选项)
PrivilegesRequiredOverrideCurrentUser=仅为我安装(&M)
PrivilegesRequiredOverrideCurrentUserRecommended=仅为我安装(&M) (建议选项)
; *** 其他错误
ErrorCreatingDir=安装程序无法创建目录“%1”
ErrorTooManyFilesInDir=无法在目录“%1”中创建文件,因为里面包含太多文件
; *** 安装程序公共消息
ExitSetupTitle=退出安装程序
ExitSetupMessage=安装程序尚未完成。如果现在退出,将不会安装该程序。%n%n您之后可以再次运行安装程序完成安装。%n%n现在退出安装程序吗?
AboutSetupMenuItem=关于安装程序(&A)...
AboutSetupTitle=关于安装程序
AboutSetupMessage=%1 版本 %2%n%3%n%n%1 主页:%n%4
AboutSetupNote=
TranslatorNote=简体中文翻译由Kira(847320916@qq.com)维护。项目地址:https://github.com/kira-96/Inno-Setup-Chinese-Simplified-Translation
; *** 按钮
ButtonBack=< 上一步(&B)
ButtonNext=下一步(&N) >
ButtonInstall=安装(&I)
ButtonOK=确定
ButtonCancel=取消
ButtonYes=是(&Y)
ButtonYesToAll=全是(&A)
ButtonNo=否(&N)
ButtonNoToAll=全否(&O)
ButtonFinish=完成(&F)
ButtonBrowse=浏览(&B)...
ButtonWizardBrowse=浏览(&R)...
ButtonNewFolder=新建文件夹(&M)
; *** “选择语言”对话框消息
SelectLanguageTitle=选择安装语言
SelectLanguageLabel=选择安装时使用的语言。
; *** 公共向导文字
ClickNext=点击“下一步”继续,或点击“取消”退出安装程序。
BeveledLabel=
BrowseDialogTitle=浏览文件夹
BrowseDialogLabel=在下面的列表中选择一个文件夹,然后点击“确定”。
NewFolderName=新建文件夹
; *** “欢迎”向导页
WelcomeLabel1=欢迎使用 [name] 安装向导
WelcomeLabel2=现在将安装 [name/ver] 到您的电脑中。%n%n建议您在继续安装前关闭所有其他应用程序。
; *** “密码”向导页
WizardPassword=密码
PasswordLabel1=这个安装程序有密码保护。
PasswordLabel3=请输入密码,然后点击“下一步”继续。密码区分大小写。
PasswordEditLabel=密码(&P):
IncorrectPassword=您输入的密码不正确,请重新输入。
; *** “许可协议”向导页
WizardLicense=许可协议
LicenseLabel=请在继续安装前阅读以下重要信息。
LicenseLabel3=请仔细阅读下列许可协议。在继续安装前您必须同意这些协议条款。
LicenseAccepted=我同意此协议(&A)
LicenseNotAccepted=我不同意此协议(&D)
; *** “信息”向导页
WizardInfoBefore=信息
InfoBeforeLabel=请在继续安装前阅读以下重要信息。
InfoBeforeClickLabel=准备好继续安装后,点击“下一步”。
WizardInfoAfter=信息
InfoAfterLabel=请在继续安装前阅读以下重要信息。
InfoAfterClickLabel=准备好继续安装后,点击“下一步”。
; *** “用户信息”向导页
WizardUserInfo=用户信息
UserInfoDesc=请输入您的信息。
UserInfoName=用户名(&U):
UserInfoOrg=组织(&O):
UserInfoSerial=序列号(&S):
UserInfoNameRequired=您必须输入用户名。
; *** “选择目标目录”向导页
WizardSelectDir=选择目标位置
SelectDirDesc=您想将 [name] 安装在哪里?
SelectDirLabel3=安装程序将安装 [name] 到下面的文件夹中。
SelectDirBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
DiskSpaceGBLabel=至少需要有 [gb] GB 的可用磁盘空间。
DiskSpaceMBLabel=至少需要有 [mb] MB 的可用磁盘空间。
CannotInstallToNetworkDrive=安装程序无法安装到一个网络驱动器。
CannotInstallToUNCPath=安装程序无法安装到一个 UNC 路径。
InvalidPath=您必须输入一个带驱动器卷标的完整路径,例如:%n%nC:\APP%n%n或UNC路径:%n%n\\server\share
InvalidDrive=您选定的驱动器或 UNC 共享不存在或不能访问。请选择其他位置。
DiskSpaceWarningTitle=磁盘空间不足
DiskSpaceWarning=安装程序至少需要 %1 KB 的可用空间才能安装,但选定驱动器只有 %2 KB 的可用空间。%n%n您一定要继续吗?
DirNameTooLong=文件夹名称或路径太长。
InvalidDirName=文件夹名称无效。
BadDirName32=文件夹名称不能包含下列任何字符:%n%n%1
DirExistsTitle=文件夹已存在
DirExists=文件夹:%n%n%1%n%n已经存在。您一定要安装到这个文件夹中吗?
DirDoesntExistTitle=文件夹不存在
DirDoesntExist=文件夹:%n%n%1%n%n不存在。您想要创建此文件夹吗?
; *** “选择组件”向导页
WizardSelectComponents=选择组件
SelectComponentsDesc=您想安装哪些程序组件?
SelectComponentsLabel2=选中您想安装的组件;取消您不想安装的组件。然后点击“下一步”继续。
FullInstallation=完全安装
; if possible don't translate 'Compact' as 'Minimal' (I mean 'Minimal' in your language)
CompactInstallation=简洁安装
CustomInstallation=自定义安装
NoUninstallWarningTitle=组件已存在
NoUninstallWarning=安装程序检测到下列组件已安装在您的电脑中:%n%n%1%n%n取消选中这些组件不会卸载它们。%n%n确定要继续吗?
ComponentSize1=%1 KB
ComponentSize2=%1 MB
ComponentsDiskSpaceGBLabel=当前选择的组件需要至少 [gb] GB 的磁盘空间。
ComponentsDiskSpaceMBLabel=当前选择的组件需要至少 [mb] MB 的磁盘空间。
; *** “选择附加任务”向导页
WizardSelectTasks=选择附加任务
SelectTasksDesc=您想要安装程序执行哪些附加任务?
SelectTasksLabel2=选择您想要安装程序在安装 [name] 时执行的附加任务,然后点击“下一步”。
; *** “选择开始菜单文件夹”向导页
WizardSelectProgramGroup=选择开始菜单文件夹
SelectStartMenuFolderDesc=安装程序应该在哪里放置程序的快捷方式?
SelectStartMenuFolderLabel3=安装程序将在下列“开始”菜单文件夹中创建程序的快捷方式。
SelectStartMenuFolderBrowseLabel=点击“下一步”继续。如果您想选择其他文件夹,点击“浏览”。
MustEnterGroupName=您必须输入一个文件夹名。
GroupNameTooLong=文件夹名或路径太长。
InvalidGroupName=无效的文件夹名字。
BadGroupName=文件夹名不能包含下列任何字符:%n%n%1
NoProgramGroupCheck2=不创建开始菜单文件夹(&D)
; *** “准备安装”向导页
WizardReady=准备安装
ReadyLabel1=安装程序准备就绪,现在可以开始安装 [name] 到您的电脑。
ReadyLabel2a=点击“安装”继续此安装程序。如果您想重新考虑或修改任何设置,点击“上一步”。
ReadyLabel2b=点击“安装”继续此安装程序。
ReadyMemoUserInfo=用户信息:
ReadyMemoDir=目标位置:
ReadyMemoType=安装类型:
ReadyMemoComponents=已选择组件:
ReadyMemoGroup=开始菜单文件夹:
ReadyMemoTasks=附加任务:
; *** TExtractionWizardPage 向导页面与 ExtractArchive
ExtractingLabel=正在解压文件...
ButtonStopExtraction=停止解压(&S)
StopExtraction=您确定要停止解压吗?
ErrorExtractionAborted=解压已中止
ErrorExtractionFailed=解压失败:%1
; *** 压缩文件解压失败详情
ArchiveIncorrectPassword=压缩文件密码不正确
ArchiveIsCorrupted=压缩文件已损坏
ArchiveUnsupportedFormat=不支持的压缩文件格式
; *** TDownloadWizardPage 向导页面和 DownloadTemporaryFile
DownloadingLabel2=正在下载文件...
ButtonStopDownload=停止下载(&S)
StopDownload=您确定要停止下载吗?
ErrorDownloadAborted=下载已中止
ErrorDownloadFailed=下载失败:%1 %2
ErrorDownloadSizeFailed=获取下载大小失败:%1 %2
ErrorProgress=无效的进度:%1 / %2
ErrorFileSize=文件大小错误:预期 %1,实际 %2
; *** “正在准备安装”向导页
WizardPreparing=正在准备安装
PreparingDesc=安装程序正在准备安装 [name] 到您的电脑。
PreviousInstallNotCompleted=先前的程序安装或卸载未完成,您需要重启您的电脑以完成。%n%n在重启电脑后,再次运行安装程序以完成 [name] 的安装。
CannotContinue=安装程序不能继续。请点击“取消”退出。
ApplicationsFound=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。
ApplicationsFound2=以下应用程序正在使用将由安装程序更新的文件。建议您允许安装程序自动关闭这些应用程序。安装完成后,安装程序将尝试重新启动这些应用程序。
CloseApplications=自动关闭应用程序(&A)
DontCloseApplications=不要关闭应用程序(&D)
ErrorCloseApplications=安装程序无法自动关闭所有应用程序。建议您在继续之前,关闭所有在使用需要由安装程序更新的文件的应用程序。
PrepareToInstallNeedsRestart=安装程序必须重启您的计算机。计算机重启后,请再次运行安装程序以完成 [name] 的安装。%n%n是否立即重新启动?
; *** “正在安装”向导页
WizardInstalling=正在安装
InstallingLabel=安装程序正在安装 [name] 到您的电脑,请稍候。
; *** “安装完成”向导页
FinishedHeadingLabel=[name] 安装完成
FinishedLabelNoIcons=安装程序已在您的电脑中安装了 [name]。
FinishedLabel=安装程序已在您的电脑中安装了 [name]。您可以通过已安装的快捷方式运行此应用程序。
ClickFinish=点击“完成”退出安装程序。
FinishedRestartLabel=为完成 [name] 的安装,安装程序必须重新启动您的电脑。要立即重启吗?
FinishedRestartMessage=为完成 [name] 的安装,安装程序必须重新启动您的电脑。%n%n要立即重启吗?
ShowReadmeCheck=是,我想查阅自述文件
YesRadio=是,立即重启电脑(&Y)
NoRadio=否,稍后重启电脑(&N)
; used for example as 'Run MyProg.exe'
RunEntryExec=运行 %1
; used for example as 'View Readme.txt'
RunEntryShellExec=查阅 %1
; *** “安装程序需要下一张磁盘”提示
ChangeDiskTitle=安装程序需要下一张磁盘
SelectDiskLabel2=请插入磁盘 %1 并点击“确定”。%n%n如果这个磁盘中的文件可以在下列文件夹之外的文件夹中找到,请输入正确的路径或点击“浏览”。
PathLabel=路径(&P):
FileNotInDir2=“%2”中找不到文件“%1”。请插入正确的磁盘或选择其他文件夹。
SelectDirectoryLabel=请指定下一张磁盘的位置。
; *** 安装阶段消息
SetupAborted=安装程序未完成安装。%n%n请修正这个问题并重新运行安装程序。
AbortRetryIgnoreSelectAction=选择操作
AbortRetryIgnoreRetry=重试(&T)
AbortRetryIgnoreIgnore=忽略错误并继续(&I)
AbortRetryIgnoreCancel=关闭安装程序
RetryCancelSelectAction=选择操作
RetryCancelRetry=重试(&T)
RetryCancelCancel=取消(&C)
; *** 安装状态消息
StatusClosingApplications=正在关闭应用程序...
StatusCreateDirs=正在创建目录...
StatusExtractFiles=正在提取文件...
StatusDownloadFiles=正在下载文件...
StatusCreateIcons=正在创建快捷方式...
StatusCreateIniEntries=正在创建 INI 条目...
StatusCreateRegistryEntries=正在创建注册表条目...
StatusRegisterFiles=正在注册文件...
StatusSavingUninstall=正在保存卸载信息...
StatusRunProgram=正在完成安装...
StatusRestartingApplications=正在重启应用程序...
StatusRollback=正在撤销更改...
; *** 其他错误
ErrorInternal2=内部错误:%1
ErrorFunctionFailedNoCode=%1 失败
ErrorFunctionFailed=%1 失败;错误代码 %2
ErrorFunctionFailedWithMessage=%1 失败;错误代码 %2.%n%3
ErrorExecutingProgram=无法执行文件:%n%1
; *** 注册表错误
ErrorRegOpenKey=打开注册表项时出错:%n%1\%2
ErrorRegCreateKey=创建注册表项时出错:%n%1\%2
ErrorRegWriteKey=写入注册表项时出错:%n%1\%2
; *** INI 错误
ErrorIniEntry=在文件“%1”中创建 INI 条目时出错。
; *** 文件复制错误
FileAbortRetryIgnoreSkipNotRecommended=跳过此文件(&S) (不推荐)
FileAbortRetryIgnoreIgnoreNotRecommended=忽略错误并继续(&I) (不推荐)
SourceIsCorrupted=源文件已损坏
SourceDoesntExist=源文件“%1”不存在
SourceVerificationFailed=源文件验证失败: %1
VerificationSignatureDoesntExist=签名文件“%1”不存在
VerificationSignatureInvalid=签名文件“%1”无效
VerificationKeyNotFound=签名文件“%1”使用了未知密钥
VerificationFileNameIncorrect=文件名不正确
VerificationFileTagIncorrect=文件标签不正确
VerificationFileSizeIncorrect=文件大小不正确
VerificationFileHashIncorrect=文件哈希值不正确
ExistingFileReadOnly2=无法替换现有文件,它是只读的。
ExistingFileReadOnlyRetry=移除只读属性并重试(&R)
ExistingFileReadOnlyKeepExisting=保留现有文件(&K)
ErrorReadingExistingDest=尝试读取现有文件时出错:
FileExistsSelectAction=选择操作
FileExists2=文件已经存在。
FileExistsOverwriteExisting=覆盖已存在的文件(&O)
FileExistsKeepExisting=保留现有的文件(&K)
FileExistsOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
ExistingFileNewerSelectAction=选择操作
ExistingFileNewer2=现有的文件比安装程序将要安装的文件还要新。
ExistingFileNewerOverwriteExisting=覆盖已存在的文件(&O)
ExistingFileNewerKeepExisting=保留现有的文件(&K) (推荐)
ExistingFileNewerOverwriteOrKeepAll=为所有冲突文件执行此操作(&D)
ErrorChangingAttr=尝试更改下列现有文件的属性时出错:
ErrorCreatingTemp=尝试在目标目录创建文件时出错:
ErrorReadingSource=尝试读取下列源文件时出错:
ErrorCopying=尝试复制下列文件时出错:
ErrorDownloading=下载文件时出错:
ErrorExtracting=解压压缩文件时出错:
ErrorReplacingExistingFile=尝试替换现有文件时出错:
ErrorRestartReplace=重启并替换失败:
ErrorRenamingTemp=尝试重命名下列目标目录中的一个文件时出错:
ErrorRegisterServer=无法注册 DLL/OCX:%1
ErrorRegSvr32Failed=RegSvr32 失败;退出代码 %1
ErrorRegisterTypeLib=无法注册类库:%1
; *** 卸载显示名字标记
; used for example as 'My Program (32-bit)'
UninstallDisplayNameMark=%1 (%2)
; used for example as 'My Program (32-bit, All users)'
UninstallDisplayNameMarks=%1 (%2, %3)
UninstallDisplayNameMark32Bit=32 位
UninstallDisplayNameMark64Bit=64 位
UninstallDisplayNameMarkAllUsers=所有用户
UninstallDisplayNameMarkCurrentUser=当前用户
; *** 安装后错误
ErrorOpeningReadme=尝试打开自述文件时出错。
ErrorRestartingComputer=安装程序无法重启电脑,请手动重启。
; *** 卸载消息
UninstallNotFound=文件“%1”不存在。无法卸载。
UninstallOpenError=文件“%1”不能被打开。无法卸载。
UninstallUnsupportedVer=此版本的卸载程序无法识别卸载日志文件“%1”的格式。无法卸载
UninstallUnknownEntry=卸载日志中遇到一个未知条目 (%1)
ConfirmUninstall=您确认要完全移除 %1 及其所有组件吗?
UninstallOnlyOnWin64=仅允许在 64 位 Windows 中卸载此程序。
OnlyAdminCanUninstall=仅使用管理员权限的用户能完成此卸载。
UninstallStatusLabel=正在从您的电脑中移除 %1,请稍候。
UninstalledAll=已顺利从您的电脑中移除 %1。
UninstalledMost=%1 卸载完成。%n%n有部分内容未能被删除,但您可以手动删除它们。
UninstalledAndNeedsRestart=为完成 %1 的卸载,需要重启您的电脑。%n%n立即重启电脑吗?
UninstallDataCorrupted=文件“%1”已损坏。无法卸载
; *** 卸载状态消息
ConfirmDeleteSharedFileTitle=删除共享的文件吗?
ConfirmDeleteSharedFile2=系统表示下列共享的文件已不有其他程序使用。您希望卸载程序删除这些共享的文件吗?%n%n如果删除这些文件,但仍有程序在使用这些文件,则这些程序可能出现异常。如果您不能确定,请选择“否”,在系统中保留这些文件以免引发问题。
SharedFileNameLabel=文件名:
SharedFileLocationLabel=位置:
WizardUninstalling=卸载状态
StatusUninstalling=正在卸载 %1...
; *** Shutdown block reasons
ShutdownBlockReasonInstallingApp=正在安装 %1。
ShutdownBlockReasonUninstallingApp=正在卸载 %1。
; The custom messages below aren't used by Setup itself, but if you make
; use of them in your scripts, you'll want to translate them.
[CustomMessages]
NameAndVersion=%1 版本 %2
AdditionalIcons=附加快捷方式:
CreateDesktopIcon=创建桌面快捷方式(&D)
CreateQuickLaunchIcon=创建快速启动栏快捷方式(&Q)
ProgramOnTheWeb=%1 网站
UninstallProgram=卸载 %1
LaunchProgram=运行 %1
AssocFileExtension=将 %2 文件扩展名与 %1 建立关联(&A)
AssocingFileExtension=正在将 %2 文件扩展名与 %1 建立关联...
AutoStartProgramGroupDescription=启动:
AutoStartProgram=自动启动 %1
AddonHostProgramNotFound=您选择的文件夹中无法找到 %1。%n%n您要继续吗?

View File

@ -1,5 +1,7 @@
#define MyAppName "固定资产打印助手" #define MyAppName "固定资产打印助手"
#define MyAppVersion "0.2.0" #ifndef MyAppVersion
#define MyAppVersion "0.2.5"
#endif
#define MyAppPublisher "常州简尚家具有限公司" #define MyAppPublisher "常州简尚家具有限公司"
#define MyAppExeName "FixedAssetPrintAgent.exe" #define MyAppExeName "FixedAssetPrintAgent.exe"
#define MyAppId "com.jsgdzc.printagent.dotnet" #define MyAppId "com.jsgdzc.printagent.dotnet"
@ -22,11 +24,18 @@ WizardStyle=modern
ArchitecturesAllowed=x64compatible ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible ArchitecturesInstallIn64BitMode=x64compatible
UninstallDisplayIcon={app}\{#MyAppExeName} UninstallDisplayIcon={app}\{#MyAppExeName}
AppMutex=Jsgdzc.FixedAssetPrintAgent
CloseApplications=yes
CloseApplicationsFilter={#MyAppExeName}
RestartApplications=no
OutputDir={#MyAppOutputDir} OutputDir={#MyAppOutputDir}
OutputBaseFilename=print-agent-setup-{#MyAppVersion} OutputBaseFilename=print-agent-setup-{#MyAppVersion}
[Languages] [Languages]
Name: "chinesesimp"; MessagesFile: "compiler:Default.isl" Name: "chinesesimp"; MessagesFile: "compiler:Default.isl,ChineseSimplified.isl"
[CustomMessages]
chinesesimp.InstallationRunningHint=如果看到“打印助手正在运行”的提示,保持“自动关闭应用程序”即可,安装程序会先关闭旧版助手,再继续覆盖安装。
[Tasks] [Tasks]
Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加图标:"; Flags: unchecked Name: "desktopicon"; Description: "创建桌面快捷方式"; GroupDescription: "附加图标:"; Flags: unchecked
@ -41,3 +50,11 @@ Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: de
[Run] [Run]
Filename: "{app}\{#MyAppExeName}"; Description: "立即启动 {#MyAppName}"; Flags: nowait postinstall skipifsilent Filename: "{app}\{#MyAppExeName}"; Description: "立即启动 {#MyAppName}"; Flags: nowait postinstall skipifsilent
[Code]
procedure InitializeWizard();
begin
WizardForm.WelcomeLabel2.Caption :=
WizardForm.WelcomeLabel2.Caption + #13#10#13#10 +
CustomMessage('InstallationRunningHint');
end;

5
print-agent/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
build/
*.log
data/

36
print-agent/README.md Normal file
View File

@ -0,0 +1,36 @@
# 固定资产打印助手 v1 骨架
这是一个独立的 Windows 托盘常驻打印助手骨架,当前只落在 `print-agent/` 内,不改主系统前后端。
## 当前已具备
- Electron 最小桌面壳
- 本地配置存储
- 驱动资源清单读取
- 一次性配对码接入客户端
- 心跳 / 领取任务 / 状态回写客户端
- 首次向导基础界面
- 驱动帮助区块
- 测试打印占位
## 当前仍是占位
- 正式资产标签 PNG 渲染
- 静默打印到真实标签机
- 安装包构建与签名
- 服务器 `downloads/` 真正归档文件
## 本地启动
```bash
cd print-agent
npm install
npm start
```
## 测试
```bash
cd print-agent
npm test
```

5082
print-agent/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
print-agent/package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "fixed-asset-print-agent",
"version": "0.1.0",
"description": "固定资产系统 Windows 托盘常驻打印助手,支持远程标签静默打印",
"main": "src/main.js",
"type": "module",
"scripts": {
"start": "electron .",
"dev": "electron .",
"test": "node --test tests/*.test.mjs",
"pack:dir": "electron-builder --dir",
"dist": "electron-builder --win nsis"
},
"build": {
"appId": "com.jsgdzc.printagent",
"productName": "固定资产打印助手",
"files": [
"src/**/*",
"resources/**/*",
"package.json"
],
"win": {
"signAndEditExecutable": false,
"target": [
"nsis"
]
}
},
"devDependencies": {
"electron": "^37.2.4",
"electron-builder": "^26.0.12"
},
"dependencies": {
"qrcode": "^1.5.4"
}
}

View File

@ -0,0 +1,10 @@
当前 print-agent 子项目只提供“公司归档版驱动”的清单信息与下载位说明。
归档版目标文件名:
- xprinter-driver-2026.01.28.01.exe
原始来源路径:
- D:\Users\54972\Downloads\芯烨windows一键安装版驱动2026.01.28.01.exe
由于本次子任务写入范围仅限 print-agent/,未把 exe 真正复制到仓库根目录 downloads/。
后续应由主线程或发布流程把该文件上传到服务器约定目录,并保持与 driver-resources.json 中的 relativePath 一致。

View File

@ -0,0 +1,15 @@
{
"officialLatest": {
"version": "latest",
"title": "芯烨官方最新版驱动",
"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/",
"checkedAt": "2026-03-25T00:00:00.000+08:00"
},
"companyArchive": {
"version": "2026.01.28.01",
"fileName": "xprinter-driver-2026.01.28.01.exe",
"relativePath": "downloads/printer-driver/xprinter/archive/2026.01.28.01/xprinter-driver-2026.01.28.01.exe",
"sourcePath": "D:\\Users\\54972\\Downloads\\芯烨windows一键安装版驱动2026.01.28.01.exe",
"notes": "当前子项目只保留归档清单和下载位说明,实际服务器归档文件由主线程发布流程接手。"
}
}

View File

@ -0,0 +1,92 @@
import fs from 'node:fs/promises'
import path from 'node:path'
export const CONFIG_FILE_NAME = 'agent-config.json'
const DEFAULT_DRIVER_HELP = {
officialLatest: {
version: 'latest',
title: '芯烨官方最新版驱动',
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/'
},
companyArchive: {
version: '2026.01.28.01',
fileName: 'xprinter-driver-2026.01.28.01.exe',
relativePath:
'downloads/printer-driver/xprinter/archive/2026.01.28.01/xprinter-driver-2026.01.28.01.exe'
}
}
export function defaultAgentConfig() {
return {
serverUrl: 'https://www.jsgdzc.cn',
pairingCode: '',
deviceToken: '',
terminalId: null,
terminalName: '',
selectedPrinterName: '',
pairedAt: '',
pollIntervalMs: 3000,
heartbeatIntervalMs: 30000,
driverHelp: DEFAULT_DRIVER_HELP
}
}
export function createConfigStore(baseDir) {
const filePath = path.join(baseDir, CONFIG_FILE_NAME)
async function ensureDir() {
await fs.mkdir(baseDir, { recursive: true })
}
async function load() {
try {
const content = await fs.readFile(filePath, 'utf8')
return {
...defaultAgentConfig(),
...JSON.parse(content)
}
} catch (error) {
if (error && error.code === 'ENOENT') {
return defaultAgentConfig()
}
throw error
}
}
async function save(patch) {
const current = await load()
const next = {
...current,
...patch
}
if (patch?.pairResult) {
next.terminalId =
patch.pairResult.terminalId ??
patch.pairResult.terminal_id ??
current.terminalId
next.deviceToken =
patch.pairResult.deviceToken ??
patch.pairResult.device_token ??
current.deviceToken
next.pairedAt =
patch.pairResult.pairedAt ??
patch.pairResult.paired_at ??
current.pairedAt
}
delete next.pairResult
await ensureDir()
await fs.writeFile(filePath, JSON.stringify(next, null, 2))
return next
}
return {
filePath,
load,
save
}
}

170
print-agent/src/index.html Normal file
View File

@ -0,0 +1,170 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>固定资产打印助手</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main class="app-shell">
<header class="hero">
<div>
<p class="eyebrow">Windows 托盘常驻 exe</p>
<h1>固定资产打印助手</h1>
<p class="hero-copy">
先完成配对、打印机选择和驱动检查,再把远程标签任务交给本机执行。
</p>
</div>
<div class="status-chip" id="runtime-chip">未连接</div>
</header>
<section class="panel warning-panel">
<strong>当前是远程标签打印 v1:</strong>
以后手机或网页只负责发任务,真正出纸由这台电脑静默完成。首次接入仍需要先安装驱动、选择标签机并确认 60x40mm。
</section>
<section class="grid-two">
<article class="panel">
<div class="panel-head">
<h2>1. 首次配对</h2>
<p>输入服务器地址、终端名称和一次性配对码。</p>
</div>
<form id="pair-form" class="field-stack">
<label class="field">
<span>服务器地址</span>
<input id="server-url" type="url" placeholder="https://www.jsgdzc.cn" />
</label>
<label class="field">
<span>终端名称</span>
<input id="terminal-name" type="text" placeholder="例如:行政办公室标签机" />
</label>
<label class="field">
<span>一次性配对码</span>
<input id="pair-code" type="text" placeholder="例如:ABCD-EFGH" />
</label>
<div class="button-row">
<button type="button" id="save-config-button">保存本地配置</button>
<button type="submit" class="primary">立即配对</button>
</div>
</form>
</article>
<article class="panel">
<div class="panel-head">
<h2>2. 打印机与标签纸</h2>
<p>选择 60x40mm 标签打印机,并用真实测试标签检查出纸。</p>
</div>
<div class="field-stack">
<label class="field">
<span>当前标签打印机</span>
<select id="printer-select"></select>
</label>
<div class="button-row">
<button type="button" id="refresh-printers-button">刷新打印机</button>
<button type="button" id="save-printer-button">保存打印机</button>
<button type="button" id="test-print-button">打印测试标签</button>
</div>
</div>
<div class="detail-list">
<div>
<span>已配对终端 ID</span>
<strong id="terminal-id">-</strong>
</div>
<div>
<span>设备令牌状态</span>
<strong id="token-status">未配对</strong>
</div>
<div>
<span>预期标签纸</span>
<strong>60x40mm</strong>
</div>
</div>
</article>
</section>
<section class="grid-two">
<article class="panel">
<div class="panel-head">
<h2>3. 驱动帮助</h2>
<p>先装驱动,再重新检测打印机。</p>
</div>
<div class="driver-card">
<div>
<p class="driver-label">官方最新版</p>
<h3 id="official-driver-title">芯烨官方驱动</h3>
<p id="official-driver-url" class="mono"></p>
</div>
<button type="button" id="open-official-driver-button">打开官方链接</button>
</div>
<div class="driver-card">
<div>
<p class="driver-label">公司归档版</p>
<h3 id="archive-driver-version">版本 -</h3>
<p id="archive-driver-path" class="mono"></p>
</div>
<button type="button" id="open-archive-driver-button">打开归档下载位</button>
</div>
<p class="footnote">
如果当前电脑还没识别到标签机,先装驱动,再点“刷新打印机”,最后再继续 60x40mm 测试。
</p>
</article>
<article class="panel">
<div class="panel-head">
<h2>4. 运行时联通</h2>
<p>用于手动检查心跳、领取任务和状态回写链路。</p>
</div>
<div class="button-row">
<button type="button" id="heartbeat-button">立即发送心跳</button>
<button type="button" id="claim-button">手动领取一条任务</button>
</div>
<div class="detail-list">
<div>
<span>最近心跳</span>
<strong id="last-heartbeat">-</strong>
</div>
<div>
<span>当前版本</span>
<strong id="current-version">-</strong>
</div>
<div>
<span>服务器版本</span>
<strong id="latest-version">-</strong>
</div>
<div>
<span>版本状态</span>
<strong id="update-status">-</strong>
</div>
<div>
<span>最近轮询</span>
<strong id="last-claim">-</strong>
</div>
<div>
<span>最近任务结果</span>
<strong id="last-job-result">-</strong>
</div>
<div>
<span>最近错误</span>
<strong id="last-error">-</strong>
</div>
</div>
</article>
</section>
<section class="panel">
<div class="panel-head">
<h2>运行日志</h2>
<p id="flash-message">准备就绪。</p>
</div>
<pre id="log-output" class="log-output"></pre>
</section>
</main>
<script type="module" src="./renderer.js"></script>
</body>
</html>

275
print-agent/src/main.js Normal file
View File

@ -0,0 +1,275 @@
import path from 'node:path'
import os from 'node:os'
import { fileURLToPath } from 'node:url'
import {
app,
BrowserWindow,
ipcMain,
Menu,
Tray,
nativeImage,
shell
} from 'electron'
import { createConfigStore } from './config/storage.js'
import {
loadDriverResources,
resolveDriverDownloadUrl
} from './services/driverManifest.js'
import { createPrintAgentClient } from './services/apiClient.js'
import { createJobProcessor } from './services/jobProcessor.js'
import { createAgentRuntime } from './services/agentRuntime.js'
import {
buildAssetLabelPrintHtml,
buildTestLabelPrintHtml
} from './services/labelDocument.js'
import { printHtmlSilently } from './services/electronPrinter.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const agentRoot = path.resolve(__dirname, '..')
let mainWindow = null
let tray = null
let runtime = null
let store = null
let isQuitting = false
let shouldShowMainWindow = false
function createTrayIcon() {
return nativeImage.createFromDataURL(
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAK0lEQVR4AWP4//8/AyUYTFhYWPjPwMBgkJGR4T8GBgYmhjEwMDAwQAEA8A4Qbw6X8UIAAAAASUVORK5CYII='
)
}
function sendStateToRenderer(state) {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('agent:state', state)
}
}
async function getPrinters() {
if (!mainWindow || mainWindow.isDestroyed()) {
return []
}
try {
const printers = await mainWindow.webContents.getPrintersAsync()
return printers.map((printer) => ({
name: printer.name,
description: printer.description || '',
status: printer.status ?? 0,
isDefault: Boolean(printer.isDefault)
}))
} catch (error) {
return [
{
name: '',
description: `打印机列表读取失败:${error.message}`,
status: -1,
isDefault: false
}
]
}
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1080,
height: 760,
minWidth: 960,
minHeight: 680,
show: false,
autoHideMenuBar: true,
backgroundColor: '#0b0d12',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
sandbox: false
}
})
mainWindow.loadFile(path.join(__dirname, 'index.html'))
mainWindow.on('ready-to-show', () => {
if (shouldShowMainWindow) {
mainWindow.show()
}
})
mainWindow.on('close', (event) => {
if (!isQuitting) {
event.preventDefault()
mainWindow.hide()
}
})
}
function createTray() {
tray = new Tray(createTrayIcon())
tray.setToolTip('固定资产打印助手')
tray.setContextMenu(
Menu.buildFromTemplate([
{
label: '打开打印助手',
click: () => mainWindow?.show()
},
{
label: '退出',
click: () => {
isQuitting = true
runtime?.stop()
app.quit()
}
}
])
)
tray.on('double-click', () => mainWindow?.show())
}
async function getDriverResourcesForUi(config) {
const resources = await loadDriverResources(agentRoot)
return {
...resources,
companyArchive: {
...resources.companyArchive,
downloadUrl: resolveDriverDownloadUrl(
config?.serverUrl || 'https://www.jsgdzc.cn',
resources.companyArchive
)
}
}
}
function registerIpcHandlers() {
ipcMain.handle('agent:bootstrap', async () => {
const config = await runtime.loadConfig()
const printers = await runtime.refreshPrinters()
const driverResources = await getDriverResourcesForUi(config)
return {
config,
printers,
driverResources,
runtimeState: runtime.getState()
}
})
ipcMain.handle('agent:save-config', async (_event, patch) => {
const config = await runtime.updateConfig(patch)
const driverResources = await getDriverResourcesForUi(config)
return { config, driverResources }
})
ipcMain.handle('agent:list-printers', async () => runtime.refreshPrinters())
ipcMain.handle('agent:heartbeat-now', async () => runtime.heartbeatOnce())
ipcMain.handle('agent:claim-now', async () => runtime.pollOnce())
ipcMain.handle('agent:test-print', async () => runtime.runTestPrint())
ipcMain.handle('agent:open-external', async (_event, url) => shell.openExternal(url))
ipcMain.handle('agent:pair', async (_event, payload) => {
const currentConfig = await runtime.loadConfig()
const client = createPrintAgentClient({
serverUrl: payload.serverUrl || currentConfig.serverUrl
})
const result = await client.pair({
pairCode: payload.pairCode,
terminalName: payload.terminalName,
machineName: payload.machineName || os.hostname(),
platform: payload.platform || process.platform
})
const config = await runtime.updateConfig({
serverUrl: payload.serverUrl || currentConfig.serverUrl,
terminalName: payload.terminalName || currentConfig.terminalName,
pairingCode: '',
pairResult: result
})
return {
config,
pairResult: result
}
})
}
async function createRuntime() {
store = createConfigStore(path.join(app.getPath('userData'), 'data'))
const jobProcessor = createJobProcessor({
async printAssetLabels({ items, printerName }) {
const html = await buildAssetLabelPrintHtml({
items
})
await printHtmlSilently({
html,
printerName,
jobName: '资产标签打印'
})
return {
printed_count: items.length
}
},
async printTestLabel({ printerName }) {
const html = await buildTestLabelPrintHtml({
printerName
})
await printHtmlSilently({
html,
printerName,
jobName: '测试标签打印'
})
}
})
runtime = createAgentRuntime({
store,
clientFactory: (options) => createPrintAgentClient(options),
getPrinters,
jobProcessor,
appVersion: app.getVersion(),
onStateChange: sendStateToRenderer
})
await runtime.start()
const config = runtime.getState().config
shouldShowMainWindow = !config?.deviceToken
if (shouldShowMainWindow) {
mainWindow?.show()
} else {
mainWindow?.hide()
}
}
app.setAppUserModelId('com.jsgdzc.printagent')
if (process.platform === 'win32') {
app.setLoginItemSettings({
openAtLogin: true,
openAsHidden: true
})
}
app.on('before-quit', () => {
isQuitting = true
})
app.whenReady().then(async () => {
createWindow()
createTray()
registerIpcHandlers()
await createRuntime()
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
} else {
mainWindow?.show()
}
})
app.on('window-all-closed', (event) => {
event.preventDefault()
})

View File

@ -0,0 +1,17 @@
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('printAgent', {
bootstrap: () => ipcRenderer.invoke('agent:bootstrap'),
saveConfig: (patch) => ipcRenderer.invoke('agent:save-config', patch),
pair: (payload) => ipcRenderer.invoke('agent:pair', payload),
listPrinters: () => ipcRenderer.invoke('agent:list-printers'),
heartbeatNow: () => ipcRenderer.invoke('agent:heartbeat-now'),
claimNow: () => ipcRenderer.invoke('agent:claim-now'),
testPrint: () => ipcRenderer.invoke('agent:test-print'),
openExternal: (url) => ipcRenderer.invoke('agent:open-external', url),
onStateChange: (callback) => {
const listener = (_event, state) => callback(state)
ipcRenderer.on('agent:state', listener)
return () => ipcRenderer.removeListener('agent:state', listener)
}
})

209
print-agent/src/renderer.js Normal file
View File

@ -0,0 +1,209 @@
const ui = {
config: null,
driverResources: null,
runtimeState: null,
printers: []
}
function $(id) {
return document.getElementById(id)
}
function setFlashMessage(message, isError = false) {
const el = $('flash-message')
el.textContent = message
el.style.color = isError ? 'var(--danger)' : 'var(--muted)'
}
function renderPrinterOptions() {
const select = $('printer-select')
select.innerHTML = ''
const printers = ui.printers || []
if (!printers.length) {
const option = document.createElement('option')
option.value = ''
option.textContent = '暂未识别到打印机'
select.append(option)
return
}
printers.forEach((printer) => {
const option = document.createElement('option')
option.value = printer.name
option.textContent = printer.name || '未命名打印机'
if (printer.name === ui.config?.selectedPrinterName) {
option.selected = true
}
select.append(option)
})
}
function renderDriverResources() {
const resources = ui.driverResources
if (!resources) {
return
}
$('official-driver-title').textContent =
resources.officialLatest.title || '芯烨官方最新版驱动'
$('official-driver-url').textContent = resources.officialLatest.sourceUrl || '-'
$('archive-driver-version').textContent = `版本 ${resources.companyArchive.version || '-'}`
$('archive-driver-path').textContent =
resources.companyArchive.downloadUrl || resources.companyArchive.relativePath || '-'
}
function renderRuntimeState() {
const runtimeState = ui.runtimeState || {}
const config = ui.config || {}
$('runtime-chip').textContent = config.deviceToken ? '已配对' : '未配对'
$('terminal-id').textContent = config.terminalId ?? '-'
$('token-status').textContent = config.deviceToken ? '已保存' : '未配对'
$('current-version').textContent = runtimeState.appVersion || '-'
$('latest-version').textContent = runtimeState.latestReleaseVersion || '-'
$('update-status').textContent = runtimeState.updateStatusMessage || '-'
$('last-heartbeat').textContent = runtimeState.lastHeartbeatAt || '-'
$('last-claim').textContent = runtimeState.lastClaimAt || '-'
$('last-job-result').textContent = runtimeState.lastJobResult || '-'
$('last-error').textContent = runtimeState.lastError || '-'
$('log-output').textContent = (runtimeState.logLines || []).join('\n')
}
function renderConfig() {
const config = ui.config || {}
$('server-url').value = config.serverUrl || ''
$('terminal-name').value = config.terminalName || ''
$('pair-code').value = config.pairingCode || ''
renderPrinterOptions()
renderDriverResources()
renderRuntimeState()
}
async function saveConfigPatch(patch) {
const result = await window.printAgent.saveConfig(patch)
ui.config = result.config
ui.driverResources = result.driverResources
renderConfig()
}
function currentFormValues() {
return {
serverUrl: $('server-url').value.trim(),
terminalName: $('terminal-name').value.trim(),
pairCode: $('pair-code').value.trim(),
selectedPrinterName: $('printer-select').value
}
}
async function bootstrap() {
const result = await window.printAgent.bootstrap()
ui.config = result.config
ui.printers = result.printers
ui.driverResources = result.driverResources
ui.runtimeState = result.runtimeState
renderConfig()
}
function bindEvents() {
$('pair-form').addEventListener('submit', async (event) => {
event.preventDefault()
const values = currentFormValues()
try {
const result = await window.printAgent.pair(values)
ui.config = result.config
$('pair-code').value = ''
renderConfig()
setFlashMessage(
`配对成功,终端 ID:${result.pairResult.terminalId || result.pairResult.terminal_id}`
)
} catch (error) {
setFlashMessage(`配对失败:${error.message}`, true)
}
})
$('save-config-button').addEventListener('click', async () => {
try {
await saveConfigPatch(currentFormValues())
setFlashMessage('本地配置已保存。')
} catch (error) {
setFlashMessage(`保存失败:${error.message}`, true)
}
})
$('save-printer-button').addEventListener('click', async () => {
try {
await saveConfigPatch({
selectedPrinterName: $('printer-select').value
})
setFlashMessage('打印机选择已保存。')
} catch (error) {
setFlashMessage(`保存打印机失败:${error.message}`, true)
}
})
$('refresh-printers-button').addEventListener('click', async () => {
try {
ui.printers = await window.printAgent.listPrinters()
renderPrinterOptions()
setFlashMessage('打印机列表已刷新。')
} catch (error) {
setFlashMessage(`刷新失败:${error.message}`, true)
}
})
$('test-print-button').addEventListener('click', async () => {
try {
await saveConfigPatch({
selectedPrinterName: $('printer-select').value
})
const result = await window.printAgent.testPrint()
setFlashMessage(result.message)
} catch (error) {
setFlashMessage(`测试打印失败:${error.message}`, true)
}
})
$('heartbeat-button').addEventListener('click', async () => {
try {
await window.printAgent.heartbeatNow()
setFlashMessage('心跳已发送。')
} catch (error) {
setFlashMessage(`心跳失败:${error.message}`, true)
}
})
$('claim-button').addEventListener('click', async () => {
try {
const result = await window.printAgent.claimNow()
setFlashMessage(result ? '已完成一次远程标签处理。' : '当前没有可领取任务。')
} catch (error) {
setFlashMessage(`领取任务失败:${error.message}`, true)
}
})
$('open-official-driver-button').addEventListener('click', async () => {
const url = ui.driverResources?.officialLatest?.sourceUrl
if (url) {
await window.printAgent.openExternal(url)
}
})
$('open-archive-driver-button').addEventListener('click', async () => {
const url = ui.driverResources?.companyArchive?.downloadUrl
if (url) {
await window.printAgent.openExternal(url)
}
})
window.printAgent.onStateChange((runtimeState) => {
ui.runtimeState = runtimeState
renderRuntimeState()
})
}
bindEvents()
bootstrap().catch((error) => {
setFlashMessage(`初始化失败:${error.message}`, true)
})

View File

@ -0,0 +1,274 @@
export function createAgentRuntime({
store,
clientFactory,
getPrinters,
jobProcessor,
appVersion = '',
setIntervalImpl = globalThis.setInterval,
clearIntervalImpl = globalThis.clearInterval,
onStateChange = () => {}
}) {
const state = {
running: false,
config: null,
printers: [],
appVersion,
latestReleaseVersion: '',
updateStatusMessage: '',
lastHeartbeatAt: '',
lastClaimAt: '',
lastJobResult: '',
lastError: '',
logLines: []
}
let heartbeatTimer = null
let pollTimer = null
function pushLog(message) {
const line = `${new Date().toLocaleString('zh-CN', { hour12: false })} ${message}`
state.logLines = [line, ...state.logLines].slice(0, 40)
onStateChange({ ...state })
}
async function loadConfig() {
state.config = await store.load()
onStateChange({ ...state })
return state.config
}
async function refreshPrinters() {
state.printers = await getPrinters()
onStateChange({ ...state })
return state.printers
}
function buildLabelProfile() {
return {
paper_size: '60x40mm',
width_mm: 60,
height_mm: 40,
orientation: 'landscape',
scale_mode: 'fit'
}
}
function getClient() {
return clientFactory({
serverUrl: state.config?.serverUrl,
getDeviceToken: () => state.config?.deviceToken || ''
})
}
async function checkForUpdates() {
if (!state.config?.serverUrl) {
state.updateStatusMessage = state.appVersion
? `当前版本 ${state.appVersion}`
: '未配置版本信息'
onStateChange({ ...state })
return null
}
try {
const client = getClient()
const releaseInfo = await client.getPrintAgentReleases()
const latestVersion =
releaseInfo?.latest_version ||
(Array.isArray(releaseInfo?.releases) ? releaseInfo.releases[0]?.version : '') ||
''
state.latestReleaseVersion = latestVersion
state.updateStatusMessage =
latestVersion && latestVersion !== state.appVersion
? `发现新版本 ${latestVersion},可从后台下载更新`
: state.appVersion
? `当前已是最新版本 ${state.appVersion}`
: latestVersion
? `服务器当前版本 ${latestVersion}`
: '暂未读取到版本信息'
onStateChange({ ...state })
return releaseInfo
} catch (error) {
state.updateStatusMessage = '版本检查失败,请稍后重试'
onStateChange({ ...state })
return null
}
}
async function heartbeatOnce() {
if (!state.config) {
await loadConfig()
}
if (!state.config?.deviceToken || !state.config?.terminalId) {
return {
skipped: true,
message: '尚未完成配对'
}
}
const printers = await refreshPrinters()
const client = getClient()
const payload = {
terminalId: state.config?.terminalId,
terminalName: state.config?.terminalName,
selectedPrinterName: state.config?.selectedPrinterName,
printerSnapshot: printers,
labelProfile: buildLabelProfile(),
ready:
Boolean(state.config?.deviceToken) && Boolean(state.config?.selectedPrinterName)
}
try {
const response = await client.sendHeartbeat(payload)
state.lastHeartbeatAt = new Date().toISOString()
state.lastError = ''
pushLog(`心跳已发送:${response.message || '服务器已接收'}`)
return response
} catch (error) {
state.lastError = error.message
pushLog(`心跳失败:${error.message}`)
throw error
}
}
async function pollOnce() {
if (!state.config?.deviceToken || !state.config?.terminalId) {
return null
}
const client = getClient()
try {
const claimResult = await client.claimNextJob({
terminalId: state.config.terminalId
})
state.lastClaimAt = new Date().toISOString()
if (!claimResult?.job) {
return null
}
const job = claimResult.job
await client.reportJobStatus(job.id, {
status: 'printing'
})
const processingResult = await jobProcessor.process(job, {
selectedPrinterName: state.config?.selectedPrinterName
})
state.lastJobResult = processingResult.message || processingResult.errorMessage || ''
await client.reportJobStatus(job.id, {
status: processingResult.nextStatus,
errorMessage: processingResult.errorMessage || null
})
pushLog(
processingResult.ok
? `任务 ${job.id} 已发送到 ${state.config?.selectedPrinterName || '当前打印机'}。`
: `任务 ${job.id} 打印失败:${processingResult.errorMessage}`
)
return processingResult
} catch (error) {
state.lastError = error.message
pushLog(`领取任务失败:${error.message}`)
throw error
}
}
async function runTestPrint() {
if (!state.config) {
await loadConfig()
}
const printers = await refreshPrinters()
const result = await jobProcessor.runTestPrint({
selectedPrinterName: state.config?.selectedPrinterName
})
if (state.config?.deviceToken && state.config?.terminalId) {
try {
const client = getClient()
await client.requestTestPrint({
selectedPrinterName: state.config?.selectedPrinterName,
printerSnapshot: printers,
labelProfile: buildLabelProfile(),
success: result.ok,
errorMessage: result.ok ? null : result.message
})
} catch (error) {
state.lastError = error.message
pushLog(`测试打印结果回写失败:${error.message}`)
}
}
state.lastJobResult = result.message
pushLog(result.message)
return result
}
async function updateConfig(patch) {
state.config = await store.save(patch)
onStateChange({ ...state })
return state.config
}
async function start() {
if (heartbeatTimer) {
clearIntervalImpl(heartbeatTimer)
heartbeatTimer = null
}
if (pollTimer) {
clearIntervalImpl(pollTimer)
pollTimer = null
}
const config = await loadConfig()
await refreshPrinters()
await checkForUpdates()
state.running = true
onStateChange({ ...state })
await heartbeatOnce().catch(() => {})
await pollOnce().catch(() => {})
heartbeatTimer = setIntervalImpl(() => {
heartbeatOnce().catch(() => {})
}, config.heartbeatIntervalMs)
pollTimer = setIntervalImpl(() => {
pollOnce().catch(() => {})
}, config.pollIntervalMs)
}
function stop() {
state.running = false
if (heartbeatTimer) {
clearIntervalImpl(heartbeatTimer)
heartbeatTimer = null
}
if (pollTimer) {
clearIntervalImpl(pollTimer)
pollTimer = null
}
onStateChange({ ...state })
}
return {
getState() {
return { ...state }
},
loadConfig,
refreshPrinters,
checkForUpdates,
heartbeatOnce,
pollOnce,
runTestPrint,
updateConfig,
start,
stop
}
}

View File

@ -0,0 +1,166 @@
function trimBaseUrl(serverUrl) {
return String(serverUrl || '')
.replace(/\/+$/, '')
.replace(/\/api$/i, '')
}
function unwrapEnvelope(payload) {
if (payload && typeof payload === 'object' && 'data' in payload && 'code' in payload) {
const data = payload.data
if (data && typeof data === 'object' && !Array.isArray(data)) {
return {
...data,
message: data.message || payload.message || ''
}
}
return data
}
return payload
}
async function parseResponse(response) {
let data = {}
if (typeof response.text === 'function') {
const text = await response.text()
data = text ? JSON.parse(text) : {}
} else if (typeof response.json === 'function') {
data = (await response.json()) || {}
}
if (!response.ok) {
const error = new Error(data.message || '打印助手请求失败')
error.status = response.status
error.payload = data
throw error
}
return unwrapEnvelope(data)
}
export function createPrintAgentClient({
serverUrl,
getDeviceToken = () => '',
fetchImpl = globalThis.fetch
}) {
if (typeof fetchImpl !== 'function') {
throw new Error('当前运行环境不支持 fetch,请补充 fetch 实现')
}
async function request(endpoint, { method = 'GET', body, auth = true } = {}) {
const headers = {
'Content-Type': 'application/json'
}
if (auth) {
const deviceToken = getDeviceToken()
if (deviceToken) {
headers.Authorization = `Bearer ${deviceToken}`
}
}
const response = await fetchImpl(`${trimBaseUrl(serverUrl)}${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined
})
return parseResponse(response)
}
const toSnakeCaseBody = (body = {}) => {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return body
}
const mapped = { ...body }
if ('pairCode' in mapped) {
mapped.code = mapped.pairCode
delete mapped.pairCode
}
if ('terminalName' in mapped) {
mapped.terminal_name = mapped.terminalName
delete mapped.terminalName
}
if ('machineName' in mapped) {
mapped.machine_name = mapped.machineName
delete mapped.machineName
}
if ('selectedPrinterName' in mapped) {
mapped.selected_printer_name = mapped.selectedPrinterName
delete mapped.selectedPrinterName
}
if ('printerSnapshot' in mapped) {
mapped.printer_snapshot = mapped.printerSnapshot
delete mapped.printerSnapshot
}
if ('labelProfile' in mapped) {
mapped.label_profile = mapped.labelProfile
delete mapped.labelProfile
}
if ('errorMessage' in mapped) {
mapped.error_message = mapped.errorMessage
delete mapped.errorMessage
}
if ('retryable' in mapped) {
mapped.retryable = Boolean(mapped.retryable)
}
if ('terminalId' in mapped) {
mapped.terminal_id = mapped.terminalId
delete mapped.terminalId
}
return mapped
}
return {
pair(payload) {
return request('/api/print-agent/pair', {
method: 'POST',
auth: false,
body: toSnakeCaseBody(payload)
})
},
sendHeartbeat(payload) {
return request('/api/print-agent/heartbeat', {
method: 'POST',
body: toSnakeCaseBody(payload)
})
},
claimNextJob(payload) {
return request('/api/print-agent/jobs/claim', {
method: 'POST',
body: toSnakeCaseBody(payload)
})
},
reportJobStatus(jobId, payload) {
return request(`/api/print-agent/jobs/${jobId}/status`, {
method: 'POST',
body: toSnakeCaseBody(payload)
})
},
requestTestPrint(payload) {
return request('/api/print-agent/test-print', {
method: 'POST',
body: toSnakeCaseBody(payload)
})
},
getPrintAgentReleases() {
return request('/downloads/print-agent/releases', {
method: 'GET',
auth: false
})
}
}
}

View File

@ -0,0 +1,61 @@
import fs from 'node:fs/promises'
import path from 'node:path'
export const DEFAULT_DRIVER_RESOURCES = {
officialLatest: {
version: 'latest',
title: '芯烨官方最新版驱动',
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/',
checkedAt: '2026-03-25T00:00:00.000+08:00'
},
companyArchive: {
version: '2026.01.28.01',
fileName: 'xprinter-driver-2026.01.28.01.exe',
relativePath:
'downloads/printer-driver/xprinter/archive/2026.01.28.01/xprinter-driver-2026.01.28.01.exe',
sourcePath:
'D:\\Users\\54972\\Downloads\\芯烨windows一键安装版驱动2026.01.28.01.exe'
}
}
export async function loadDriverResources(repoRoot) {
const manifestPaths = [
path.join(repoRoot, 'resources', 'driver-resources.json'),
path.join(repoRoot, 'print-agent', 'resources', 'driver-resources.json')
]
for (const manifestPath of manifestPaths) {
try {
const content = await fs.readFile(manifestPath, 'utf8')
const manifest = JSON.parse(content)
return {
...DEFAULT_DRIVER_RESOURCES,
...manifest,
officialLatest: {
...DEFAULT_DRIVER_RESOURCES.officialLatest,
...manifest.officialLatest
},
companyArchive: {
...DEFAULT_DRIVER_RESOURCES.companyArchive,
...manifest.companyArchive
}
}
} catch (error) {
if (error && error.code === 'ENOENT') {
continue
}
throw error
}
}
return DEFAULT_DRIVER_RESOURCES
}
export function resolveDriverDownloadUrl(serverUrl, manifest) {
const baseUrl = String(serverUrl || '')
.replace(/\/+$/, '')
.replace(/\/api$/i, '')
const relativePath = String(manifest.relativePath || '').replace(/^\/+/, '')
return `${baseUrl}/${relativePath}`
}

View File

@ -0,0 +1,101 @@
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { BrowserWindow } from 'electron'
const LABEL_PAGE_SIZE_MICRONS = {
width: 60000,
height: 40000
}
const waitForPrintableContent = async (win) => {
await win.webContents.executeJavaScript(`
new Promise((resolve) => {
const waitForImages = () => {
const images = Array.from(document.images || [])
Promise.all(
images.map((img) =>
img.complete
? Promise.resolve()
: new Promise((done) => {
img.addEventListener('load', done, { once: true })
img.addEventListener('error', done, { once: true })
})
)
).then(() => setTimeout(resolve, 120))
}
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(waitForImages).catch(waitForImages)
} else {
waitForImages()
}
})
`)
}
export const printHtmlSilently = async ({
html,
printerName,
jobName = '固定资产标签打印'
}) => {
const resolvedPrinterName = String(printerName ?? '').trim()
if (!resolvedPrinterName) {
throw new Error('尚未选择标签打印机,请先完成打印机配置。')
}
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gdzc-print-'))
const htmlPath = path.join(tempDir, 'print.html')
await fs.writeFile(htmlPath, html, 'utf8')
const printWindow = new BrowserWindow({
show: false,
width: 980,
height: 720,
backgroundColor: '#ffffff',
webPreferences: {
sandbox: false,
backgroundThrottling: false
}
})
try {
await printWindow.loadFile(htmlPath)
await waitForPrintableContent(printWindow)
await new Promise((resolve, reject) => {
printWindow.webContents.print(
{
silent: true,
printBackground: true,
deviceName: resolvedPrinterName,
margins: {
marginType: 'none'
},
pageSize: LABEL_PAGE_SIZE_MICRONS
},
(success, failureReason) => {
if (success) {
resolve(true)
return
}
reject(
new Error(
failureReason || `${jobName}失败,请检查打印机是否在线、驱动是否正常。`
)
)
}
)
})
} finally {
if (!printWindow.isDestroyed()) {
printWindow.destroy()
}
await fs.rm(tempDir, {
recursive: true,
force: true
})
}
}

View File

@ -0,0 +1,121 @@
const normalizePrinterName = (printerName) => String(printerName ?? '').trim()
const parsePayload = (job) => {
if (job?.payload && typeof job.payload === 'object') {
return job.payload
}
if (typeof job?.payload_json === 'string') {
try {
return JSON.parse(job.payload_json)
} catch {
return null
}
}
if (job?.payload_json && typeof job.payload_json === 'object') {
return job.payload_json
}
return null
}
const getAssetLabelItems = (job) => {
const payload = parsePayload(job)
if (!payload || !Array.isArray(payload.items)) {
return null
}
return payload.items
}
export function createTestPrintSuccessMessage(printerName) {
const resolvedPrinterName = normalizePrinterName(printerName) || '已选打印机'
return `测试标签已发送到 ${resolvedPrinterName},请确认是否按 60x40mm 正常出纸。`
}
export function createJobProcessor({
printAssetLabels = async () => {
throw new Error('尚未注入真实资产标签打印执行器')
},
printTestLabel = async () => {
throw new Error('尚未注入真实测试标签打印执行器')
}
} = {}) {
return {
async process(job, { selectedPrinterName } = {}) {
if (job?.job_type === 'asset_label') {
const printerName = normalizePrinterName(selectedPrinterName)
if (!printerName) {
return {
ok: false,
nextStatus: 'failed',
errorMessage: '尚未选择标签打印机,请先在打印助手里完成打印机配置。'
}
}
const items = getAssetLabelItems(job)
if (!items?.length) {
return {
ok: false,
nextStatus: 'failed',
errorMessage: '当前打印任务缺少可用的资产标签数据,已阻止实际出纸。'
}
}
try {
const printResult = await printAssetLabels({
job,
items,
printerName
})
const printedCount =
typeof printResult?.printed_count === 'number' ? printResult.printed_count : items.length
return {
ok: true,
nextStatus: 'completed',
message: `已发送到 ${printerName} 打印,准备输出 ${printedCount} 张资产标签。`
}
} catch (error) {
return {
ok: false,
nextStatus: 'failed',
errorMessage: error instanceof Error ? error.message : '资产标签打印失败'
}
}
}
return {
ok: true,
nextStatus: 'completed',
message: '当前任务类型未实现,已跳过。'
}
},
async runTestPrint({ selectedPrinterName }) {
const printerName = normalizePrinterName(selectedPrinterName)
if (!printerName) {
return {
ok: false,
message: '尚未选择标签打印机,请先保存打印机后再做测试打印。'
}
}
try {
await printTestLabel({
printerName
})
return {
ok: true,
message: createTestPrintSuccessMessage(printerName)
}
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : '测试打印失败'
}
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More