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_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,17 +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.delay
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,
@@ -36,14 +167,7 @@ object PrintHelper {
} else {
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
}
printerManager.commit().fold(
onSuccess = {
delay(1000)
printerManager.closeDevice()
Result.success(Unit)
},
onFailure = { Result.failure(it) }
)
printerManager.commit()
} catch (e: Exception) {
Result.failure(e)
}
@@ -53,72 +177,150 @@ 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)
} else {
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
}
printerManager.commit().fold(
onSuccess = {
delay(1000)
printerManager.closeDevice()
Result.success(Unit)
},
onFailure = { Result.failure(it) }
)
printerManager.commit()
} catch (e: Exception) {
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(
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)
} else {
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
}
printerManager.commit().fold(
onSuccess = {
delay(1000)
printerManager.closeDevice()
Result.success(Unit)
},
onFailure = { Result.failure(it) }
)
printerManager.commit()
} catch (e: Exception) {
Result.failure(e)
}
@@ -149,14 +351,7 @@ object PrintHelper {
} else {
printerManager.addLineFeed((unwindMm / 7).toInt().coerceAtLeast(2))
}
printerManager.commit().fold(
onSuccess = {
delay(1000)
printerManager.closeDevice()
Result.success(Unit)
},
onFailure = { Result.failure(it) }
)
printerManager.commit()
} catch (e: Exception) {
Result.failure(e)
}
@@ -46,8 +46,6 @@ class PrintJob(private val manager: PrinterManager) {
if (lineFeeds > 0) manager.addLineFeed(lineFeeds).getOrThrow()
manager.commit().getOrThrow()
Thread.sleep(1500)
manager.closeDevice().getOrThrow()
}
/** 初始化 → 添加内容 → 提交(不关闭) */
@@ -301,8 +301,6 @@ class PrinterManager(private val context: Context) {
initDevice().getOrThrow()
addText(text).getOrThrow()
commit().getOrThrow()
Thread.sleep(1500)
closeDevice().getOrThrow()
}
fun quickLabel(text: String): Result<Unit> = runCatching {
@@ -310,8 +308,6 @@ class PrinterManager(private val context: Context) {
enableMarkDetection(true).getOrThrow()
addText(text).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 {
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)) },
onItemClick = { id -> rootNavController.navigate(Routes.itemDetail(id)) },
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) {
@@ -28,7 +28,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
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.sp
import androidx.compose.ui.viewinterop.AndroidView
@@ -51,7 +51,8 @@ fun SearchScreen(
onWorkOrderClick: (Int) -> Unit,
onItemClick: (Int) -> Unit,
onContainerClick: (Int) -> Unit,
onBack: () -> Unit
onBack: () -> Unit,
onOpsSysNavigate: (module: String, id: Int) -> Unit
) {
val appContainer = LocalAppContainer.current
val viewModel: SearchViewModel = viewModel(
@@ -67,6 +68,13 @@ fun SearchScreen(
if (granted) showScanner = true
}
LaunchedEffect(uiState.opsSysNavigation) {
uiState.opsSysNavigation?.let { navInfo ->
onOpsSysNavigate(navInfo.module, navInfo.id)
viewModel.clearOpsSysNavigation()
}
}
fun checkCameraAndScan() {
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
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
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.PurchaseOrder
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.WarehouseRepository
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.asStateFlow
import kotlinx.coroutines.launch
import org.json.JSONObject
enum class SearchCategory(val label: String) {
ALL("全部"),
@@ -24,6 +24,11 @@ enum class SearchCategory(val label: String) {
CONTAINER("容器")
}
data class OpsSysNavInfo(
val module: String,
val id: Int
)
data class SearchUiState(
val query: String = "",
val category: SearchCategory = SearchCategory.ALL,
@@ -32,7 +37,8 @@ data class SearchUiState(
val items: List<Item> = emptyList(),
val containers: List<Container> = emptyList(),
val isSearching: Boolean = false,
val errorMessage: String? = null
val errorMessage: String? = null,
val opsSysNavigation: OpsSysNavInfo? = null
)
class SearchViewModel(
@@ -61,7 +67,19 @@ class SearchViewModel(
val query = _uiState.value.query.trim()
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)
_uiState.value = _uiState.value.copy(category = actualCategory)
@@ -125,6 +143,10 @@ class SearchViewModel(
_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> {
val prefixes = mapOf(
"po:" to SearchCategory.ORDER,
@@ -14,7 +14,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.ops_android.ui.LocalAppContainer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -120,8 +119,6 @@ fun PrinterTestScreen(onBack: () -> Unit) {
val pm = printerManager!!
pm.initDevice().getOrThrow()
pm.commit().getOrThrow()
delay(1000)
pm.closeDevice().getOrThrow()
}
},
modifier = Modifier.fillMaxWidth(),
@@ -137,8 +134,6 @@ fun PrinterTestScreen(onBack: () -> Unit) {
pm.initDevice().getOrThrow()
pm.addLineFeed(5)
pm.commit().getOrThrow()
delay(1000)
pm.closeDevice().getOrThrow()
}
},
modifier = Modifier.fillMaxWidth(),
@@ -155,8 +150,6 @@ fun PrinterTestScreen(onBack: () -> Unit) {
pm.initDevice().getOrThrow()
pm.addLineFeed(10)
pm.commit().getOrThrow()
delay(1000)
pm.closeDevice().getOrThrow()
}
},
modifier = Modifier.fillMaxWidth(),
@@ -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()
@@ -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.LinkedWorkOrder
import com.example.ops_android.data.repository.WarehouseRepository
import com.example.ops_android.printer.PrintHelper
import com.example.ops_android.ui.LocalAppContainer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -123,6 +124,17 @@ fun ItemDetailScreen(id: Int, onBack: () -> Unit, onCreateWorkOrder: (Int) -> Un
title = { Text("物品详情") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } },
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) }
if (resp?.canModifyItem == true) {
IconButton(onClick = { showMoveDialog = true }) { Icon(Icons.Default.OpenWith, "移动", 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) }
}
}
@@ -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>
<string name="app_name">ops_android</string>
<string name="app_name">Operations 2</string>
</resources>
+2 -2
View File
@@ -1,3 +1,3 @@
#Thu Jun 25 17:53:49 CST 2026
VERSION_CODE=90
#Fri Jun 26 20:46:06 CST 2026
VERSION_CODE=121
VERSION_NAME=1.2
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB