feat: 重构工单和物品打印模板为左右排布,支持二维码和条码

- 工单模板:左侧二维码+客户,右侧工单ID/标题/描述
- 物品模板:左侧二维码,右侧物品ID/名称/数量/位置/客户,底部条码+序列号
- 打印标签设置:新增标签宽度/高度/打印前回退长度设置
- QR码JSON增加type=ops_sys字段区分系统来源
- WorkOrder/Item模型增加linkedCustomers等关联数据
- 使用Canvas绘制Bitmap实现自定义打印布局
This commit is contained in:
2026-06-26 19:52:52 +08:00
parent ae07e12200
commit 6bc653bb74
10 changed files with 377 additions and 27 deletions
@@ -28,6 +28,9 @@ class SessionManager(private val context: Context) {
private val KEY_COOKIE_REMEMBER = booleanPreferencesKey("cookie_remember")
private val KEY_PRINTER_BLACK_MARK = booleanPreferencesKey("printer_black_mark")
private val KEY_PRINTER_UNWIND_MM = intPreferencesKey("printer_unwind_mm")
private val KEY_LABEL_WIDTH_MM = intPreferencesKey("label_width_mm")
private val KEY_LABEL_HEIGHT_MM = intPreferencesKey("label_height_mm")
private val KEY_PRINT_RETRACT_MM = intPreferencesKey("print_retract_mm")
}
@Volatile
@@ -46,6 +49,18 @@ class SessionManager(private val context: Context) {
var cachedUnwindMm: Int = 40
private set
@Volatile
var cachedLabelWidthMm: Int = 53
private set
@Volatile
var cachedLabelHeightMm: Int = 40
private set
@Volatile
var cachedPrintRetractMm: Int = 10
private set
fun clearCookieCache() {
cachedCookieValue = ""
}
@@ -59,6 +74,9 @@ class SessionManager(private val context: Context) {
cachedCookieValue = prefs[KEY_COOKIE_VALUE] ?: ""
cachedBlackMarkEnabled = prefs[KEY_PRINTER_BLACK_MARK] ?: true
cachedUnwindMm = prefs[KEY_PRINTER_UNWIND_MM] ?: 40
cachedLabelWidthMm = prefs[KEY_LABEL_WIDTH_MM] ?: 53
cachedLabelHeightMm = prefs[KEY_LABEL_HEIGHT_MM] ?: 40
cachedPrintRetractMm = prefs[KEY_PRINT_RETRACT_MM] ?: 10
}
suspend fun getApiBaseUrl(): String = cachedBaseUrl.ifEmpty {
@@ -115,4 +133,19 @@ class SessionManager(private val context: Context) {
cachedUnwindMm = mm
context.dataStore.edit { it[KEY_PRINTER_UNWIND_MM] = mm }
}
suspend fun setLabelWidthMm(mm: Int) {
cachedLabelWidthMm = mm
context.dataStore.edit { it[KEY_LABEL_WIDTH_MM] = mm }
}
suspend fun setLabelHeightMm(mm: Int) {
cachedLabelHeightMm = mm
context.dataStore.edit { it[KEY_LABEL_HEIGHT_MM] = mm }
}
suspend fun setPrintRetractMm(mm: Int) {
cachedPrintRetractMm = mm
context.dataStore.edit { it[KEY_PRINT_RETRACT_MM] = mm }
}
}
@@ -1,5 +1,6 @@
package com.example.ops_android.data.model
import com.example.ops_android.data.remote.api.LinkedCustomerInfo
import com.google.gson.annotations.SerializedName
data class Item(
@@ -14,7 +15,10 @@ data class Item(
@SerializedName("CreatorID") val user_id: Int? = null,
@SerializedName("barcode") private val _barcode: String? = null,
@SerializedName("spec") private val _spec: String? = null,
@SerializedName("move_history") private val _move_history: List<MoveHistory>? = null
@SerializedName("move_history") private val _move_history: List<MoveHistory>? = null,
// Filled from detail API
@kotlin.jvm.Transient val linkedCustomers: List<LinkedCustomerInfo>? = null,
@kotlin.jvm.Transient val containerBreadcrumb: String? = null
) {
val barcode: String? get() = _barcode
val spec: String? get() = _spec
@@ -1,5 +1,6 @@
package com.example.ops_android.data.model
import com.example.ops_android.data.remote.api.LinkedCustomer
import com.google.gson.annotations.SerializedName
data class WorkOrder(
@@ -11,7 +12,8 @@ data class WorkOrder(
@SerializedName("UpdatedAt") val updated_at: String? = null,
@SerializedName("UserID") val user_id: Int? = null,
// Filled from detail API
@kotlin.jvm.Transient val commits: List<Commit>? = null
@kotlin.jvm.Transient val commits: List<Commit>? = null,
@kotlin.jvm.Transient val linkedCustomers: List<LinkedCustomer>? = null
)
data class WorkOrderCommit(
@@ -101,7 +101,12 @@ class WarehouseRepository(
return try {
val response = warehouseApi.getItem(GetItemRequest(id))
if (response.errCode == 0 && response.data != null) {
Result.success(response.data)
val resp = response.data
val itm = resp.item?.copy(
linkedCustomers = resp.customers,
containerBreadcrumb = resp.container_breadcrumb
)
Result.success(resp.copy(item = itm))
} else {
Result.failure(Exception("获取物品详情失败"))
}
@@ -29,7 +29,11 @@ class WorkOrderRepository(
return try {
val response = workOrderApi.get(WorkOrderGetRequest(id))
if (response.errCode == 0 && response.data?.order != null) {
Result.success(response.data.order)
val detail = response.data
val wo = detail.order.copy(
linkedCustomers = detail.linkedCustomers
)
Result.success(wo)
} else {
Result.failure(Exception("获取工单详情失败"))
}
@@ -1,16 +1,148 @@
package com.example.ops_android.printer
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import com.example.ops_android.data.model.Item
import com.example.ops_android.data.model.PurchaseOrder
import com.example.ops_android.data.model.WorkOrder
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.oned.Code128Writer
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object PrintHelper {
private val printMutex = Mutex()
private const val DOTS_PER_MM = 8
private fun mmToDots(mm: Int): Int = mm * DOTS_PER_MM
private fun buildQrContent(module: String, id: Int): String {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
return JSONObject().apply {
put("v", "v1")
put("module", module)
put("id", id)
put("printedAt", sdf.format(Date()))
put("type", "ops_sys")
}.toString()
}
private fun generateQRBitmap(content: String, size: Int): Bitmap {
val hints = mapOf(EncodeHintType.MARGIN to 1)
val bitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
for (y in 0 until size) {
for (x in 0 until size) {
bitmap.setPixel(x, y, if (bitMatrix[x, y]) Color.BLACK else Color.WHITE)
}
}
return bitmap
}
private fun drawTextBlock(
canvas: Canvas,
label: String,
value: String,
x: Float,
y: Float,
maxWidth: Float,
maxLines: Int = 1,
textSize: Float = 22f,
bold: Boolean = false,
lineSpacingExtra: Float = 4f
): Float {
val text = "$label$value"
val paint = TextPaint().apply {
color = Color.BLACK
this.textSize = textSize
isAntiAlias = true
isFakeBoldText = bold
}
val builder = StaticLayout.Builder.obtain(text, 0, text.length, paint, maxWidth.toInt())
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setMaxLines(maxLines)
.setEllipsize(android.text.TextUtils.TruncateAt.END)
.setLineSpacing(lineSpacingExtra, 1f)
val layout = builder.build()
canvas.save()
canvas.translate(x, y)
layout.draw(canvas)
canvas.restore()
return y + layout.height
}
private fun buildWorkOrderLabelBitmap(wo: WorkOrder, widthDots: Int, heightDots: Int): Bitmap {
val bitmap = Bitmap.createBitmap(widthDots, heightDots, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.WHITE)
val padding = 12
val dividerX = (widthDots * 0.48).toInt()
val leftInnerWidth = dividerX - padding * 2
val qrSize = (leftInnerWidth * 0.95).toInt().coerceAtLeast(140)
val qrX = (dividerX - qrSize) / 2
val qrY = padding
val qrContent = buildQrContent("workorder", wo.id)
val qrBitmap = generateQRBitmap(qrContent, qrSize)
canvas.drawBitmap(qrBitmap, qrX.toFloat(), qrY.toFloat(), null)
val sepPaint = Paint().apply {
color = Color.BLACK
strokeWidth = 1.5f
isAntiAlias = true
}
canvas.drawLine(dividerX.toFloat(), padding.toFloat(), dividerX.toFloat(), (heightDots - padding).toFloat(), sepPaint)
val textX = (dividerX + padding).toFloat()
val textMaxWidth = (widthDots - dividerX - padding * 2).toFloat()
var textY = (padding - 2).toFloat()
val maxTextY = (heightDots - padding).toFloat()
textY = drawTextBlock(canvas, "", "工单:${wo.id}", textX, textY, textMaxWidth, textSize = 32f, bold = true, lineSpacingExtra = 6f)
if (textY < maxTextY) {
textY = drawTextBlock(canvas, "", "标题:${wo.title ?: "-"}", textX, textY, textMaxWidth, maxLines = 3, textSize = 28f, lineSpacingExtra = 4f)
}
if (textY < maxTextY) {
textY = drawTextBlock(canvas, "", "描述:${wo.description ?: "-"}", textX, textY, textMaxWidth, maxLines = 6, textSize = 28f, lineSpacingExtra = 4f)
}
val customerNames = wo.linkedCustomers
?.mapNotNull { c ->
listOfNotNull(c.first_name, c.last_name)
.joinToString(" ")
.ifBlank { null }
}
?.joinToString(", ")
?: "-"
val customerText = "客户:$customerNames"
val customerY = qrY + qrSize + 4
val leftTextMaxWidth = (dividerX - padding * 2).toFloat()
if (customerY < heightDots - padding) {
drawTextBlock(canvas, "", customerText, padding.toFloat(), customerY.toFloat(), leftTextMaxWidth, maxLines = 2, textSize = 28f, lineSpacingExtra = 4f)
}
return bitmap
}
suspend fun printOrderLabel(
printerManager: PrinterManager,
@@ -45,20 +177,22 @@ object PrintHelper {
suspend fun printWorkOrderLabel(
printerManager: PrinterManager,
workOrder: WorkOrder,
labelWidthMm: Int = 53,
labelHeightMm: Int = 40,
blackMarkEnabled: Boolean = true,
unwindMm: Int = 40
unwindMm: Int = 40,
printRetractMm: Int = 10
): Result<Unit> = printMutex.withLock {
withContext(Dispatchers.IO) {
try {
printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
printerManager.setFontSize(24)
printerManager.setBold(true)
printerManager.addText("工单: ${workOrder.title ?: "-"}\n")
printerManager.setFontSize(20)
printerManager.setBold(false)
if (workOrder.id > 0) {
printerManager.addBarcode("WO-${workOrder.id}")
if (printRetractMm > 0) {
printerManager.setUnwindLength(mmToDots(printRetractMm))
}
val widthDots = mmToDots(labelWidthMm)
val heightDots = mmToDots(labelHeightMm)
val bitmap = buildWorkOrderLabelBitmap(workOrder, widthDots, heightDots)
printerManager.printImage(bitmap, 1)
if (blackMarkEnabled) {
printerManager.addLineFeed(2)
printerManager.enableMarkDetection(true)
@@ -72,24 +206,109 @@ object PrintHelper {
}
}
private fun buildItemLabelBitmap(item: Item, widthDots: Int, heightDots: Int): Bitmap {
val bitmap = Bitmap.createBitmap(widthDots, heightDots, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.WHITE)
val padding = 12
val barcodeReserve = if (!item.serial_number.isNullOrBlank()) 70 else 0
val dividerX = (widthDots * 0.48).toInt()
val leftInnerWidth = dividerX - padding * 2
val qrSize = (leftInnerWidth * 0.95).toInt().coerceAtLeast(140)
val qrX = (dividerX - qrSize) / 2
val qrY = padding
val qrContent = buildQrContent("item", item.id)
val qrBitmap = generateQRBitmap(qrContent, qrSize)
canvas.drawBitmap(qrBitmap, qrX.toFloat(), qrY.toFloat(), null)
val sepPaint = Paint().apply {
color = Color.BLACK
strokeWidth = 1.5f
isAntiAlias = true
}
val textX = (dividerX + padding).toFloat()
val textMaxWidth = (widthDots - dividerX - padding * 2).toFloat()
var textY = (padding - 2).toFloat()
val maxTextY = heightDots - padding - barcodeReserve
textY = drawTextBlock(canvas, "", "物品:${item.id}", textX, textY, textMaxWidth, textSize = 32f, bold = true, lineSpacingExtra = 6f)
if (textY < maxTextY) {
textY = drawTextBlock(canvas, "", "名称:${item.name ?: "-"}", textX, textY, textMaxWidth, maxLines = 3, textSize = 25f, lineSpacingExtra = 4f)
}
if (textY < maxTextY && item.quantity != 1) {
textY = drawTextBlock(canvas, "", "数量:${item.quantity}", textX, textY, textMaxWidth, textSize = 25f, lineSpacingExtra = 4f)
}
if (textY < maxTextY && !item.containerBreadcrumb.isNullOrBlank()) {
textY = drawTextBlock(canvas, "", "位置:${item.containerBreadcrumb}", textX, textY, textMaxWidth, maxLines = 4, textSize = 25f, lineSpacingExtra = 4f)
}
if (textY < maxTextY && !item.linkedCustomers.isNullOrEmpty()) {
val customerNames = item.linkedCustomers
.mapNotNull { c ->
listOfNotNull(c.first_name, c.last_name)
.joinToString(" ")
.ifBlank { null }
}
.joinToString(", ")
textY = drawTextBlock(canvas, "", "客户:$customerNames", textX, textY, textMaxWidth, maxLines = 2, textSize = 25f, lineSpacingExtra = 4f)
}
canvas.drawLine(dividerX.toFloat(), padding.toFloat(), dividerX.toFloat(), maxTextY.toFloat(), sepPaint)
if (!item.serial_number.isNullOrBlank()) {
canvas.drawLine(padding.toFloat(), maxTextY.toFloat(), (widthDots - padding).toFloat(), maxTextY.toFloat(), sepPaint)
val snTextY = maxTextY + 4
val snTextHeight = drawTextBlock(canvas, "", item.serial_number ?: "",
padding.toFloat(), snTextY.toFloat(),
(widthDots - padding * 2).toFloat(),
maxLines = 1, textSize = 22f, lineSpacingExtra = 0f
) - snTextY
val barcodeWidth = widthDots - padding * 4
val barcodeHeight = 35
val barcodeX = (widthDots - barcodeWidth) / 2
val barcodeY = snTextY + snTextHeight + 4
val code128 = Code128Writer()
val barcodeMatrix = code128.encode(item.serial_number, BarcodeFormat.CODE_128, barcodeWidth, barcodeHeight)
val barcodeBitmap = Bitmap.createBitmap(barcodeWidth, barcodeHeight, Bitmap.Config.ARGB_8888)
for (y in 0 until barcodeHeight) {
for (x in 0 until barcodeWidth) {
barcodeBitmap.setPixel(x, y, if (barcodeMatrix[x, y]) Color.BLACK else Color.WHITE)
}
}
canvas.drawBitmap(barcodeBitmap, barcodeX.toFloat(), barcodeY.toFloat(), null)
}
return bitmap
}
suspend fun printItemLabel(
printerManager: PrinterManager,
item: Item,
labelWidthMm: Int = 53,
labelHeightMm: Int = 40,
blackMarkEnabled: Boolean = true,
unwindMm: Int = 40
unwindMm: Int = 40,
printRetractMm: Int = 10
): Result<Unit> = printMutex.withLock {
withContext(Dispatchers.IO) {
try {
printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
printerManager.setFontSize(24)
printerManager.setBold(true)
printerManager.addText("${item.name ?: "-"}\n")
printerManager.setFontSize(18)
printerManager.setBold(false)
item.serial_number?.let { printerManager.addText("编号: $it\n") }
if (item.id > 0) {
printerManager.addBarcode("ITEM-${item.id}")
if (printRetractMm > 0) {
printerManager.setUnwindLength(mmToDots(printRetractMm))
}
val widthDots = mmToDots(labelWidthMm)
val heightDots = mmToDots(labelHeightMm)
val bitmap = buildItemLabelBitmap(item, widthDots, heightDots)
printerManager.printImage(bitmap, 1)
if (blackMarkEnabled) {
printerManager.addLineFeed(2)
printerManager.enableMarkDetection(true)
@@ -148,6 +148,49 @@ fun SettingsScreen(
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text("标签尺寸", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = uiState.labelWidthMm,
onValueChange = viewModel::onLabelWidthChange,
label = { Text("标签宽度 (mm)") },
suffix = { Text("mm") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = uiState.labelHeightMm,
onValueChange = viewModel::onLabelHeightChange,
label = { Text("标签高度 (mm)") },
suffix = { Text("mm") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Button(onClick = { viewModel.saveLabelSize() }, modifier = Modifier.fillMaxWidth()) {
Text("保存标签尺寸")
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text("打印校准", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
value = uiState.printRetractMm,
onValueChange = viewModel::onPrintRetractMmChange,
label = { Text("打印前回退长度 (mm)") },
suffix = { Text("mm") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Button(onClick = { viewModel.savePrintRetractMm() }, modifier = Modifier.fillMaxWidth()) {
Text("保存回退长度")
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "关于",
style = MaterialTheme.typography.titleMedium
@@ -25,7 +25,10 @@ data class SettingsUiState(
val testSuccess: Boolean = false,
val message: String? = null,
val blackMarkEnabled: Boolean = true,
val unwindMm: String = "40"
val unwindMm: String = "40",
val labelWidthMm: String = "53",
val labelHeightMm: String = "40",
val printRetractMm: String = "10"
)
class SettingsViewModel(
@@ -56,7 +59,10 @@ class SettingsViewModel(
private fun loadPrinterSettings() {
_uiState.value = _uiState.value.copy(
blackMarkEnabled = sessionManager.cachedBlackMarkEnabled,
unwindMm = sessionManager.cachedUnwindMm.toString()
unwindMm = sessionManager.cachedUnwindMm.toString(),
labelWidthMm = sessionManager.cachedLabelWidthMm.toString(),
labelHeightMm = sessionManager.cachedLabelHeightMm.toString(),
printRetractMm = sessionManager.cachedPrintRetractMm.toString()
)
}
@@ -83,6 +89,42 @@ class SettingsViewModel(
}
}
fun onLabelWidthChange(value: String) {
if (value.isEmpty() || value.all { it.isDigit() } && value.toIntOrNull()?.let { it in 10..200 } == true) {
_uiState.value = _uiState.value.copy(labelWidthMm = value)
}
}
fun onLabelHeightChange(value: String) {
if (value.isEmpty() || value.all { it.isDigit() } && value.toIntOrNull()?.let { it in 10..200 } == true) {
_uiState.value = _uiState.value.copy(labelHeightMm = value)
}
}
fun saveLabelSize() {
val width = _uiState.value.labelWidthMm.toIntOrNull() ?: 53
val height = _uiState.value.labelHeightMm.toIntOrNull() ?: 40
viewModelScope.launch {
sessionManager.setLabelWidthMm(width)
sessionManager.setLabelHeightMm(height)
_uiState.value = _uiState.value.copy(message = "标签尺寸已保存")
}
}
fun onPrintRetractMmChange(value: String) {
if (value.isEmpty() || value.all { it.isDigit() } && value.toIntOrNull()?.let { it in 0..50 } == true) {
_uiState.value = _uiState.value.copy(printRetractMm = value)
}
}
fun savePrintRetractMm() {
val mm = _uiState.value.printRetractMm.toIntOrNull() ?: 10
viewModelScope.launch {
sessionManager.setPrintRetractMm(mm)
_uiState.value = _uiState.value.copy(message = "回退长度已保存")
}
}
fun saveUrl() {
viewModelScope.launch {
val url = _uiState.value.apiBaseUrl.trim()
@@ -129,9 +129,7 @@ fun ItemDetailScreen(id: Int, onBack: () -> Unit, onCreateWorkOrder: (Int) -> Un
val printerManager = app.printerManager
if (printerManager != null) {
IconButton(onClick = {
scope.launch {
PrintHelper.printItemLabel(printerManager, itm, app.sessionManager.cachedBlackMarkEnabled, app.sessionManager.cachedUnwindMm)
}
scope.launch { PrintHelper.printItemLabel(printerManager, itm, labelWidthMm = app.sessionManager.cachedLabelWidthMm, labelHeightMm = app.sessionManager.cachedLabelHeightMm, blackMarkEnabled = app.sessionManager.cachedBlackMarkEnabled, unwindMm = app.sessionManager.cachedUnwindMm, printRetractMm = app.sessionManager.cachedPrintRetractMm) }
}) {
Icon(Icons.Default.Print, "打印", tint = Color.White)
}
@@ -69,7 +69,7 @@ fun WorkOrderDetailScreen(id: Int, onBack: () -> Unit) {
if (printerManager != null) {
val sm = com.example.ops_android.ui.LocalAppContainer.current.sessionManager
IconButton(onClick = {
scope.launch { com.example.ops_android.printer.PrintHelper.printWorkOrderLabel(printerManager, wo_, sm.cachedBlackMarkEnabled, sm.cachedUnwindMm) }
scope.launch { com.example.ops_android.printer.PrintHelper.printWorkOrderLabel(printerManager, wo_, labelWidthMm = sm.cachedLabelWidthMm, labelHeightMm = sm.cachedLabelHeightMm, blackMarkEnabled = sm.cachedBlackMarkEnabled, unwindMm = sm.cachedUnwindMm, printRetractMm = sm.cachedPrintRetractMm) }
}) { Icon(Icons.Default.Print, "打印", tint = Color.White) }
}
}