Compare commits

...
11 Commits
Author SHA1 Message Date
kevin 081884b723 Signed-off-by: 无闻风 <wuwenfengmi@gmail.com> 2026-06-26 20:47:09 +08:00
kevin b7dab20751 feat: 搜索页支持JSON扫码直达,去掉前缀提示
- 搜索输入框内容若是JSON则解码,type为ops_sys时根据module跳转(workorder→工单详情,item→物品详情)
- JSON解码后无论type是否为ops_sys都清空输入框
- 非JSON内容走原有关键字搜索逻辑
- 去掉前缀快捷搜索提示文本
2026-06-26 20:46:58 +08:00
kevin a7d81d42ea Signed-off-by: 无闻风 <wuwenfengmi@gmail.com> 2026-06-26 20:32:49 +08:00
kevin 85bcdfccaa Signed-off-by: 无闻风 <wuwenfengmi@gmail.com>
修改图标与应用名字
2026-06-26 20:20:52 +08:00
kevin 388c0dcfc5 Signed-off-by: 无闻风 <wuwenfengmi@gmail.com> 2026-06-26 20:02:08 +08:00
kevin 57c5362da3 fix: 物品打印模板客户移至左侧二维码下方 2026-06-26 19:57:06 +08:00
kevin b691545ad4 Signed-off-by: 无闻风 <wuwenfengmi@gmail.com> 2026-06-26 19:53:46 +08:00
kevin 6bc653bb74 feat: 重构工单和物品打印模板为左右排布,支持二维码和条码
- 工单模板:左侧二维码+客户,右侧工单ID/标题/描述
- 物品模板:左侧二维码,右侧物品ID/名称/数量/位置/客户,底部条码+序列号
- 打印标签设置:新增标签宽度/高度/打印前回退长度设置
- QR码JSON增加type=ops_sys字段区分系统来源
- WorkOrder/Item模型增加linkedCustomers等关联数据
- 使用Canvas绘制Bitmap实现自定义打印布局
2026-06-26 19:52:52 +08:00
kevin ae07e12200 up 2026-06-26 17:23:48 +08:00
kevin 7a9b3db3ca fix: 修复每次打印后关闭设备导致二次打印报设备未打开
移除 PrintHelper/PrintJob/PrinterManager/PrinterTestScreen 中每次打印后的 closeDevice() 调用,
设备生命周期由 MainActivity 管理(onStart 打开,onDestroy 关闭),避免反复 close->open 导致 SDK open() 失败。
2026-06-26 17:22:48 +08:00
kevin 71692a509e Signed-off-by: 无闻风 <wuwenfengmi@gmail.com>
up
2026-06-26 15:45:25 +08:00
47 changed files with 4799 additions and 114 deletions
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.lc_print_sdk"
android:versionCode="1"
android:versionName="1.0" >
<uses-sdk
android:minSdkVersion="15"
android:targetSdkVersion="30" />
</manifest>
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -28,6 +28,9 @@ class SessionManager(private val context: Context) {
private val KEY_COOKIE_REMEMBER = booleanPreferencesKey("cookie_remember") private val KEY_COOKIE_REMEMBER = booleanPreferencesKey("cookie_remember")
private val KEY_PRINTER_BLACK_MARK = booleanPreferencesKey("printer_black_mark") private val KEY_PRINTER_BLACK_MARK = booleanPreferencesKey("printer_black_mark")
private val KEY_PRINTER_UNWIND_MM = intPreferencesKey("printer_unwind_mm") 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 @Volatile
@@ -46,6 +49,18 @@ class SessionManager(private val context: Context) {
var cachedUnwindMm: Int = 40 var cachedUnwindMm: Int = 40
private set 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() { fun clearCookieCache() {
cachedCookieValue = "" cachedCookieValue = ""
} }
@@ -59,6 +74,9 @@ class SessionManager(private val context: Context) {
cachedCookieValue = prefs[KEY_COOKIE_VALUE] ?: "" cachedCookieValue = prefs[KEY_COOKIE_VALUE] ?: ""
cachedBlackMarkEnabled = prefs[KEY_PRINTER_BLACK_MARK] ?: true cachedBlackMarkEnabled = prefs[KEY_PRINTER_BLACK_MARK] ?: true
cachedUnwindMm = prefs[KEY_PRINTER_UNWIND_MM] ?: 40 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 { suspend fun getApiBaseUrl(): String = cachedBaseUrl.ifEmpty {
@@ -115,4 +133,19 @@ class SessionManager(private val context: Context) {
cachedUnwindMm = mm cachedUnwindMm = mm
context.dataStore.edit { it[KEY_PRINTER_UNWIND_MM] = 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 package com.example.ops_android.data.model
import com.example.ops_android.data.remote.api.LinkedCustomerInfo
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
data class Item( data class Item(
@@ -14,7 +15,10 @@ data class Item(
@SerializedName("CreatorID") val user_id: Int? = null, @SerializedName("CreatorID") val user_id: Int? = null,
@SerializedName("barcode") private val _barcode: String? = null, @SerializedName("barcode") private val _barcode: String? = null,
@SerializedName("spec") private val _spec: 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 barcode: String? get() = _barcode
val spec: String? get() = _spec val spec: String? get() = _spec
@@ -1,5 +1,6 @@
package com.example.ops_android.data.model package com.example.ops_android.data.model
import com.example.ops_android.data.remote.api.LinkedCustomer
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
data class WorkOrder( data class WorkOrder(
@@ -11,7 +12,8 @@ data class WorkOrder(
@SerializedName("UpdatedAt") val updated_at: String? = null, @SerializedName("UpdatedAt") val updated_at: String? = null,
@SerializedName("UserID") val user_id: Int? = null, @SerializedName("UserID") val user_id: Int? = null,
// Filled from detail API // 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( data class WorkOrderCommit(
@@ -101,7 +101,12 @@ class WarehouseRepository(
return try { return try {
val response = warehouseApi.getItem(GetItemRequest(id)) val response = warehouseApi.getItem(GetItemRequest(id))
if (response.errCode == 0 && response.data != null) { 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 { } else {
Result.failure(Exception("获取物品详情失败")) Result.failure(Exception("获取物品详情失败"))
} }
@@ -29,7 +29,11 @@ class WorkOrderRepository(
return try { return try {
val response = workOrderApi.get(WorkOrderGetRequest(id)) val response = workOrderApi.get(WorkOrderGetRequest(id))
if (response.errCode == 0 && response.data?.order != null) { 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 { } else {
Result.failure(Exception("获取工单详情失败")) Result.failure(Exception("获取工单详情失败"))
} }
@@ -1,17 +1,148 @@
package com.example.ops_android.printer 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.Item
import com.example.ops_android.data.model.PurchaseOrder import com.example.ops_android.data.model.PurchaseOrder
import com.example.ops_android.data.model.WorkOrder 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.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object PrintHelper { object PrintHelper {
private val printMutex = Mutex() 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( suspend fun printOrderLabel(
printerManager: PrinterManager, printerManager: PrinterManager,
@@ -36,14 +167,7 @@ object PrintHelper {
} else { } else {
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2)) printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
} }
printerManager.commit().fold( printerManager.commit()
onSuccess = {
delay(1000)
printerManager.closeDevice()
Result.success(Unit)
},
onFailure = { Result.failure(it) }
)
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
} }
@@ -53,72 +177,150 @@ object PrintHelper {
suspend fun printWorkOrderLabel( suspend fun printWorkOrderLabel(
printerManager: PrinterManager, printerManager: PrinterManager,
workOrder: WorkOrder, workOrder: WorkOrder,
labelWidthMm: Int = 53,
labelHeightMm: Int = 40,
blackMarkEnabled: Boolean = true, blackMarkEnabled: Boolean = true,
unwindMm: Int = 40 unwindMm: Int = 40,
printRetractMm: Int = 10
): Result<Unit> = printMutex.withLock { ): Result<Unit> = printMutex.withLock {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
printerManager.initDevice().onFailure { return@withContext Result.failure(it) } printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
printerManager.setFontSize(24) if (printRetractMm > 0) {
printerManager.setBold(true) printerManager.setUnwindLength(mmToDots(printRetractMm))
printerManager.addText("工单: ${workOrder.title ?: "-"}\n")
printerManager.setFontSize(20)
printerManager.setBold(false)
if (workOrder.id > 0) {
printerManager.addBarcode("WO-${workOrder.id}")
} }
val widthDots = mmToDots(labelWidthMm)
val heightDots = mmToDots(labelHeightMm)
val bitmap = buildWorkOrderLabelBitmap(workOrder, widthDots, heightDots)
printerManager.printImage(bitmap, 1)
if (blackMarkEnabled) { if (blackMarkEnabled) {
printerManager.addLineFeed(2) printerManager.addLineFeed(2)
printerManager.enableMarkDetection(true) printerManager.enableMarkDetection(true)
} else { } else {
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2)) printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
} }
printerManager.commit().fold( printerManager.commit()
onSuccess = {
delay(1000)
printerManager.closeDevice()
Result.success(Unit)
},
onFailure = { Result.failure(it) }
)
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
} }
} }
} }
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)
}
canvas.drawLine(dividerX.toFloat(), padding.toFloat(), dividerX.toFloat(), maxTextY.toFloat(), sepPaint)
if (!item.linkedCustomers.isNullOrEmpty()) {
val customerNames = item.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 < maxTextY) {
drawTextBlock(canvas, "", customerText, padding.toFloat(), customerY.toFloat(), leftTextMaxWidth, maxLines = 2, textSize = 20f, lineSpacingExtra = 2f)
}
}
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( suspend fun printItemLabel(
printerManager: PrinterManager, printerManager: PrinterManager,
item: Item, item: Item,
labelWidthMm: Int = 53,
labelHeightMm: Int = 40,
blackMarkEnabled: Boolean = true, blackMarkEnabled: Boolean = true,
unwindMm: Int = 40 unwindMm: Int = 40,
printRetractMm: Int = 10
): Result<Unit> = printMutex.withLock { ): Result<Unit> = printMutex.withLock {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
printerManager.initDevice().onFailure { return@withContext Result.failure(it) } printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
printerManager.setFontSize(24) if (printRetractMm > 0) {
printerManager.setBold(true) printerManager.setUnwindLength(mmToDots(printRetractMm))
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}")
} }
val widthDots = mmToDots(labelWidthMm)
val heightDots = mmToDots(labelHeightMm)
val bitmap = buildItemLabelBitmap(item, widthDots, heightDots)
printerManager.printImage(bitmap, 1)
if (blackMarkEnabled) { if (blackMarkEnabled) {
printerManager.addLineFeed(2) printerManager.addLineFeed(2)
printerManager.enableMarkDetection(true) printerManager.enableMarkDetection(true)
} else { } else {
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2)) printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
} }
printerManager.commit().fold( printerManager.commit()
onSuccess = {
delay(1000)
printerManager.closeDevice()
Result.success(Unit)
},
onFailure = { Result.failure(it) }
)
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
} }
@@ -149,14 +351,7 @@ object PrintHelper {
} else { } else {
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2)) printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
} }
printerManager.commit().fold( printerManager.commit()
onSuccess = {
delay(1000)
printerManager.closeDevice()
Result.success(Unit)
},
onFailure = { Result.failure(it) }
)
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)
} }
@@ -46,8 +46,6 @@ class PrintJob(private val manager: PrinterManager) {
if (lineFeeds > 0) manager.addLineFeed(lineFeeds).getOrThrow() if (lineFeeds > 0) manager.addLineFeed(lineFeeds).getOrThrow()
manager.commit().getOrThrow() manager.commit().getOrThrow()
Thread.sleep(1500)
manager.closeDevice().getOrThrow()
} }
/** 初始化 → 添加内容 → 提交(不关闭) */ /** 初始化 → 添加内容 → 提交(不关闭) */
@@ -301,8 +301,6 @@ class PrinterManager(private val context: Context) {
initDevice().getOrThrow() initDevice().getOrThrow()
addText(text).getOrThrow() addText(text).getOrThrow()
commit().getOrThrow() commit().getOrThrow()
Thread.sleep(1500)
closeDevice().getOrThrow()
} }
fun quickLabel(text: String): Result<Unit> = runCatching { fun quickLabel(text: String): Result<Unit> = runCatching {
@@ -310,8 +308,6 @@ class PrinterManager(private val context: Context) {
enableMarkDetection(true).getOrThrow() enableMarkDetection(true).getOrThrow()
addText(text).getOrThrow() addText(text).getOrThrow()
commit().getOrThrow() commit().getOrThrow()
Thread.sleep(1500)
closeDevice().getOrThrow()
} }
/** 创建链式打印任务 */ /** 创建链式打印任务 */
@@ -80,6 +80,21 @@ fun HomeScreen(
} }
} }
// ── 快捷入口 ──────────────────────────
item {
Text("快捷入口", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
QuickLinkCard(Icons.Default.Search, "搜索", Color(0xFF4CAF50), Modifier.weight(1f), onSearchClick)
QuickLinkCard(Icons.Default.Event, "日程", Color(0xFF9C27B0), Modifier.weight(1f), onScheduleClick)
}
}
// ── 今日日程 ────────────────────────── // ── 今日日程 ──────────────────────────
item { item {
Text("今日日程", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text("今日日程", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
@@ -172,21 +187,6 @@ fun HomeScreen(
} }
} }
} }
// ── 快捷入口 ──────────────────────────
item {
Text("快捷入口", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
QuickLinkCard(Icons.Default.Search, "搜索", Color(0xFF4CAF50), Modifier.weight(1f), onSearchClick)
QuickLinkCard(Icons.Default.Event, "日程", Color(0xFF9C27B0), Modifier.weight(1f), onScheduleClick)
}
}
} }
} }
} }
@@ -111,7 +111,13 @@ fun OPSNavGraph(isLoggedIn: Boolean) {
onWorkOrderClick = { id -> rootNavController.navigate(Routes.workOrderDetail(id)) }, onWorkOrderClick = { id -> rootNavController.navigate(Routes.workOrderDetail(id)) },
onItemClick = { id -> rootNavController.navigate(Routes.itemDetail(id)) }, onItemClick = { id -> rootNavController.navigate(Routes.itemDetail(id)) },
onContainerClick = { id -> rootNavController.navigate(Routes.MAIN) }, onContainerClick = { id -> rootNavController.navigate(Routes.MAIN) },
onBack = { rootNavController.popBackStack() } onBack = { rootNavController.popBackStack() },
onOpsSysNavigate = { module, id ->
when (module) {
"workorder" -> rootNavController.navigate(Routes.workOrderDetail(id))
"item" -> rootNavController.navigate(Routes.itemDetail(id))
}
}
) )
} }
composable(Routes.SCHEDULE) { composable(Routes.SCHEDULE) {
@@ -28,7 +28,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
@@ -51,7 +51,8 @@ fun SearchScreen(
onWorkOrderClick: (Int) -> Unit, onWorkOrderClick: (Int) -> Unit,
onItemClick: (Int) -> Unit, onItemClick: (Int) -> Unit,
onContainerClick: (Int) -> Unit, onContainerClick: (Int) -> Unit,
onBack: () -> Unit onBack: () -> Unit,
onOpsSysNavigate: (module: String, id: Int) -> Unit
) { ) {
val appContainer = LocalAppContainer.current val appContainer = LocalAppContainer.current
val viewModel: SearchViewModel = viewModel( val viewModel: SearchViewModel = viewModel(
@@ -67,6 +68,13 @@ fun SearchScreen(
if (granted) showScanner = true if (granted) showScanner = true
} }
LaunchedEffect(uiState.opsSysNavigation) {
uiState.opsSysNavigation?.let { navInfo ->
onOpsSysNavigate(navInfo.module, navInfo.id)
viewModel.clearOpsSysNavigation()
}
}
fun checkCameraAndScan() { fun checkCameraAndScan() {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
hasCameraPermission = true hasCameraPermission = true
@@ -128,14 +136,6 @@ fun SearchScreen(
} }
} }
// Prefix hints
Text(
"前缀快捷搜索: po:采购单 wo:工单 item:物品 warehouse:容器",
fontSize = 11.sp,
color = Color.Gray,
modifier = Modifier.padding(horizontal = 16.dp),
textAlign = TextAlign.Center
)
// Category tabs // Category tabs
PrimaryScrollableTabRow( PrimaryScrollableTabRow(
@@ -7,7 +7,6 @@ import com.example.ops_android.data.model.Container
import com.example.ops_android.data.model.Item import com.example.ops_android.data.model.Item
import com.example.ops_android.data.model.PurchaseOrder import com.example.ops_android.data.model.PurchaseOrder
import com.example.ops_android.data.model.WorkOrder import com.example.ops_android.data.model.WorkOrder
import com.example.ops_android.data.repository.CustomerRepository
import com.example.ops_android.data.repository.OrderRepository import com.example.ops_android.data.repository.OrderRepository
import com.example.ops_android.data.repository.WarehouseRepository import com.example.ops_android.data.repository.WarehouseRepository
import com.example.ops_android.data.repository.WorkOrderRepository import com.example.ops_android.data.repository.WorkOrderRepository
@@ -15,6 +14,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject
enum class SearchCategory(val label: String) { enum class SearchCategory(val label: String) {
ALL("全部"), ALL("全部"),
@@ -24,6 +24,11 @@ enum class SearchCategory(val label: String) {
CONTAINER("容器") CONTAINER("容器")
} }
data class OpsSysNavInfo(
val module: String,
val id: Int
)
data class SearchUiState( data class SearchUiState(
val query: String = "", val query: String = "",
val category: SearchCategory = SearchCategory.ALL, val category: SearchCategory = SearchCategory.ALL,
@@ -32,7 +37,8 @@ data class SearchUiState(
val items: List<Item> = emptyList(), val items: List<Item> = emptyList(),
val containers: List<Container> = emptyList(), val containers: List<Container> = emptyList(),
val isSearching: Boolean = false, val isSearching: Boolean = false,
val errorMessage: String? = null val errorMessage: String? = null,
val opsSysNavigation: OpsSysNavInfo? = null
) )
class SearchViewModel( class SearchViewModel(
@@ -61,7 +67,19 @@ class SearchViewModel(
val query = _uiState.value.query.trim() val query = _uiState.value.query.trim()
if (query.isEmpty()) return if (query.isEmpty()) return
// Parse prefix shortcuts val jsonObj = try { JSONObject(query) } catch (e: Exception) { null }
if (jsonObj != null) {
_uiState.value = _uiState.value.copy(query = "")
if (jsonObj.optString("type") == "ops_sys") {
val module = jsonObj.optString("module")
val id = jsonObj.optInt("id")
if (module.isNotBlank() && id > 0) {
_uiState.value = _uiState.value.copy(opsSysNavigation = OpsSysNavInfo(module, id))
}
}
return
}
val (actualCategory, actualQuery) = parsePrefix(query) val (actualCategory, actualQuery) = parsePrefix(query)
_uiState.value = _uiState.value.copy(category = actualCategory) _uiState.value = _uiState.value.copy(category = actualCategory)
@@ -125,6 +143,10 @@ class SearchViewModel(
_uiState.value = SearchUiState(query = _uiState.value.query, category = _uiState.value.category) _uiState.value = SearchUiState(query = _uiState.value.query, category = _uiState.value.category)
} }
fun clearOpsSysNavigation() {
_uiState.value = _uiState.value.copy(opsSysNavigation = null)
}
private fun parsePrefix(query: String): Pair<SearchCategory, String> { private fun parsePrefix(query: String): Pair<SearchCategory, String> {
val prefixes = mapOf( val prefixes = mapOf(
"po:" to SearchCategory.ORDER, "po:" to SearchCategory.ORDER,
@@ -14,7 +14,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.ops_android.ui.LocalAppContainer import com.example.ops_android.ui.LocalAppContainer
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -120,8 +119,6 @@ fun PrinterTestScreen(onBack: () -> Unit) {
val pm = printerManager!! val pm = printerManager!!
pm.initDevice().getOrThrow() pm.initDevice().getOrThrow()
pm.commit().getOrThrow() pm.commit().getOrThrow()
delay(1000)
pm.closeDevice().getOrThrow()
} }
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -137,8 +134,6 @@ fun PrinterTestScreen(onBack: () -> Unit) {
pm.initDevice().getOrThrow() pm.initDevice().getOrThrow()
pm.addLineFeed(5) pm.addLineFeed(5)
pm.commit().getOrThrow() pm.commit().getOrThrow()
delay(1000)
pm.closeDevice().getOrThrow()
} }
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -155,8 +150,6 @@ fun PrinterTestScreen(onBack: () -> Unit) {
pm.initDevice().getOrThrow() pm.initDevice().getOrThrow()
pm.addLineFeed(10) pm.addLineFeed(10)
pm.commit().getOrThrow() pm.commit().getOrThrow()
delay(1000)
pm.closeDevice().getOrThrow()
} }
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -148,6 +148,49 @@ fun SettingsScreen(
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) 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(
text = "关于", text = "关于",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
@@ -25,7 +25,10 @@ data class SettingsUiState(
val testSuccess: Boolean = false, val testSuccess: Boolean = false,
val message: String? = null, val message: String? = null,
val blackMarkEnabled: Boolean = true, 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( class SettingsViewModel(
@@ -56,7 +59,10 @@ class SettingsViewModel(
private fun loadPrinterSettings() { private fun loadPrinterSettings() {
_uiState.value = _uiState.value.copy( _uiState.value = _uiState.value.copy(
blackMarkEnabled = sessionManager.cachedBlackMarkEnabled, 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() { fun saveUrl() {
viewModelScope.launch { viewModelScope.launch {
val url = _uiState.value.apiBaseUrl.trim() val url = _uiState.value.apiBaseUrl.trim()
@@ -28,6 +28,7 @@ import com.example.ops_android.data.remote.api.ItemResponse
import com.example.ops_android.data.remote.api.LinkedCustomerInfo import com.example.ops_android.data.remote.api.LinkedCustomerInfo
import com.example.ops_android.data.remote.api.LinkedWorkOrder import com.example.ops_android.data.remote.api.LinkedWorkOrder
import com.example.ops_android.data.repository.WarehouseRepository import com.example.ops_android.data.repository.WarehouseRepository
import com.example.ops_android.printer.PrintHelper
import com.example.ops_android.ui.LocalAppContainer import com.example.ops_android.ui.LocalAppContainer
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -123,6 +124,17 @@ fun ItemDetailScreen(id: Int, onBack: () -> Unit, onCreateWorkOrder: (Int) -> Un
title = { Text("物品详情") }, title = { Text("物品详情") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } },
actions = { actions = {
val scope = rememberCoroutineScope()
item?.let { itm ->
val printerManager = app.printerManager
if (printerManager != null) {
IconButton(onClick = {
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)
}
}
}
IconButton(onClick = { onCreateWorkOrder(id) }) { Icon(Icons.Default.AddTask, "新建工单", tint = Color.White) } IconButton(onClick = { onCreateWorkOrder(id) }) { Icon(Icons.Default.AddTask, "新建工单", tint = Color.White) }
if (resp?.canModifyItem == true) { if (resp?.canModifyItem == true) {
IconButton(onClick = { showMoveDialog = true }) { Icon(Icons.Default.OpenWith, "移动", tint = Color.White) } IconButton(onClick = { showMoveDialog = true }) { Icon(Icons.Default.OpenWith, "移动", tint = Color.White) }
@@ -69,7 +69,7 @@ fun WorkOrderDetailScreen(id: Int, onBack: () -> Unit) {
if (printerManager != null) { if (printerManager != null) {
val sm = com.example.ops_android.ui.LocalAppContainer.current.sessionManager val sm = com.example.ops_android.ui.LocalAppContainer.current.sessionManager
IconButton(onClick = { 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) } }) { Icon(Icons.Default.Print, "打印", tint = Color.White) }
} }
} }
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

+1 -1
View File
@@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">ops_android</string> <string name="app_name">Operations 2</string>
</resources> </resources>
+2 -2
View File
@@ -1,3 +1,3 @@
#Thu Jun 25 17:53:49 CST 2026 #Fri Jun 26 20:46:06 CST 2026
VERSION_CODE=90 VERSION_CODE=121
VERSION_NAME=1.2 VERSION_NAME=1.2
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB