diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 589f329..ee4ad94 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -36,6 +36,16 @@
+
+
+
+
diff --git a/app/src/main/java/com/example/ops_android/AppContainer.kt b/app/src/main/java/com/example/ops_android/AppContainer.kt
index 2a1416f..8eaaf44 100644
--- a/app/src/main/java/com/example/ops_android/AppContainer.kt
+++ b/app/src/main/java/com/example/ops_android/AppContainer.kt
@@ -20,6 +20,7 @@ class AppContainer(context: Context) {
val warehouseApi by lazy { apiServiceFactory.create() }
val customerApi by lazy { apiServiceFactory.create() }
val scheduleApi by lazy { apiServiceFactory.create() }
+ val fileApi by lazy { apiServiceFactory.create() }
val authRepository by lazy { AuthRepository(userApi, sessionManager) }
val orderRepository by lazy { OrderRepository(purchaseApi) }
diff --git a/app/src/main/java/com/example/ops_android/data/model/Container.kt b/app/src/main/java/com/example/ops_android/data/model/Container.kt
index c79c68d..e362a0e 100644
--- a/app/src/main/java/com/example/ops_android/data/model/Container.kt
+++ b/app/src/main/java/com/example/ops_android/data/model/Container.kt
@@ -5,7 +5,7 @@ import com.google.gson.annotations.SerializedName
data class Container(
@SerializedName("ID") val id: Int = 0,
@SerializedName("Title") val name: String? = null,
- @SerializedName("ParentID") val parent_id: Int = 0,
+ @SerializedName("ParentID") val parent_id: Int? = null,
@SerializedName("Remark") val remark: String? = null,
@SerializedName("CreatedAt") val created_at: String? = null,
@SerializedName("CreatorID") val user_id: Int? = null,
diff --git a/app/src/main/java/com/example/ops_android/data/model/Item.kt b/app/src/main/java/com/example/ops_android/data/model/Item.kt
index df4f06a..55dd50f 100644
--- a/app/src/main/java/com/example/ops_android/data/model/Item.kt
+++ b/app/src/main/java/com/example/ops_android/data/model/Item.kt
@@ -8,8 +8,9 @@ data class Item(
@SerializedName("SerialNumber") val serial_number: String? = null,
@SerializedName("Remark") val remark: String? = null,
@SerializedName("Quantity") val quantity: Int = 1,
- @SerializedName("ContainerID") val container_id: Int = 0,
+ @SerializedName("ContainerID") val container_id: Int? = null,
@SerializedName("CreatedAt") val created_at: String? = null,
+ @SerializedName("UpdatedAt") val updated_at: String? = null,
@SerializedName("CreatorID") val user_id: Int? = null,
@SerializedName("barcode") private val _barcode: String? = null,
@SerializedName("spec") private val _spec: String? = null,
diff --git a/app/src/main/java/com/example/ops_android/data/remote/CookieInterceptor.kt b/app/src/main/java/com/example/ops_android/data/remote/CookieInterceptor.kt
index 92da480..cc701bf 100644
--- a/app/src/main/java/com/example/ops_android/data/remote/CookieInterceptor.kt
+++ b/app/src/main/java/com/example/ops_android/data/remote/CookieInterceptor.kt
@@ -17,6 +17,12 @@ class CookieInterceptor(
return chain.proceed(originalRequest)
}
+ // Skip multipart requests (e.g., file uploads)
+ val ct = originalRequest.body?.contentType()?.toString() ?: ""
+ if (ct.contains("multipart")) {
+ return chain.proceed(originalRequest)
+ }
+
val cookieValue = sessionManager.cachedCookieValue
val gson = Gson()
diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/CustomerApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/CustomerApi.kt
index c6e8dfc..273631b 100644
--- a/app/src/main/java/com/example/ops_android/data/remote/api/CustomerApi.kt
+++ b/app/src/main/java/com/example/ops_android/data/remote/api/CustomerApi.kt
@@ -5,6 +5,12 @@ import com.example.ops_android.data.remote.ApiResponse
import retrofit2.http.Body
import retrofit2.http.POST
+data class CustomerListRequest(
+ val search: String? = null,
+ val page: Int = 1,
+ val page_size: Int = 10
+)
+
data class CustomerListResponse(
val customers: List?
)
@@ -20,17 +26,17 @@ data class CustomerResponse(
interface CustomerApi {
@POST("/customer/list")
- suspend fun list(@Body request: Map = emptyMap()): ApiResponse
+ suspend fun list(@Body request: CustomerListRequest): ApiResponse
@POST("/customer/get")
suspend fun get(@Body request: CustomerGetRequest): ApiResponse
@POST("/customer/add")
- suspend fun add(@Body request: Map): ApiResponse
+ suspend fun add(@Body request: Map): ApiResponse
@POST("/customer/update")
- suspend fun update(@Body request: Map): ApiResponse
+ suspend fun update(@Body request: Map): ApiResponse
@POST("/customer/delete")
- suspend fun delete(@Body request: Map): ApiResponse
+ suspend fun delete(@Body request: Map): ApiResponse
}
diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/FileApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/FileApi.kt
new file mode 100644
index 0000000..2ae46ef
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/remote/api/FileApi.kt
@@ -0,0 +1,24 @@
+package com.example.ops_android.data.remote.api
+
+import com.example.ops_android.data.remote.ApiResponse
+import okhttp3.MultipartBody
+import okhttp3.RequestBody
+import retrofit2.http.Multipart
+import retrofit2.http.POST
+import retrofit2.http.Part
+
+data class FileUploadResponse(
+ val hash: String? = null,
+ val get: String? = null,
+ val download: String? = null
+)
+
+interface FileApi {
+
+ @Multipart
+ @POST("/files/upload/image")
+ suspend fun uploadImage(
+ @Part("cookie") cookie: RequestBody,
+ @Part file: MultipartBody.Part
+ ): ApiResponse
+}
diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/PurchaseApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/PurchaseApi.kt
index ff0445c..1c29c00 100644
--- a/app/src/main/java/com/example/ops_android/data/remote/api/PurchaseApi.kt
+++ b/app/src/main/java/com/example/ops_android/data/remote/api/PurchaseApi.kt
@@ -49,14 +49,14 @@ interface PurchaseApi {
suspend fun getOrder(@Body request: GetOrderRequest): ApiResponse
@POST("/purchase/getordercount")
- suspend fun getOrderCount(@Body request: Map = emptyMap()): ApiResponse
+ suspend fun getOrderCount(@Body request: EmptyBody = EmptyBody()): ApiResponse
@POST("/purchase/updatestatus")
suspend fun updateOrderStatus(@Body request: UpdateOrderStatusRequest): ApiResponse
@POST("/purchase/addorder")
- suspend fun addOrder(@Body request: Map): ApiResponse
+ suspend fun addOrder(@Body request: Map): ApiResponse
@POST("/purchase/updateorder")
- suspend fun updateOrder(@Body request: Map): ApiResponse
+ suspend fun updateOrder(@Body request: Map): ApiResponse
}
diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/ScheduleApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/ScheduleApi.kt
index e8539f3..c2811fe 100644
--- a/app/src/main/java/com/example/ops_android/data/remote/api/ScheduleApi.kt
+++ b/app/src/main/java/com/example/ops_android/data/remote/api/ScheduleApi.kt
@@ -6,8 +6,8 @@ import retrofit2.http.Body
import retrofit2.http.POST
data class GetEventsRequest(
- val start_date: String? = null,
- val end_date: String? = null
+ val start: String? = null,
+ val end: String? = null
)
data class EventsResponse(
@@ -20,11 +20,11 @@ interface ScheduleApi {
suspend fun getEvents(@Body request: GetEventsRequest): ApiResponse
@POST("/schedule/addevent")
- suspend fun addEvent(@Body request: Map): ApiResponse
+ suspend fun addEvent(@Body request: Map): ApiResponse
@POST("/schedule/editevent")
- suspend fun editEvent(@Body request: Map): ApiResponse
+ suspend fun editEvent(@Body request: Map): ApiResponse
@POST("/schedule/deleevent")
- suspend fun deleteEvent(@Body request: Map): ApiResponse
+ suspend fun deleteEvent(@Body request: Map): ApiResponse
}
diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/UserApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/UserApi.kt
index 0a530e9..b716fe8 100644
--- a/app/src/main/java/com/example/ops_android/data/remote/api/UserApi.kt
+++ b/app/src/main/java/com/example/ops_android/data/remote/api/UserApi.kt
@@ -43,6 +43,8 @@ data class UserInfoResponse(
val userInfo: UserDetail?
)
+class EmptyBody
+
interface UserApi {
@POST("/users/login")
@@ -52,7 +54,7 @@ interface UserApi {
suspend fun register(@Body request: RegisterRequest): ApiResponse
@POST("/users/getinfo")
- suspend fun getUserInfo(@Body request: Map = emptyMap()): ApiResponse
+ suspend fun getUserInfo(@Body request: EmptyBody = EmptyBody()): ApiResponse
@POST("/users/changePassword")
suspend fun changePassword(@Body request: ChangePasswordRequest): ApiResponse
diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/WarehouseApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/WarehouseApi.kt
index 53b604a..76929e7 100644
--- a/app/src/main/java/com/example/ops_android/data/remote/api/WarehouseApi.kt
+++ b/app/src/main/java/com/example/ops_android/data/remote/api/WarehouseApi.kt
@@ -2,12 +2,13 @@ package com.example.ops_android.data.remote.api
import com.example.ops_android.data.model.Container
import com.example.ops_android.data.model.Item
+import com.example.ops_android.data.model.TabFileInfo
import com.example.ops_android.data.remote.ApiResponse
import retrofit2.http.Body
import retrofit2.http.POST
data class ListContainerRequest(
- val parent_id: Int = 0
+ val parent_id: Int? = null
)
data class ListContainerResponse(
@@ -23,7 +24,10 @@ data class ContainerResponse(
)
data class ListItemRequest(
- val container_id: Int = 0
+ val container_id: Int? = null,
+ val search: String? = null,
+ val entries: Int = 10,
+ val page: Int = 1
)
data class ListItemResponse(
@@ -35,12 +39,42 @@ data class GetItemRequest(
)
data class ItemResponse(
- val item: Item?
+ val item: Item?,
+ val photos: List? = null,
+ val commits: List? = null,
+ val work_orders: List? = null,
+ val customers: List? = null,
+ val canModifyItem: Boolean = false,
+ val container_breadcrumb: String? = null
+)
+
+data class ItemCommitDetail(
+ val ID: Int = 0,
+ val ItemID: Int = 0,
+ val UserID: Int = 0,
+ val OldContainerBreadcrumb: String? = null,
+ val NewContainerBreadcrumb: String? = null,
+ val Remark: String? = null,
+ val CreatedAt: String? = null
+)
+
+data class LinkedWorkOrder(
+ val id: Int = 0,
+ val title: String? = null,
+ val status: String? = null
+)
+
+data class LinkedCustomerInfo(
+ val id: Int = 0,
+ val first_name: String? = null,
+ val last_name: String? = null,
+ val title: String? = null,
+ val primary_phone: String? = null
)
data class MoveItemRequest(
val item_id: Int,
- val new_container: Int
+ val new_container: Int? = null
)
data class WarehouseCountResponse(
@@ -58,13 +92,13 @@ interface WarehouseApi {
suspend fun getContainer(@Body request: GetContainerRequest): ApiResponse
@POST("/warehouse/add_container")
- suspend fun addContainer(@Body request: Map): ApiResponse
+ suspend fun addContainer(@Body request: Map): ApiResponse
@POST("/warehouse/update_container")
- suspend fun updateContainer(@Body request: Map): ApiResponse
+ suspend fun updateContainer(@Body request: Map): ApiResponse
@POST("/warehouse/delete_container")
- suspend fun deleteContainer(@Body request: Map): ApiResponse
+ suspend fun deleteContainer(@Body request: Map): ApiResponse
@POST("/warehouse/list_item")
suspend fun listItem(@Body request: ListItemRequest): ApiResponse
@@ -73,17 +107,17 @@ interface WarehouseApi {
suspend fun getItem(@Body request: GetItemRequest): ApiResponse
@POST("/warehouse/add_item")
- suspend fun addItem(@Body request: Map): ApiResponse
+ suspend fun addItem(@Body request: Map): ApiResponse
@POST("/warehouse/update_item")
- suspend fun updateItem(@Body request: Map): ApiResponse
+ suspend fun updateItem(@Body request: Map): ApiResponse
@POST("/warehouse/delete_item")
- suspend fun deleteItem(@Body request: Map): ApiResponse
+ suspend fun deleteItem(@Body request: Map): ApiResponse
@POST("/warehouse/move_item")
suspend fun moveItem(@Body request: MoveItemRequest): ApiResponse
@POST("/warehouse/count")
- suspend fun getCount(@Body request: Map = emptyMap()): ApiResponse
+ suspend fun getCount(@Body request: EmptyBody = EmptyBody()): ApiResponse
}
diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/WorkOrderApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/WorkOrderApi.kt
index 865349e..b1edd66 100644
--- a/app/src/main/java/com/example/ops_android/data/remote/api/WorkOrderApi.kt
+++ b/app/src/main/java/com/example/ops_android/data/remote/api/WorkOrderApi.kt
@@ -75,16 +75,16 @@ interface WorkOrderApi {
suspend fun get(@Body request: WorkOrderGetRequest): ApiResponse
@POST("/work_order/count")
- suspend fun getCount(@Body request: Map = emptyMap()): ApiResponse
+ suspend fun getCount(@Body request: EmptyBody = EmptyBody()): ApiResponse
@POST("/work_order/add")
- suspend fun add(@Body request: Map): ApiResponse
+ suspend fun add(@Body request: Map): ApiResponse
@POST("/work_order/update")
- suspend fun update(@Body request: Map): ApiResponse
+ suspend fun update(@Body request: Map): ApiResponse
@POST("/work_order/delete")
- suspend fun delete(@Body request: Map): ApiResponse
+ suspend fun delete(@Body request: Map): ApiResponse
@POST("/work_order/commit")
suspend fun commit(@Body request: WorkOrderCommitRequest): ApiResponse
diff --git a/app/src/main/java/com/example/ops_android/data/repository/CustomerRepository.kt b/app/src/main/java/com/example/ops_android/data/repository/CustomerRepository.kt
index c6036f5..6778d3c 100644
--- a/app/src/main/java/com/example/ops_android/data/repository/CustomerRepository.kt
+++ b/app/src/main/java/com/example/ops_android/data/repository/CustomerRepository.kt
@@ -3,13 +3,14 @@ package com.example.ops_android.data.repository
import com.example.ops_android.data.model.Customer
import com.example.ops_android.data.remote.api.CustomerApi
import com.example.ops_android.data.remote.api.CustomerGetRequest
+import com.example.ops_android.data.remote.api.CustomerListRequest
class CustomerRepository(
private val customerApi: CustomerApi
) {
- suspend fun list(): Result> {
+ suspend fun list(search: String? = null): Result> {
return try {
- val response = customerApi.list()
+ val response = customerApi.list(CustomerListRequest(search = search, page_size = if (search != null) 10 else 5))
if (response.errCode == 0 && response.data != null) {
Result.success(response.data.customers ?: emptyList())
} else {
diff --git a/app/src/main/java/com/example/ops_android/data/repository/WarehouseRepository.kt b/app/src/main/java/com/example/ops_android/data/repository/WarehouseRepository.kt
index 21e9895..f3cdd2b 100644
--- a/app/src/main/java/com/example/ops_android/data/repository/WarehouseRepository.kt
+++ b/app/src/main/java/com/example/ops_android/data/repository/WarehouseRepository.kt
@@ -7,13 +7,14 @@ import com.example.ops_android.data.remote.api.ListContainerRequest
import com.example.ops_android.data.remote.api.GetContainerRequest
import com.example.ops_android.data.remote.api.ListItemRequest
import com.example.ops_android.data.remote.api.GetItemRequest
+import com.example.ops_android.data.remote.api.ItemResponse
import com.example.ops_android.data.remote.api.MoveItemRequest
import com.example.ops_android.data.remote.api.WarehouseCountResponse
class WarehouseRepository(
private val warehouseApi: WarehouseApi
) {
- suspend fun listContainer(parentId: Int = 0): Result> {
+ suspend fun listContainer(parentId: Int? = null): Result> {
return try {
val response = warehouseApi.listContainer(ListContainerRequest(parentId))
if (response.errCode == 0 && response.data != null) {
@@ -69,7 +70,7 @@ class WarehouseRepository(
}
}
- suspend fun listItem(containerId: Int = 0): Result> {
+ suspend fun listItem(containerId: Int? = null): Result> {
return try {
val response = warehouseApi.listItem(ListItemRequest(containerId))
if (response.errCode == 0 && response.data != null) {
@@ -82,11 +83,25 @@ class WarehouseRepository(
}
}
- suspend fun getItem(id: Int): Result- {
+ suspend fun searchItems(query: String): Result
> {
+ return try {
+ val request = ListItemRequest(search = query, entries = 10)
+ val response = warehouseApi.listItem(request)
+ if (response.errCode == 0 && response.data != null) {
+ Result.success(response.data.items ?: emptyList())
+ } else {
+ Result.failure(Exception("搜索物品失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun getItem(id: Int): Result {
return try {
val response = warehouseApi.getItem(GetItemRequest(id))
- if (response.errCode == 0 && response.data?.item != null) {
- Result.success(response.data.item)
+ if (response.errCode == 0 && response.data != null) {
+ Result.success(response.data)
} else {
Result.failure(Exception("获取物品详情失败"))
}
@@ -125,7 +140,7 @@ class WarehouseRepository(
}
}
- suspend fun moveItem(itemId: Int, newContainer: Int): Result {
+ suspend fun moveItem(itemId: Int, newContainer: Int?): Result {
return try {
val response = warehouseApi.moveItem(MoveItemRequest(itemId, newContainer))
if (response.errCode == 0) Result.success(Unit)
diff --git a/app/src/main/java/com/example/ops_android/printer/PrintHelper.kt b/app/src/main/java/com/example/ops_android/printer/PrintHelper.kt
index c299c13..acbe138 100644
--- a/app/src/main/java/com/example/ops_android/printer/PrintHelper.kt
+++ b/app/src/main/java/com/example/ops_android/printer/PrintHelper.kt
@@ -4,102 +4,106 @@ 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 kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
object PrintHelper {
+ private val printMutex = Mutex()
+
suspend fun printOrderLabel(
printerManager: PrinterManager,
order: PurchaseOrder
- ): Result = withContext(Dispatchers.IO) {
- try {
- printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
- printerManager.setFontSize(24)
- printerManager.setBold(true)
- printerManager.addText("采购单: ${order.title ?: "-"}\n")
- printerManager.setFontSize(20)
- printerManager.setBold(false)
- if (order.id > 0) {
- printerManager.addBarcode("PO-${order.id}").fold(
- onSuccess = {},
- onFailure = {}
+ ): Result = printMutex.withLock {
+ withContext(Dispatchers.IO) {
+ try {
+ printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
+ printerManager.setFontSize(24)
+ printerManager.setBold(true)
+ printerManager.addText("采购单: ${order.title ?: "-"}\n")
+ printerManager.setFontSize(20)
+ printerManager.setBold(false)
+ if (order.id > 0) {
+ printerManager.addBarcode("PO-${order.id}")
+ }
+ printerManager.addLineFeed(2)
+ printerManager.enableMarkDetection(true)
+ printerManager.commit().fold(
+ onSuccess = {
+ delay(1000)
+ printerManager.closeDevice()
+ Result.success(Unit)
+ },
+ onFailure = { Result.failure(it) }
)
+ } catch (e: Exception) {
+ Result.failure(e)
}
- printerManager.addLineFeed(2)
- printerManager.commit().fold(
- onSuccess = {
- Thread.sleep(1000)
- printerManager.closeDevice()
- Result.success(Unit)
- },
- onFailure = { Result.failure(it) }
- )
- } catch (e: Exception) {
- Result.failure(e)
}
}
suspend fun printWorkOrderLabel(
printerManager: PrinterManager,
workOrder: WorkOrder
- ): Result = 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}").fold(
- onSuccess = {},
- onFailure = {}
+ ): Result = 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}")
+ }
+ printerManager.addLineFeed(2)
+ printerManager.enableMarkDetection(true)
+ printerManager.commit().fold(
+ onSuccess = {
+ delay(1000)
+ printerManager.closeDevice()
+ Result.success(Unit)
+ },
+ onFailure = { Result.failure(it) }
)
+ } catch (e: Exception) {
+ Result.failure(e)
}
- printerManager.addLineFeed(2)
- printerManager.commit().fold(
- onSuccess = {
- Thread.sleep(1000)
- printerManager.closeDevice()
- Result.success(Unit)
- },
- onFailure = { Result.failure(it) }
- )
- } catch (e: Exception) {
- Result.failure(e)
}
}
suspend fun printItemLabel(
printerManager: PrinterManager,
item: Item
- ): Result = 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") }
- item.spec?.let { printerManager.addText("规格: $it\n") }
- if (item.id > 0) {
- printerManager.addBarcode("ITEM-${item.id}").fold(
- onSuccess = {},
- onFailure = {}
+ ): Result = 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}")
+ }
+ printerManager.addLineFeed(2)
+ printerManager.enableMarkDetection(true)
+ printerManager.commit().fold(
+ onSuccess = {
+ delay(1000)
+ printerManager.closeDevice()
+ Result.success(Unit)
+ },
+ onFailure = { Result.failure(it) }
)
+ } catch (e: Exception) {
+ Result.failure(e)
}
- printerManager.addLineFeed(2)
- printerManager.commit().fold(
- onSuccess = {
- Thread.sleep(1000)
- printerManager.closeDevice()
- Result.success(Unit)
- },
- onFailure = { Result.failure(it) }
- )
- } catch (e: Exception) {
- Result.failure(e)
}
}
@@ -107,31 +111,31 @@ object PrintHelper {
printerManager: PrinterManager,
containerName: String,
barcode: String? = null
- ): Result = withContext(Dispatchers.IO) {
- try {
- printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
- printerManager.setFontSize(24)
- printerManager.setBold(true)
- printerManager.addText("容器: $containerName\n")
- if (barcode != null) {
- printerManager.setFontSize(18)
- printerManager.setBold(false)
- printerManager.addBarcode(barcode).fold(
- onSuccess = {},
- onFailure = {}
+ ): Result = printMutex.withLock {
+ withContext(Dispatchers.IO) {
+ try {
+ printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
+ printerManager.setFontSize(24)
+ printerManager.setBold(true)
+ printerManager.addText("容器: $containerName\n")
+ if (barcode != null) {
+ printerManager.setFontSize(18)
+ printerManager.setBold(false)
+ printerManager.addBarcode(barcode)
+ }
+ printerManager.addLineFeed(2)
+ printerManager.enableMarkDetection(true)
+ printerManager.commit().fold(
+ onSuccess = {
+ delay(1000)
+ printerManager.closeDevice()
+ Result.success(Unit)
+ },
+ onFailure = { Result.failure(it) }
)
+ } catch (e: Exception) {
+ Result.failure(e)
}
- printerManager.addLineFeed(2)
- printerManager.commit().fold(
- onSuccess = {
- Thread.sleep(1000)
- printerManager.closeDevice()
- Result.success(Unit)
- },
- onFailure = { Result.failure(it) }
- )
- } catch (e: Exception) {
- Result.failure(e)
}
}
}
diff --git a/app/src/main/java/com/example/ops_android/ui/home/HomeScreen.kt b/app/src/main/java/com/example/ops_android/ui/home/HomeScreen.kt
index cbcbd04..eb942d7 100644
--- a/app/src/main/java/com/example/ops_android/ui/home/HomeScreen.kt
+++ b/app/src/main/java/com/example/ops_android/ui/home/HomeScreen.kt
@@ -8,6 +8,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -43,13 +44,19 @@ fun HomeScreen(
)
}
) { padding ->
- LazyColumn(
+ PullToRefreshBox(
+ isRefreshing = uiState.isRefreshing,
+ onRefresh = { viewModel.refresh() },
modifier = Modifier
.fillMaxSize()
.padding(padding)
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
if (uiState.isLoading) {
item {
Box(
@@ -181,6 +188,7 @@ fun HomeScreen(
}
}
}
+ }
}
}
diff --git a/app/src/main/java/com/example/ops_android/ui/home/HomeViewModel.kt b/app/src/main/java/com/example/ops_android/ui/home/HomeViewModel.kt
index 985c15a..f8ec53d 100644
--- a/app/src/main/java/com/example/ops_android/ui/home/HomeViewModel.kt
+++ b/app/src/main/java/com/example/ops_android/ui/home/HomeViewModel.kt
@@ -9,12 +9,9 @@ import com.example.ops_android.data.model.WorkOrderCounts
import com.example.ops_android.data.repository.OrderRepository
import com.example.ops_android.data.repository.ScheduleRepository
import com.example.ops_android.data.repository.WorkOrderRepository
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.*
@@ -24,6 +21,7 @@ data class HomeUiState(
val orderCounts: OrderCounts? = null,
val workOrderCounts: WorkOrderCounts? = null,
val isLoading: Boolean = false,
+ val isRefreshing: Boolean = false,
val errorMessage: String? = null
)
@@ -36,11 +34,8 @@ class HomeViewModel(
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow = _uiState.asStateFlow()
- private var autoRefreshJob: Job? = null
-
init {
loadData()
- startAutoRefresh()
}
fun loadData() {
@@ -67,18 +62,21 @@ class HomeViewModel(
}
}
- private fun startAutoRefresh() {
- autoRefreshJob = viewModelScope.launch {
- while (isActive) {
- delay(5000)
- loadData()
- }
- }
- }
+ fun refresh() {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isRefreshing = true)
+ val today = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
+ val eventsResult = scheduleRepository.getEvents(today, today)
+ val orderCounts = orderRepository.getOrderCount()
+ val workOrderCounts = workOrderRepository.getCount()
- override fun onCleared() {
- super.onCleared()
- autoRefreshJob?.cancel()
+ _uiState.value = _uiState.value.copy(
+ todayEvents = eventsResult.getOrNull() ?: emptyList(),
+ orderCounts = orderCounts.getOrNull(),
+ workOrderCounts = workOrderCounts.getOrNull(),
+ isRefreshing = false
+ )
+ }
}
class Factory(
diff --git a/app/src/main/java/com/example/ops_android/ui/navigation/OPSNavGraph.kt b/app/src/main/java/com/example/ops_android/ui/navigation/OPSNavGraph.kt
index 814cbec..a1c673c 100644
--- a/app/src/main/java/com/example/ops_android/ui/navigation/OPSNavGraph.kt
+++ b/app/src/main/java/com/example/ops_android/ui/navigation/OPSNavGraph.kt
@@ -45,7 +45,7 @@ object Routes {
const val ORDER_DETAIL = "order_detail/{orderId}"
const val ORDER_FORM = "order_form?editId={editId}"
const val WORK_ORDER_DETAIL = "work_order_detail/{id}"
- const val WORK_ORDER_FORM = "work_order_form?editId={editId}"
+ const val WORK_ORDER_FORM = "work_order_form?editId={editId}&prefillItemId={prefillItemId}"
const val ITEM_DETAIL = "item_detail/{id}"
const val ITEM_FORM = "item_form/{containerId}?editId={editId}"
const val CUSTOMER_LIST = "customer_list"
@@ -53,7 +53,8 @@ object Routes {
fun orderDetail(orderId: Int) = "order_detail/$orderId"
fun orderForm(editId: Int? = null) = if (editId != null) "order_form?editId=$editId" else "order_form"
fun workOrderDetail(id: Int) = "work_order_detail/$id"
- fun workOrderForm(editId: Int? = null) = if (editId != null) "work_order_form?editId=$editId" else "work_order_form"
+ fun workOrderForm(editId: Int? = null, prefillItemId: Int = -1) =
+ "work_order_form?editId=${editId ?: -1}&prefillItemId=$prefillItemId"
fun itemDetail(id: Int) = "item_detail/$id"
fun itemForm(containerId: Int, editId: Int? = null) = if (editId != null) "item_form/$containerId?editId=$editId" else "item_form/$containerId"
}
@@ -120,13 +121,19 @@ fun OPSNavGraph(isLoggedIn: Boolean) {
val id = backStackEntry.arguments?.getInt("id") ?: return@composable
WorkOrderDetailScreen(id = id, onBack = { rootNavController.popBackStack() })
}
- composable(Routes.WORK_ORDER_FORM, arguments = listOf(navArgument("editId") { type = NavType.IntType; defaultValue = -1 })) { backStackEntry ->
+ composable(Routes.WORK_ORDER_FORM, arguments = listOf(
+ navArgument("editId") { type = NavType.IntType; defaultValue = -1 },
+ navArgument("prefillItemId") { type = NavType.IntType; defaultValue = -1 }
+ )) { backStackEntry ->
val editId = backStackEntry.arguments?.getInt("editId")?.takeIf { it > 0 }
- WorkOrderFormScreen(editId = editId, onBack = { rootNavController.popBackStack() }, onSaved = { rootNavController.popBackStack() })
+ val prefillItemId = backStackEntry.arguments?.getInt("prefillItemId")?.takeIf { it > 0 }
+ WorkOrderFormScreen(editId = editId, prefillItemId = prefillItemId, onBack = { rootNavController.popBackStack() }, onSaved = { rootNavController.popBackStack() })
}
composable(Routes.ITEM_DETAIL, arguments = listOf(navArgument("id") { type = NavType.IntType })) { backStackEntry ->
val id = backStackEntry.arguments?.getInt("id") ?: return@composable
- ItemDetailScreen(id = id, onBack = { rootNavController.popBackStack() })
+ ItemDetailScreen(id = id, onBack = { rootNavController.popBackStack() },
+ onCreateWorkOrder = { itemId -> rootNavController.navigate(Routes.workOrderForm(prefillItemId = itemId)) },
+ onWorkOrderClick = { woId -> rootNavController.navigate(Routes.workOrderDetail(woId)) })
}
composable(Routes.ITEM_FORM, arguments = listOf(navArgument("containerId") { type = NavType.IntType }, navArgument("editId") { type = NavType.IntType; defaultValue = -1 })) { backStackEntry ->
val containerId = backStackEntry.arguments?.getInt("containerId") ?: 0
@@ -186,7 +193,8 @@ fun MainScreen(onLogout: () -> Unit, onNavigate: (String) -> Unit) {
}
composable(BottomTabs.WAREHOUSE) {
WarehouseScreen(
- onItemClick = { id -> onNavigate(Routes.itemDetail(id)) }
+ onItemClick = { id -> onNavigate(Routes.itemDetail(id)) },
+ onAddItemClick = { containerId -> onNavigate(Routes.itemForm(containerId)) }
)
}
composable(BottomTabs.PROFILE) {
diff --git a/app/src/main/java/com/example/ops_android/ui/order/OrderListScreen.kt b/app/src/main/java/com/example/ops_android/ui/order/OrderListScreen.kt
index d1e0753..a18d75b 100644
--- a/app/src/main/java/com/example/ops_android/ui/order/OrderListScreen.kt
+++ b/app/src/main/java/com/example/ops_android/ui/order/OrderListScreen.kt
@@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -53,7 +54,7 @@ fun OrderListScreen(
) { padding ->
Column(modifier = Modifier.padding(padding)) {
// Status filter tabs
- ScrollableTabRow(
+ PrimaryScrollableTabRow(
selectedTabIndex = ORDER_STATUSES.indexOfFirst { it.first == uiState.selectedStatus }.coerceAtLeast(0),
modifier = Modifier.fillMaxWidth(),
edgePadding = 8.dp
@@ -89,29 +90,35 @@ fun OrderListScreen(
}
}
else -> {
- LazyColumn(
- modifier = Modifier.fillMaxSize(),
- contentPadding = PaddingValues(8.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
+ PullToRefreshBox(
+ isRefreshing = uiState.isRefreshing,
+ onRefresh = { viewModel.refresh() },
+ modifier = Modifier.fillMaxSize()
) {
- items(uiState.orders, key = { it.id }) { order ->
- OrderListItem(
- order = order,
- onClick = { onOrderClick(order.id) }
- )
- }
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(uiState.orders, key = { it.id }) { order ->
+ OrderListItem(
+ order = order,
+ onClick = { onOrderClick(order.id) }
+ )
+ }
- if (uiState.isLoadingMore) {
- item {
- Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
- CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ if (uiState.isLoadingMore) {
+ item {
+ Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ }
}
}
- }
- // infinite scroll trigger
- item {
- LaunchedEffect(Unit) { viewModel.loadMore() }
+ // infinite scroll trigger
+ item {
+ LaunchedEffect(Unit) { viewModel.loadMore() }
+ }
}
}
}
diff --git a/app/src/main/java/com/example/ops_android/ui/profile/ProfileScreen.kt b/app/src/main/java/com/example/ops_android/ui/profile/ProfileScreen.kt
index 008842b..6565b00 100644
--- a/app/src/main/java/com/example/ops_android/ui/profile/ProfileScreen.kt
+++ b/app/src/main/java/com/example/ops_android/ui/profile/ProfileScreen.kt
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material3.*
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -35,6 +36,10 @@ fun ProfileScreen(
val uiState by viewModel.uiState.collectAsState()
var showLogoutDialog by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ viewModel.loadProfile()
+ }
+
LaunchedEffect(uiState.isLoggedOut) {
if (uiState.isLoggedOut) {
onLogout()
@@ -52,12 +57,35 @@ fun ProfileScreen(
)
}
) { padding ->
- Column(
+ PullToRefreshBox(
+ isRefreshing = uiState.isRefreshing,
+ onRefresh = { viewModel.refresh() },
modifier = Modifier
.fillMaxSize()
.padding(padding)
- .verticalScroll(rememberScrollState())
) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ if (uiState.isLoading) {
+ Box(
+ modifier = Modifier.fillMaxWidth().padding(32.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+
+ uiState.errorMessage?.let { error ->
+ Card(
+ modifier = Modifier.fillMaxWidth().padding(16.dp),
+ colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE))
+ ) {
+ Text(text = error, modifier = Modifier.padding(16.dp), color = Color(0xFFC62828))
+ }
+ }
Box(
modifier = Modifier
.fillMaxWidth()
@@ -129,6 +157,7 @@ fun ProfileScreen(
)
}
}
+ }
if (showLogoutDialog) {
AlertDialog(
diff --git a/app/src/main/java/com/example/ops_android/ui/profile/ProfileViewModel.kt b/app/src/main/java/com/example/ops_android/ui/profile/ProfileViewModel.kt
index 6462989..73c4e7c 100644
--- a/app/src/main/java/com/example/ops_android/ui/profile/ProfileViewModel.kt
+++ b/app/src/main/java/com/example/ops_android/ui/profile/ProfileViewModel.kt
@@ -16,6 +16,7 @@ data class ProfileUiState(
val user: UserInfo? = null,
val userInfo: UserDetail? = null,
val isLoading: Boolean = false,
+ val isRefreshing: Boolean = false,
val errorMessage: String? = null,
val isLoggedOut: Boolean = false
)
@@ -55,6 +56,28 @@ class ProfileViewModel(
}
}
+ fun refresh() {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isRefreshing = true)
+ authRepository.getUserInfo().fold(
+ onSuccess = { (user, userInfo) ->
+ _uiState.value = _uiState.value.copy(
+ user = user,
+ userInfo = userInfo,
+ isRefreshing = false,
+ errorMessage = null
+ )
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ isRefreshing = false,
+ errorMessage = e.message ?: "加载失败"
+ )
+ }
+ )
+ }
+ }
+
fun logout() {
viewModelScope.launch {
authRepository.logout()
@@ -66,7 +89,7 @@ class ProfileViewModel(
get() {
val info = _uiState.value.userInfo
val user = _uiState.value.user
- return info?.Username ?: user?.Name ?: ""
+ return info?.Username?.ifBlank { null } ?: user?.Name ?: ""
}
val avatarPath: String?
diff --git a/app/src/main/java/com/example/ops_android/ui/search/SearchScreen.kt b/app/src/main/java/com/example/ops_android/ui/search/SearchScreen.kt
index 610e49f..664ad63 100644
--- a/app/src/main/java/com/example/ops_android/ui/search/SearchScreen.kt
+++ b/app/src/main/java/com/example/ops_android/ui/search/SearchScreen.kt
@@ -138,7 +138,7 @@ fun SearchScreen(
)
// Category tabs
- ScrollableTabRow(
+ PrimaryScrollableTabRow(
selectedTabIndex = SearchCategory.entries.indexOf(uiState.category),
modifier = Modifier.fillMaxWidth(),
edgePadding = 8.dp
diff --git a/app/src/main/java/com/example/ops_android/ui/warehouse/ItemDetailScreen.kt b/app/src/main/java/com/example/ops_android/ui/warehouse/ItemDetailScreen.kt
index e290404..156fef9 100644
--- a/app/src/main/java/com/example/ops_android/ui/warehouse/ItemDetailScreen.kt
+++ b/app/src/main/java/com/example/ops_android/ui/warehouse/ItemDetailScreen.kt
@@ -1,23 +1,32 @@
package com.example.ops_android.ui.warehouse
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
-import com.example.ops_android.data.model.Item
+import coil.compose.AsyncImage
+import com.example.ops_android.data.model.TabFileInfo
+import com.example.ops_android.data.remote.api.ItemCommitDetail
+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.ui.LocalAppContainer
import kotlinx.coroutines.flow.MutableStateFlow
@@ -25,36 +34,203 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
-data class ItemDetailUiState(val item: Item? = null, val isLoading: Boolean = false, val errorMessage: String? = null)
+data class ItemDetailUiState(
+ val itemResponse: ItemResponse? = null,
+ val isLoading: Boolean = false,
+ val isDeleting: Boolean = false,
+ val errorMessage: String? = null,
+ val deleted: Boolean = false
+)
+
+class ItemDetailViewModel(
+ private val repo: WarehouseRepository,
+ private val baseUrl: String,
+ private val id: Int
+) : ViewModel() {
+ private val _s = MutableStateFlow(ItemDetailUiState())
+ val s: StateFlow = _s.asStateFlow()
-class ItemDetailViewModel(repo: WarehouseRepository, private val id: Int) : ViewModel() {
- private val _s = MutableStateFlow(ItemDetailUiState()); val s: StateFlow = _s.asStateFlow()
- private val repository = repo
init { load() }
- private fun load() { viewModelScope.launch { _s.value = _s.value.copy(isLoading = true); repository.getItem(id).fold(onSuccess = { _s.value = _s.value.copy(item = it, isLoading = false) }, onFailure = { _s.value = _s.value.copy(isLoading = false, errorMessage = it.message) }) } }
- class Factory(private val repo: WarehouseRepository, private val id: Int) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = ItemDetailViewModel(repo, id) as T }
+
+ fun load() {
+ viewModelScope.launch {
+ _s.value = _s.value.copy(isLoading = true)
+ repo.getItem(id).fold(
+ onSuccess = { _s.value = _s.value.copy(itemResponse = it, isLoading = false) },
+ onFailure = { _s.value = _s.value.copy(isLoading = false, errorMessage = it.message) }
+ )
+ }
+ }
+
+ fun delete() {
+ viewModelScope.launch {
+ _s.value = _s.value.copy(isDeleting = true)
+ repo.deleteItem(id).fold(
+ onSuccess = { _s.value = _s.value.copy(isDeleting = false, deleted = true) },
+ onFailure = { _s.value = _s.value.copy(isDeleting = false, errorMessage = it.message) }
+ )
+ }
+ }
+
+ fun move(newContainerId: Int?) {
+ viewModelScope.launch {
+ _s.value = _s.value.copy(isLoading = true)
+ val targetId = newContainerId ?: 0 // 0 means unstored
+ repo.moveItem(id, targetId).fold(
+ onSuccess = { load() },
+ onFailure = { _s.value = _s.value.copy(isLoading = false, errorMessage = it.message) }
+ )
+ }
+ }
+
+ fun photoUrl(hash: String?): String {
+ if (hash.isNullOrBlank()) return ""
+ val url = baseUrl.trimEnd('/')
+ return "$url/files/get/$hash"
+ }
+
+ class Factory(
+ private val repo: WarehouseRepository,
+ private val baseUrl: String,
+ private val id: Int
+ ) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(c: Class): T = ItemDetailViewModel(repo, baseUrl, id) as T
+ }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun ItemDetailScreen(id: Int, onBack: () -> Unit) {
- val vm: ItemDetailViewModel = viewModel(key = "item_$id", factory = ItemDetailViewModel.Factory(LocalAppContainer.current.warehouseRepository, id))
- val s by vm.s.collectAsState(); val item = s.item
+fun ItemDetailScreen(id: Int, onBack: () -> Unit, onCreateWorkOrder: (Int) -> Unit = {}, onWorkOrderClick: (Int) -> Unit = {}) {
+ val app = LocalAppContainer.current
+ val vm: ItemDetailViewModel = viewModel(
+ key = "item_detail_$id",
+ factory = ItemDetailViewModel.Factory(app.warehouseRepository, app.sessionManager.cachedBaseUrl, id)
+ )
+ val s by vm.s.collectAsState()
+ val resp = s.itemResponse
+ val item = resp?.item
+ var tabIndex by remember { mutableIntStateOf(0) }
+ var showDeleteDialog by remember { mutableStateOf(false) }
+ var showMoveDialog by remember { mutableStateOf(false) }
+ var previewPhotoUrl by remember { mutableStateOf(null) }
- Scaffold(topBar = { TopAppBar(title = { Text("物品详情") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) }) { padding ->
+ LaunchedEffect(s.deleted) { if (s.deleted) onBack() }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("物品详情") },
+ navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } },
+ actions = {
+ 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) }
+ IconButton(onClick = { showDeleteDialog = true }) { Icon(Icons.Default.Delete, "删除", tint = Color.White) }
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)
+ )
+ }
+ ) { padding ->
if (s.isLoading) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() }; return@Scaffold }
- if (item == null) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text(s.errorMessage ?: "加载失败") }; return@Scaffold }
+ if (item == null) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text(s.errorMessage ?: "加载失败", color = Color.Red) }; return@Scaffold }
- LazyColumn(Modifier.fillMaxSize().padding(padding).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
- item { Text("基本信息", fontWeight = FontWeight.Bold, fontSize = 18.sp); Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { DetailRow("名称", item.name ?: "-"); DetailRow("编号", item.serial_number ?: "-"); DetailRow("规格", item.spec ?: "-"); DetailRow("条码", item.barcode ?: "-"); DetailRow("备注", item.remark ?: "-"); DetailRow("创建时间", item.created_at ?: "-") } } }
+ Column(Modifier.fillMaxSize().padding(padding)) {
+ LazyColumn(Modifier.weight(1f).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ // Basic info
+ item {
+ Text(item.name ?: "未命名", fontWeight = FontWeight.Bold, fontSize = 20.sp)
+ item.serial_number?.let { Text(it, fontSize = 14.sp, color = Color.Gray) }
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Text("数量: ${item.quantity}", fontSize = 13.sp, color = Color.Gray)
+ resp.container_breadcrumb?.let { breadcrumb ->
+ if (breadcrumb.isNotBlank()) {
+ Text("位置: $breadcrumb", fontSize = 13.sp, color = Color(0xFF667EEA))
+ }
+ }
+ }
+ }
- item.move_history?.let { history ->
- if (history.isNotEmpty()) {
- item { Text("移动记录", fontWeight = FontWeight.Bold, fontSize = 16.sp) }
- items(history) { h ->
- Card(Modifier.fillMaxWidth()) {
- Column(Modifier.padding(12.dp)) {
- Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text("${h.from_container ?: "-"} → ${h.to_container ?: "-"}", fontWeight = FontWeight.Medium); Text(h.moved_at ?: "", fontSize = 12.sp, color = Color.Gray) }
+ // Photos
+ val photos = resp.photos
+ if (!photos.isNullOrEmpty()) {
+ item {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ photos.forEach { photo ->
+ val url = vm.photoUrl(photo.sha256)
+ AsyncImage(
+ model = url, contentDescription = null,
+ modifier = Modifier.size(100.dp).clickable { previewPhotoUrl = url },
+ contentScale = ContentScale.Crop
+ )
+ }
+ }
+ }
+ }
+
+ // Remark
+ item.remark?.let { remark ->
+ if (remark.isNotBlank()) {
+ item { Text("备注: $remark", fontSize = 13.sp, color = Color.Gray) }
+ }
+ }
+
+ // Dates
+ item {
+ Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
+ item.created_at?.let { Text("创建: $it", fontSize = 12.sp, color = Color.Gray) }
+ item.updated_at?.let { Text("更新: $it", fontSize = 12.sp, color = Color.Gray) }
+ }
+ }
+
+ // Tabs
+ item {
+ TabRow(selectedTabIndex = tabIndex) {
+ Tab(tabIndex == 0, { tabIndex = 0 }) { Text("关联工单") }
+ Tab(tabIndex == 1, { tabIndex = 1 }) { Text("关联客户") }
+ Tab(tabIndex == 2, { tabIndex = 2 }) { Text("移动历史") }
+ }
+ }
+
+ when (tabIndex) {
+ 0 -> {
+ val workOrders = resp.work_orders
+ if (workOrders.isNullOrEmpty()) {
+ item { Text("暂无关联工单", Modifier.padding(16.dp), color = Color.Gray) }
+ } else {
+ items(workOrders) { wo ->
+ WorkOrderRow(wo) { onWorkOrderClick(wo.id) }
+ }
+ }
+ }
+ 1 -> {
+ val customers = resp.customers
+ if (customers.isNullOrEmpty()) {
+ item { Text("暂无关联客户", Modifier.padding(16.dp), color = Color.Gray) }
+ } else {
+ items(customers) { c ->
+ CustomerRow(c)
+ }
+ }
+ }
+ 2 -> {
+ val commits = resp.commits
+ if (commits.isNullOrEmpty()) {
+ item { Text("暂无移动记录", Modifier.padding(16.dp), color = Color.Gray) }
+ } else {
+ items(commits) { commit ->
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(12.dp)) {
+ Text(commit.CreatedAt ?: "", fontSize = 12.sp, color = Color.Gray)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(commit.OldContainerBreadcrumb ?: "未入库", fontSize = 13.sp)
+ Icon(Icons.Default.ArrowForward, null, Modifier.size(16.dp), tint = Color.Gray)
+ Text(commit.NewContainerBreadcrumb ?: "未入库", fontSize = 13.sp, color = Color(0xFF667EEA))
+ }
+ commit.Remark?.let { if (it.isNotBlank()) Text(it, fontSize = 12.sp, color = Color.Gray) }
+ }
+ }
}
}
}
@@ -62,9 +238,117 @@ fun ItemDetailScreen(id: Int, onBack: () -> Unit) {
}
}
}
+
+ if (showDeleteDialog) {
+ AlertDialog(
+ onDismissRequest = { showDeleteDialog = false },
+ title = { Text("删除物品") },
+ text = { Text("确定要删除「${item?.name}」吗?此操作不可撤销。") },
+ confirmButton = {
+ TextButton(onClick = { showDeleteDialog = false; vm.delete() }) {
+ Text("确定", color = Color(0xFFF44336))
+ }
+ },
+ dismissButton = { TextButton(onClick = { showDeleteDialog = false }) { Text("取消") } }
+ )
+ }
+
+ if (showMoveDialog) {
+ MoveDialog(
+ currentBreadcrumb = resp?.container_breadcrumb ?: "未入库",
+ onDismiss = { showMoveDialog = false },
+ onMove = { newContainerId ->
+ showMoveDialog = false
+ vm.move(newContainerId)
+ }
+ )
+ }
+
+ if (previewPhotoUrl != null) {
+ Dialog(onDismissRequest = { previewPhotoUrl = null }) {
+ Card(Modifier.fillMaxWidth()) {
+ AsyncImage(model = previewPhotoUrl!!, contentDescription = null, modifier = Modifier.fillMaxWidth(), contentScale = ContentScale.FillWidth)
+ }
+ }
+ }
}
@Composable
-private fun DetailRow(label: String, value: String) {
- Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text(label, color = Color.Gray, modifier = Modifier.width(80.dp)); Text(value) }
+private fun WorkOrderRow(wo: LinkedWorkOrder, onClick: () -> Unit = {}) {
+ val statusColors = mapOf("pending" to Color(0xFFFF9800), "checked" to Color(0xFF2196F3), "parts_ordered" to Color(0xFF9C27B0), "repaired" to Color(0xFF4CAF50), "returned" to Color(0xFF4CAF50), "unrepairable" to Color(0xFFF44336))
+ val statusLabels = mapOf("pending" to "待处理", "checked" to "已检查", "parts_ordered" to "待配件", "repaired" to "已修复", "returned" to "已返还", "unrepairable" to "不可修")
+ Card(Modifier.fillMaxWidth().clickable { onClick() }) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(wo.title ?: "", fontWeight = FontWeight.Medium)
+ }
+ Surface(shape = MaterialTheme.shapes.small, color = statusColors[wo.status]?.copy(alpha = 0.15f) ?: Color.Gray) {
+ Text(statusLabels[wo.status] ?: wo.status ?: "", modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), fontSize = 11.sp, color = statusColors[wo.status] ?: Color.Gray)
+ }
+ }
+ }
+}
+
+@Composable
+private fun CustomerRow(c: LinkedCustomerInfo) {
+ Card(Modifier.fillMaxWidth()) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Person, null, Modifier.size(24.dp), tint = Color(0xFF667EEA))
+ Spacer(Modifier.width(8.dp))
+ Column {
+ val name = listOfNotNull(c.last_name, c.first_name).joinToString(" ").ifEmpty { "未命名" }
+ Text(name, fontWeight = FontWeight.Medium)
+ c.primary_phone?.let { Text(it, fontSize = 12.sp, color = Color.Gray) }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun MoveDialog(currentBreadcrumb: String, onDismiss: () -> Unit, onMove: (Int?) -> Unit) {
+ var search by remember { mutableStateOf("") }
+ var results by remember { mutableStateOf>(emptyList()) }
+ var selectedId by remember { mutableStateOf(null) }
+ val app = LocalAppContainer.current
+
+ fun doSearch() {
+ kotlinx.coroutines.MainScope().launch {
+ app.warehouseRepository.listContainer().fold(
+ onSuccess = { containers ->
+ results = if (search.isBlank()) containers.take(10)
+ else containers.filter { (it.name ?: "").contains(search, ignoreCase = true) }.take(10)
+ },
+ onFailure = { }
+ )
+ }
+ }
+
+ LaunchedEffect(Unit) { doSearch() }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("移动物品") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("当前位置: $currentBreadcrumb", fontSize = 13.sp, color = Color.Gray)
+ OutlinedTextField(search, { search = it; doSearch() }, label = { Text("搜索目标容器") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ TextButton(onClick = { selectedId = null }) {
+ Text("→ 未入库(取出物品)", color = Color(0xFFFF9800))
+ }
+ results.take(5).forEach { c ->
+ Surface(
+ Modifier.fillMaxWidth().clickable { selectedId = c.id },
+ color = if (selectedId == c.id) Color(0xFFE8EAF6) else Color.Transparent
+ ) {
+ Text(c.name ?: "?", Modifier.padding(8.dp))
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = { onMove(selectedId) }) { Text("确认移动") }
+ },
+ dismissButton = { TextButton(onClick = onDismiss) { Text("取消") } }
+ )
}
diff --git a/app/src/main/java/com/example/ops_android/ui/warehouse/ItemFormScreen.kt b/app/src/main/java/com/example/ops_android/ui/warehouse/ItemFormScreen.kt
index bbc8f5a..89bc152 100644
--- a/app/src/main/java/com/example/ops_android/ui/warehouse/ItemFormScreen.kt
+++ b/app/src/main/java/com/example/ops_android/ui/warehouse/ItemFormScreen.kt
@@ -1,65 +1,325 @@
package com.example.ops_android.ui.warehouse
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
+import coil.compose.AsyncImage
+import com.example.ops_android.data.local.SessionManager
+import com.example.ops_android.data.model.Customer
+import com.example.ops_android.data.remote.api.FileApi
+import com.example.ops_android.data.repository.CustomerRepository
import com.example.ops_android.data.repository.WarehouseRepository
import com.example.ops_android.ui.LocalAppContainer
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.io.File
-data class ItemFormUiState(
- val name: String = "", val serialNumber: String = "", val spec: String = "", val remark: String = "",
- val isSubmitting: Boolean = false, val errorMessage: String? = null, val success: Boolean = false
+data class ItemPhoto(
+ val uri: Uri,
+ val hash: String? = null,
+ val isUploading: Boolean = false
)
-class ItemFormViewModel(private val repo: WarehouseRepository, private val containerId: Int, private val editId: Int? = null) : ViewModel() {
+data class ItemFormUiState(
+ val name: String = "",
+ val serialNumber: String = "",
+ val quantity: String = "1",
+ val remark: String = "",
+ val selectedCustomers: List = emptyList(),
+ val photos: List = emptyList(),
+ val customerSearchQuery: String = "",
+ val customerSearchResults: List = emptyList(),
+ val isSearchingCustomers: Boolean = false,
+ val isSubmitting: Boolean = false,
+ val errorMessage: String? = null,
+ val success: Boolean = false
+)
+
+class ItemFormViewModel(
+ private val repo: WarehouseRepository,
+ private val customerRepo: CustomerRepository,
+ private val fileApi: FileApi,
+ private val sessionManager: SessionManager,
+ private val context: android.content.Context,
+ private val containerId: Int,
+ private val editId: Int? = null
+) : ViewModel() {
private val _s = MutableStateFlow(ItemFormUiState()); val s: StateFlow = _s.asStateFlow()
+ private var customerSearchJob: Job? = null
+
fun onName(v: String) { _s.value = _s.value.copy(name = v) }
fun onSerial(v: String) { _s.value = _s.value.copy(serialNumber = v) }
- fun onSpec(v: String) { _s.value = _s.value.copy(spec = v) }
- fun onRemark(v: String) { _s.value = _s.value.copy(remark = v) }
- fun submit() {
- if (_s.value.name.isBlank()) { _s.value = _s.value.copy(errorMessage = "名称不能为空"); return }
- val data = mapOf("name" to _s.value.name, "serial_number" to _s.value.serialNumber, "spec" to _s.value.spec, "remark" to _s.value.remark, "container_id" to containerId)
- viewModelScope.launch {
- _s.value = _s.value.copy(isSubmitting = true)
- val r = if (editId != null) repo.updateItem(data + ("id" to editId)) else repo.addItem(data)
- r.fold(onSuccess = { _s.value = _s.value.copy(isSubmitting = false, success = true) }, onFailure = { _s.value = _s.value.copy(isSubmitting = false, errorMessage = it.message) })
+ fun onQuantity(v: String) {
+ if (v.isEmpty() || v.all { it.isDigit() }) {
+ _s.value = _s.value.copy(quantity = v)
}
}
- class Factory(private val repo: WarehouseRepository, private val containerId: Int, private val editId: Int? = null) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = ItemFormViewModel(repo, containerId, editId) as T }
+ fun onRemark(v: String) { _s.value = _s.value.copy(remark = v) }
+
+ fun onCustomerSearchQuery(v: String) {
+ _s.value = _s.value.copy(customerSearchQuery = v)
+ customerSearchJob?.cancel()
+ customerSearchJob = viewModelScope.launch {
+ delay(300)
+ if (v.isBlank()) {
+ _s.value = _s.value.copy(customerSearchResults = emptyList(), isSearchingCustomers = false)
+ return@launch
+ }
+ _s.value = _s.value.copy(isSearchingCustomers = true)
+ customerRepo.list(v).fold(
+ onSuccess = { _s.value = _s.value.copy(customerSearchResults = it, isSearchingCustomers = false) },
+ onFailure = { _s.value = _s.value.copy(isSearchingCustomers = false) }
+ )
+ }
+ }
+
+ fun addCustomer(c: Customer) {
+ val current = _s.value.selectedCustomers
+ if (current.none { it.id == c.id }) {
+ _s.value = _s.value.copy(selectedCustomers = current + c, customerSearchQuery = "", customerSearchResults = emptyList())
+ }
+ }
+
+ fun removeCustomer(c: Customer) {
+ _s.value = _s.value.copy(selectedCustomers = _s.value.selectedCustomers.filter { it.id != c.id })
+ }
+
+ fun addPhoto(uri: Uri) {
+ val photo = ItemPhoto(uri = uri, isUploading = true)
+ _s.value = _s.value.copy(photos = _s.value.photos + photo)
+ viewModelScope.launch {
+ val inputStream = context.contentResolver.openInputStream(uri)
+ val bytes = inputStream?.readBytes() ?: return@launch
+ inputStream.close()
+ val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
+ val requestBody = bytes.toRequestBody(mime.toMediaTypeOrNull())
+ val filePart = MultipartBody.Part.createFormData("file", "photo.jpg", requestBody)
+ val cookiePart = sessionManager.cachedCookieValue
+ .toRequestBody("text/plain".toMediaTypeOrNull())
+ val result = try {
+ val response = fileApi.uploadImage(cookiePart, filePart)
+ if (response.errCode == 0 && response.data != null) response.data else null
+ } catch (e: Exception) { null }
+ _s.value = _s.value.copy(photos = _s.value.photos.map {
+ if (it.uri == uri) it.copy(hash = result?.hash, isUploading = false) else it
+ })
+ }
+ }
+
+ fun removePhoto(uri: Uri) {
+ _s.value = _s.value.copy(photos = _s.value.photos.filter { it.uri != uri })
+ }
+
+ fun submit() {
+ if (_s.value.name.isBlank()) { _s.value = _s.value.copy(errorMessage = "名称不能为空"); return }
+ val state = _s.value
+ val qty = state.quantity.toIntOrNull() ?: 1
+ val data = mutableMapOf(
+ "name" to state.name,
+ "serial_number" to state.serialNumber,
+ "quantity" to qty,
+ "remark" to state.remark
+ )
+ if (containerId > 0) {
+ data["container_id"] = containerId
+ }
+ if (state.selectedCustomers.isNotEmpty()) {
+ data["customer_ids"] = state.selectedCustomers.map { it.id }
+ }
+ val photoHashes = state.photos.mapNotNull { it.hash }
+ if (photoHashes.isNotEmpty()) {
+ data["photos"] = photoHashes
+ }
+ viewModelScope.launch {
+ _s.value = _s.value.copy(isSubmitting = true, errorMessage = null)
+ val r = if (editId != null) repo.updateItem(data + ("id" to editId)) else repo.addItem(data)
+ r.fold(
+ onSuccess = { _s.value = _s.value.copy(isSubmitting = false, success = true) },
+ onFailure = { _s.value = _s.value.copy(isSubmitting = false, errorMessage = it.message) }
+ )
+ }
+ }
+
+ class Factory(
+ private val repo: WarehouseRepository,
+ private val customerRepo: CustomerRepository,
+ private val fileApi: FileApi,
+ private val sessionManager: SessionManager,
+ private val context: android.content.Context,
+ private val containerId: Int,
+ private val editId: Int? = null
+ ) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(c: Class): T =
+ ItemFormViewModel(repo, customerRepo, fileApi, sessionManager, context, containerId, editId) as T
+ }
}
-@OptIn(ExperimentalMaterial3Api::class)
+private fun createTempImageFile(context: android.content.Context): File {
+ val dir = File(context.cacheDir, "photos")
+ if (!dir.exists()) dir.mkdirs()
+ return File(dir, "capture_${System.currentTimeMillis()}.jpg")
+}
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun ItemFormScreen(containerId: Int = 0, editId: Int? = null, onBack: () -> Unit, onSaved: () -> Unit) {
- val vm: ItemFormViewModel = viewModel(factory = ItemFormViewModel.Factory(LocalAppContainer.current.warehouseRepository, containerId, editId))
+ val app = LocalAppContainer.current
+ val context = LocalContext.current
+ val vm: ItemFormViewModel = viewModel(
+ key = "item_form_$editId",
+ factory = ItemFormViewModel.Factory(app.warehouseRepository, app.customerRepository, app.fileApi, app.sessionManager, context, containerId, editId)
+ )
val s by vm.s.collectAsState()
LaunchedEffect(s.success) { if (s.success) onSaved() }
- Scaffold(topBar = { TopAppBar(title = { Text(if (editId != null) "编辑物品" else "添加物品") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) }) { padding ->
+ var showPhotoDialog by remember { mutableStateOf(false) }
+ var cameraFileUri by remember { mutableStateOf(null) }
+
+ val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
+ val uri = cameraFileUri
+ if (success && uri != null) vm.addPhoto(uri)
+ }
+ val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ uri?.let { vm.addPhoto(it) }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(if (editId != null) "编辑物品" else "添加物品") },
+ navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)
+ )
+ }
+ ) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
- OutlinedTextField(s.name, vm::onName, label = { Text("名称 *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
- OutlinedTextField(s.serialNumber, vm::onSerial, label = { Text("编号") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
- OutlinedTextField(s.spec, vm::onSpec, label = { Text("规格") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ OutlinedTextField(s.name, vm::onName, label = { Text("物品名称 *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ OutlinedTextField(s.serialNumber, vm::onSerial, label = { Text("序列号") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ OutlinedTextField(
+ s.quantity, vm::onQuantity, label = { Text("数量") },
+ modifier = Modifier.fillMaxWidth(), singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
+ )
OutlinedTextField(s.remark, vm::onRemark, label = { Text("备注") }, modifier = Modifier.fillMaxWidth(), minLines = 2)
+
+ // ── 关联客户 ──
+ Text("关联客户", fontWeight = FontWeight.Bold, fontSize = 15.sp)
+ if (s.selectedCustomers.isNotEmpty()) {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ s.selectedCustomers.forEach { c ->
+ InputChip(
+ selected = true, onClick = { vm.removeCustomer(c) },
+ label = { Text(c.name ?: "?", fontSize = 12.sp) },
+ trailingIcon = { Icon(Icons.Default.Close, null, Modifier.size(16.dp)) }
+ )
+ }
+ }
+ }
+ Column {
+ OutlinedTextField(s.customerSearchQuery, vm::onCustomerSearchQuery, label = { Text("搜索客户") }, modifier = Modifier.fillMaxWidth(), singleLine = true, leadingIcon = { Icon(Icons.Default.Search, null) })
+ if (s.isSearchingCustomers) LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 4.dp))
+ s.customerSearchResults.take(5).forEach { c ->
+ Surface(Modifier.fillMaxWidth().clickable { vm.addCustomer(c) }, color = Color(0xFFF5F5F5)) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Person, null, Modifier.size(20.dp), tint = Color(0xFF667EEA))
+ Spacer(Modifier.width(8.dp))
+ Column(Modifier.weight(1f)) {
+ Text(c.name ?: "", fontSize = 14.sp)
+ c.phone?.let { Text(it, fontSize = 12.sp, color = Color.Gray) }
+ }
+ Icon(Icons.Default.Add, null, Modifier.size(18.dp), tint = Color(0xFF4CAF50))
+ }
+ }
+ }
+ }
+
+ // ── 图片 ──
+ Text("照片", fontWeight = FontWeight.Bold, fontSize = 15.sp)
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ s.photos.forEach { photo ->
+ Box(Modifier.size(80.dp).clip(RoundedCornerShape(8.dp))) {
+ AsyncImage(model = photo.uri, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
+ if (photo.isUploading) Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp) }
+ IconButton(onClick = { vm.removePhoto(photo.uri) }, modifier = Modifier.align(Alignment.TopEnd).size(20.dp)) { Icon(Icons.Default.Close, null, Modifier.size(14.dp), tint = Color.White) }
+ }
+ }
+ IconButton(onClick = { showPhotoDialog = true }, modifier = Modifier.size(80.dp).clip(RoundedCornerShape(8.dp)).background(Color(0xFFF0F0F0))) { Icon(Icons.Default.Add, null, Modifier.size(32.dp), tint = Color.Gray) }
+ }
+
s.errorMessage?.let { Text(it, color = Color.Red, fontSize = 14.sp) }
- Button(onClick = { vm.submit() }, Modifier.fillMaxWidth().height(48.dp), enabled = !s.isSubmitting) { Text("提交") }
+ Button(
+ onClick = { vm.submit() }, modifier = Modifier.fillMaxWidth().height(48.dp),
+ enabled = !s.isSubmitting && s.photos.none { it.isUploading }
+ ) {
+ if (s.isSubmitting) CircularProgressIndicator(Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
+ else Text("提交")
+ }
}
}
+
+ if (showPhotoDialog) {
+ AlertDialog(
+ onDismissRequest = { showPhotoDialog = false },
+ title = { Text("添加照片") },
+ text = {
+ Column {
+ TextButton(onClick = {
+ showPhotoDialog = false
+ val file = createTempImageFile(context)
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ cameraFileUri = uri
+ cameraLauncher.launch(uri)
+ }, modifier = Modifier.fillMaxWidth()) {
+ Icon(Icons.Default.CameraAlt, null, Modifier.size(24.dp))
+ Spacer(Modifier.width(12.dp))
+ Text("拍照", modifier = Modifier.weight(1f))
+ }
+ TextButton(onClick = {
+ showPhotoDialog = false
+ galleryLauncher.launch("image/*")
+ }, modifier = Modifier.fillMaxWidth()) {
+ Icon(Icons.Default.PhotoLibrary, null, Modifier.size(24.dp))
+ Spacer(Modifier.width(12.dp))
+ Text("从相册选择", modifier = Modifier.weight(1f))
+ }
+ }
+ },
+ confirmButton = {}
+ )
+ }
}
diff --git a/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseScreen.kt b/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseScreen.kt
index d4072e5..1f13571 100644
--- a/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseScreen.kt
+++ b/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseScreen.kt
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Inventory
import androidx.compose.material3.*
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -26,20 +27,21 @@ import com.example.ops_android.ui.LocalAppContainer
@Composable
fun WarehouseScreen(
onItemClick: (Int) -> Unit,
+ onAddItemClick: (Int) -> Unit = {},
viewModel: WarehouseViewModel = viewModel(factory = WarehouseViewModel.Factory(LocalAppContainer.current.warehouseRepository))
) {
val s by viewModel.s.collectAsState()
- var showAddDialog by remember { mutableStateOf(false) }
+ var showFabMenu by remember { mutableStateOf(false) }
+ var showAddContainer by remember { mutableStateOf(false) }
var newContainerName by remember { mutableStateOf("") }
- var tabIndex by remember { mutableStateOf(0) }
Scaffold(
topBar = { TopAppBar(title = { Text("仓库") }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) },
- floatingActionButton = { FloatingActionButton(onClick = { showAddDialog = true }, containerColor = Color(0xFF667EEA)) { Icon(Icons.Default.Add, "新建容器", tint = Color.White) } }
+ floatingActionButton = { FloatingActionButton(onClick = { showFabMenu = true }, containerColor = Color(0xFF667EEA)) { Icon(Icons.Default.Add, "新建", tint = Color.White) } }
) { padding ->
Column(Modifier.padding(padding)) {
// Breadcrumb
- ScrollableTabRow(selectedTabIndex = s.breadcrumb.size - 1) {
+ PrimaryScrollableTabRow(selectedTabIndex = s.breadcrumb.size - 1) {
s.breadcrumb.forEachIndexed { idx, (id, name) ->
Tab(selected = idx == s.breadcrumb.size - 1, onClick = { viewModel.navigateToBreadcrumb(idx) }, text = { Text(name, fontSize = 12.sp) })
}
@@ -51,27 +53,84 @@ fun WarehouseScreen(
FilterChip(selected = s.showItemsOnly, onClick = { viewModel.toggleItemsOnly() }, label = { Text("仅物品") })
}
- if (s.isLoading) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
- else LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
- if (!s.showItemsOnly) {
- items(s.containers) { container ->
- ContainerItem(container) { viewModel.navigateTo(container.id, container.name ?: "未命名") }
- }
+ // Error
+ s.errorMessage?.let { error ->
+ Card(
+ Modifier.fillMaxWidth().padding(horizontal = 16.dp),
+ colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE))
+ ) {
+ Text(error, modifier = Modifier.padding(12.dp), color = Color(0xFFC62828), fontSize = 13.sp)
}
- items(s.items) { item ->
- ItemRow(item) { onItemClick(item.id) }
+ }
+
+ if (s.isLoading && s.containers.isEmpty()) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
+ else PullToRefreshBox(
+ isRefreshing = s.isRefreshing,
+ onRefresh = { viewModel.refresh() },
+ modifier = Modifier.fillMaxSize()
+ ) {
+ LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ if (!s.showItemsOnly) {
+ items(s.containers) { container ->
+ ContainerItem(container) { viewModel.navigateTo(container.id, container.name ?: "未命名") }
+ }
+ }
+ items(s.items) { item ->
+ ItemRow(item) { onItemClick(item.id) }
+ }
}
}
}
}
- if (showAddDialog) AlertDialog(
- onDismissRequest = { showAddDialog = false },
- title = { Text("新建容器") },
- text = { OutlinedTextField(newContainerName, { newContainerName = it }, label = { Text("容器名称") }, singleLine = true) },
- confirmButton = { Button(onClick = { if (newContainerName.isNotBlank()) { viewModel.addContainer(newContainerName); showAddDialog = false; newContainerName = "" } }) { Text("创建") } },
- dismissButton = { TextButton(onClick = { showAddDialog = false }) { Text("取消") } }
- )
+ // FAB menu dialog
+ if (showFabMenu) {
+ AlertDialog(
+ onDismissRequest = { showFabMenu = false },
+ title = { Text("新建") },
+ text = {
+ Column {
+ TextButton(onClick = {
+ showFabMenu = false
+ newContainerName = ""
+ showAddContainer = true
+ }, modifier = Modifier.fillMaxWidth()) {
+ Icon(Icons.Default.Folder, null, Modifier.size(24.dp))
+ Spacer(Modifier.width(12.dp))
+ Text("新建容器", modifier = Modifier.weight(1f))
+ }
+ TextButton(onClick = {
+ showFabMenu = false
+ onAddItemClick(s.breadcrumb.last().first)
+ }, modifier = Modifier.fillMaxWidth()) {
+ Icon(Icons.Default.Inventory, null, Modifier.size(24.dp))
+ Spacer(Modifier.width(12.dp))
+ Text("新建物品", modifier = Modifier.weight(1f))
+ }
+ }
+ },
+ confirmButton = {}
+ )
+ }
+
+ // Add container dialog
+ if (showAddContainer) {
+ AlertDialog(
+ onDismissRequest = { showAddContainer = false },
+ title = { Text("新建容器") },
+ text = { OutlinedTextField(newContainerName, { newContainerName = it }, label = { Text("容器名称") }, singleLine = true) },
+ confirmButton = {
+ Button(onClick = {
+ if (newContainerName.isNotBlank()) {
+ viewModel.addContainer(newContainerName)
+ showAddContainer = false
+ newContainerName = ""
+ }
+ }) { Text("创建") }
+ },
+ dismissButton = { TextButton(onClick = { showAddContainer = false }) { Text("取消") } }
+ )
+ }
}
@Composable
diff --git a/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseViewModel.kt b/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseViewModel.kt
index a6c65cc..daccd65 100644
--- a/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseViewModel.kt
+++ b/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseViewModel.kt
@@ -17,6 +17,7 @@ data class WarehouseUiState(
val breadcrumb: List> = listOf(0 to "根目录"),
val showItemsOnly: Boolean = false,
val isLoading: Boolean = false,
+ val isRefreshing: Boolean = false,
val errorMessage: String? = null
)
@@ -25,19 +26,20 @@ class WarehouseViewModel(
) : ViewModel() {
private val _s = MutableStateFlow(WarehouseUiState()); val s: StateFlow = _s.asStateFlow()
- init { loadContainers(0) }
+ init { loadContainers(null) }
- fun loadContainers(parentId: Int) {
+ fun loadContainers(parentId: Int?) {
viewModelScope.launch {
- _s.value = _s.value.copy(isLoading = true)
+ _s.value = _s.value.copy(isLoading = true, isRefreshing = true)
repo.listContainer(parentId).fold(
- onSuccess = { _s.value = _s.value.copy(containers = it, isLoading = false) },
- onFailure = { _s.value = _s.value.copy(isLoading = false, errorMessage = it.message) }
+ onSuccess = { _s.value = _s.value.copy(containers = it) },
+ onFailure = { _s.value = _s.value.copy(errorMessage = it.message) }
)
repo.listItem(parentId).fold(
onSuccess = { _s.value = _s.value.copy(items = it) },
onFailure = { }
)
+ _s.value = _s.value.copy(isLoading = false, isRefreshing = false)
}
}
@@ -51,17 +53,22 @@ class WarehouseViewModel(
fun navigateToBreadcrumb(index: Int) {
val entry = _s.value.breadcrumb[index]
_s.value = _s.value.copy(breadcrumb = _s.value.breadcrumb.take(index + 1))
- loadContainers(entry.first)
+ loadContainers(entry.first.takeIf { it != 0 })
}
fun toggleItemsOnly() { _s.value = _s.value.copy(showItemsOnly = !_s.value.showItemsOnly) }
- fun refresh() { loadContainers(_s.value.breadcrumb.last().first) }
+ fun refresh() {
+ loadContainers(_s.value.breadcrumb.last().first.takeIf { it != 0 })
+ }
fun addContainer(name: String) {
val parentId = _s.value.breadcrumb.last().first
+ val parentIdOrNull: Int? = parentId.takeIf { it != 0 }
viewModelScope.launch {
- repo.addContainer(mapOf("name" to name, "parent_id" to parentId)).fold(
+ val data = mutableMapOf("title" to name)
+ if (parentIdOrNull != null) data["parent_id"] = parentIdOrNull
+ repo.addContainer(data).fold(
onSuccess = { refresh() },
onFailure = { _s.value = _s.value.copy(errorMessage = it.message) }
)
diff --git a/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderFormScreen.kt b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderFormScreen.kt
index af2a009..35b2b7e 100644
--- a/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderFormScreen.kt
+++ b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderFormScreen.kt
@@ -1,64 +1,250 @@
package com.example.ops_android.ui.workorder
+import android.net.Uri
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
-import androidx.lifecycle.viewModelScope
+import androidx.core.content.FileProvider
import androidx.lifecycle.viewmodel.compose.viewModel
-import com.example.ops_android.data.repository.WorkOrderRepository
+import coil.compose.AsyncImage
+import com.example.ops_android.data.model.Customer
+import com.example.ops_android.data.model.Item
import com.example.ops_android.ui.LocalAppContainer
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.launch
+import java.io.File
-data class WorkOrderFormUiState(
- val title: String = "", val description: String = "", val isSubmitting: Boolean = false, val errorMessage: String? = null, val success: Boolean = false
-)
-
-class WorkOrderFormViewModel(private val repo: WorkOrderRepository, private val editId: Int? = null) : ViewModel() {
- private val _s = MutableStateFlow(WorkOrderFormUiState()); val s: StateFlow = _s.asStateFlow()
- fun onTitle(v: String) { _s.value = _s.value.copy(title = v) }
- fun onDesc(v: String) { _s.value = _s.value.copy(description = v) }
- fun submit() {
- if (_s.value.title.isBlank()) { _s.value = _s.value.copy(errorMessage = "标题不能为空"); return }
- val data = mapOf("title" to _s.value.title, "description" to _s.value.description)
- viewModelScope.launch {
- _s.value = _s.value.copy(isSubmitting = true, errorMessage = null)
- val r = if (editId != null) repo.update(data + ("id" to editId)) else repo.add(data)
- r.fold(onSuccess = { _s.value = _s.value.copy(isSubmitting = false, success = true) }, onFailure = { _s.value = _s.value.copy(isSubmitting = false, errorMessage = it.message) })
- }
- }
- class Factory(private val repo: WorkOrderRepository, private val editId: Int? = null) : ViewModelProvider.Factory {
- @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = WorkOrderFormViewModel(repo, editId) as T
- }
+private fun createTempImageFile(context: android.content.Context): File {
+ val dir = File(context.cacheDir, "photos")
+ if (!dir.exists()) dir.mkdirs()
+ return File(dir, "capture_${System.currentTimeMillis()}.jpg")
}
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
-fun WorkOrderFormScreen(editId: Int? = null, onBack: () -> Unit, onSaved: () -> Unit) {
- val vm: WorkOrderFormViewModel = viewModel(factory = WorkOrderFormViewModel.Factory(LocalAppContainer.current.workOrderRepository, editId))
+fun WorkOrderFormScreen(editId: Int? = null, prefillItemId: Int? = null, onBack: () -> Unit, onSaved: () -> Unit) {
+ val app = LocalAppContainer.current
+ val context = LocalContext.current
+ val vm: WorkOrderFormViewModel = viewModel(
+ key = "wo_form_$editId",
+ factory = WorkOrderFormViewModel.Factory(
+ app.workOrderRepository, app.warehouseRepository, app.customerRepository,
+ app.fileApi, app.sessionManager, context, editId, prefillItemId
+ )
+ )
val s by vm.s.collectAsState()
LaunchedEffect(s.success) { if (s.success) onSaved() }
- Scaffold(topBar = { TopAppBar(title = { Text(if (editId != null) "编辑工单" else "新建工单") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) }) { padding ->
- Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ var showPhotoDialog by remember { mutableStateOf(false) }
+ var cameraFileUri by remember { mutableStateOf(null) }
+
+ val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
+ val uri = cameraFileUri
+ if (success && uri != null) vm.addPhoto(uri)
+ }
+
+ val galleryLauncher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
+ uri?.let { vm.addPhoto(it) }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(if (editId != null) "编辑工单" else "新建工单") },
+ navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)
+ )
+ }
+ ) { padding ->
+ Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp)) {
+ // Title
OutlinedTextField(s.title, vm::onTitle, label = { Text("标题 *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(s.description, vm::onDesc, label = { Text("描述") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
+
+ // ── 关联物品 ──
+ Text("关联物品", fontWeight = FontWeight.Bold, fontSize = 15.sp)
+
+ // Selected item chips
+ if (s.selectedItems.isNotEmpty()) {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ s.selectedItems.forEach { item ->
+ InputChip(
+ selected = true,
+ onClick = { vm.removeItem(item) },
+ label = { Text(item.name ?: "?", fontSize = 12.sp) },
+ trailingIcon = { Icon(Icons.Default.Close, null, Modifier.size(16.dp)) }
+ )
+ }
+ }
+ }
+
+ // Item search
+ Column {
+ OutlinedTextField(
+ s.itemSearchQuery, vm::onItemSearchQuery,
+ label = { Text("搜索物品") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ leadingIcon = { Icon(Icons.Default.Search, null) }
+ )
+ if (s.isSearchingItems) {
+ LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 4.dp))
+ }
+ s.itemSearchResults.take(5).forEach { item ->
+ Surface(
+ modifier = Modifier.fillMaxWidth().clickable { vm.addItem(item) },
+ color = Color(0xFFF5F5F5)
+ ) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Inventory, null, Modifier.size(20.dp), tint = Color(0xFF667EEA))
+ Spacer(Modifier.width(8.dp))
+ Column(Modifier.weight(1f)) {
+ Text(item.name ?: "", fontSize = 14.sp)
+ item.serial_number?.let { Text(it, fontSize = 12.sp, color = Color.Gray) }
+ }
+ Icon(Icons.Default.Add, null, Modifier.size(18.dp), tint = Color(0xFF4CAF50))
+ }
+ }
+ }
+ }
+
+ // ── 关联客户 ──
+ Text("关联客户", fontWeight = FontWeight.Bold, fontSize = 15.sp)
+
+ // Selected customer chips
+ if (s.selectedCustomers.isNotEmpty()) {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ s.selectedCustomers.forEach { c ->
+ InputChip(
+ selected = true,
+ onClick = { vm.removeCustomer(c) },
+ label = { Text(c.name ?: "?", fontSize = 12.sp) },
+ trailingIcon = { Icon(Icons.Default.Close, null, Modifier.size(16.dp)) }
+ )
+ }
+ }
+ }
+
+ // Customer search
+ Column {
+ OutlinedTextField(
+ s.customerSearchQuery, vm::onCustomerSearchQuery,
+ label = { Text("搜索客户") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ leadingIcon = { Icon(Icons.Default.Search, null) }
+ )
+ if (s.isSearchingCustomers) {
+ LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 4.dp))
+ }
+ s.customerSearchResults.take(5).forEach { c ->
+ Surface(
+ modifier = Modifier.fillMaxWidth().clickable { vm.addCustomer(c) },
+ color = Color(0xFFF5F5F5)
+ ) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Person, null, Modifier.size(20.dp), tint = Color(0xFF667EEA))
+ Spacer(Modifier.width(8.dp))
+ Column(Modifier.weight(1f)) {
+ Text(c.name ?: "", fontSize = 14.sp)
+ c.phone?.let { Text(it, fontSize = 12.sp, color = Color.Gray) }
+ }
+ Icon(Icons.Default.Add, null, Modifier.size(18.dp), tint = Color(0xFF4CAF50))
+ }
+ }
+ }
+ }
+
+ // ── 图片 ──
+ Text("照片", fontWeight = FontWeight.Bold, fontSize = 15.sp)
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ s.photos.forEach { photo ->
+ Box(Modifier.size(80.dp).clip(RoundedCornerShape(8.dp))) {
+ AsyncImage(
+ model = photo.uri,
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Crop
+ )
+ if (photo.isUploading) {
+ Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.4f)), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator(Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
+ }
+ }
+ IconButton(
+ onClick = { vm.removePhoto(photo.uri) },
+ modifier = Modifier.align(Alignment.TopEnd).size(20.dp)
+ ) {
+ Icon(Icons.Default.Close, null, Modifier.size(14.dp), tint = Color.White)
+ }
+ }
+ }
+ IconButton(
+ onClick = { showPhotoDialog = true },
+ modifier = Modifier.size(80.dp).clip(RoundedCornerShape(8.dp)).background(Color(0xFFF0F0F0))
+ ) {
+ Icon(Icons.Default.Add, null, Modifier.size(32.dp), tint = Color.Gray)
+ }
+ }
+
+ // ── Error & Submit ──
s.errorMessage?.let { Text(it, color = Color.Red, fontSize = 14.sp) }
- Button(onClick = { vm.submit() }, Modifier.fillMaxWidth().height(48.dp), enabled = !s.isSubmitting) {
- if (s.isSubmitting) CircularProgressIndicator(Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp) else Text("提交")
+ Button(
+ onClick = { vm.submit() },
+ modifier = Modifier.fillMaxWidth().height(48.dp),
+ enabled = !s.isSubmitting && s.photos.none { it.isUploading }
+ ) {
+ if (s.isSubmitting) CircularProgressIndicator(Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
+ else Text("提交")
}
}
}
+
+ if (showPhotoDialog) {
+ AlertDialog(
+ onDismissRequest = { showPhotoDialog = false },
+ title = { Text("添加照片") },
+ text = {
+ Column {
+ TextButton(onClick = {
+ showPhotoDialog = false
+ val file = createTempImageFile(context)
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ cameraFileUri = uri
+ cameraLauncher.launch(uri)
+ }, modifier = Modifier.fillMaxWidth()) {
+ Icon(Icons.Default.CameraAlt, null, Modifier.size(24.dp))
+ Spacer(Modifier.width(12.dp))
+ Text("拍照", modifier = Modifier.weight(1f))
+ }
+ TextButton(onClick = {
+ showPhotoDialog = false
+ galleryLauncher.launch("image/*")
+ }, modifier = Modifier.fillMaxWidth()) {
+ Icon(Icons.Default.PhotoLibrary, null, Modifier.size(24.dp))
+ Spacer(Modifier.width(12.dp))
+ Text("从相册选择", modifier = Modifier.weight(1f))
+ }
+ }
+ },
+ confirmButton = {}
+ )
+ }
}
diff --git a/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderFormViewModel.kt b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderFormViewModel.kt
new file mode 100644
index 0000000..47259aa
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderFormViewModel.kt
@@ -0,0 +1,223 @@
+package com.example.ops_android.ui.workorder
+
+import android.content.Context
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.example.ops_android.data.local.SessionManager
+import com.example.ops_android.data.model.Customer
+import com.example.ops_android.data.model.Item
+import com.example.ops_android.data.remote.api.FileApi
+import com.example.ops_android.data.remote.api.FileUploadResponse
+import com.example.ops_android.data.repository.CustomerRepository
+import com.example.ops_android.data.repository.WarehouseRepository
+import com.example.ops_android.data.repository.WorkOrderRepository
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.MultipartBody
+import okhttp3.RequestBody.Companion.toRequestBody
+
+data class UploadedPhoto(
+ val uri: Uri,
+ val hash: String? = null,
+ val isUploading: Boolean = false
+)
+
+data class WorkOrderFormUiState(
+ val title: String = "",
+ val description: String = "",
+ val selectedItems: List- = emptyList(),
+ val selectedCustomers: List = emptyList(),
+ val photos: List = emptyList(),
+ val itemSearchQuery: String = "",
+ val itemSearchResults: List
- = emptyList(),
+ val isSearchingItems: Boolean = false,
+ val customerSearchQuery: String = "",
+ val customerSearchResults: List = emptyList(),
+ val isSearchingCustomers: Boolean = false,
+ val isSubmitting: Boolean = false,
+ val errorMessage: String? = null,
+ val success: Boolean = false
+)
+
+class WorkOrderFormViewModel(
+ private val repo: WorkOrderRepository,
+ private val warehouseRepo: WarehouseRepository,
+ private val customerRepo: CustomerRepository,
+ private val fileApi: FileApi,
+ private val sessionManager: SessionManager,
+ private val context: Context,
+ private val editId: Int? = null,
+ private val prefillItemId: Int? = null
+) : ViewModel() {
+ private val _s = MutableStateFlow(WorkOrderFormUiState())
+ val s: StateFlow = _s.asStateFlow()
+
+ private var itemSearchJob: Job? = null
+ private var customerSearchJob: Job? = null
+
+ init {
+ prefillItemId?.let { itemId ->
+ viewModelScope.launch {
+ val result = warehouseRepo.getItem(itemId)
+ result.fold(
+ onSuccess = { resp ->
+ val item = resp.item
+ if (item != null) {
+ _s.value = _s.value.copy(
+ title = item.name ?: "",
+ description = listOfNotNull(item.serial_number, item.remark).joinToString("\n"),
+ selectedItems = listOf(item)
+ )
+ }
+ // Pre-fill linked customers
+ resp.customers?.forEach { cInfo ->
+ val c = Customer(
+ id = cInfo.id,
+ first_name = cInfo.first_name,
+ last_name = cInfo.last_name,
+ phone = cInfo.primary_phone
+ )
+ _s.value = _s.value.copy(selectedCustomers = _s.value.selectedCustomers + c)
+ }
+ },
+ onFailure = { }
+ )
+ }
+ }
+ }
+
+ fun onTitle(v: String) { _s.value = _s.value.copy(title = v) }
+ fun onDesc(v: String) { _s.value = _s.value.copy(description = v) }
+
+ fun onItemSearchQuery(v: String) {
+ _s.value = _s.value.copy(itemSearchQuery = v)
+ itemSearchJob?.cancel()
+ itemSearchJob = viewModelScope.launch {
+ delay(300)
+ if (v.isBlank()) {
+ _s.value = _s.value.copy(itemSearchResults = emptyList(), isSearchingItems = false)
+ return@launch
+ }
+ _s.value = _s.value.copy(isSearchingItems = true)
+ warehouseRepo.searchItems(v).fold(
+ onSuccess = { _s.value = _s.value.copy(itemSearchResults = it, isSearchingItems = false) },
+ onFailure = { _s.value = _s.value.copy(isSearchingItems = false) }
+ )
+ }
+ }
+
+ fun addItem(item: Item) {
+ val current = _s.value.selectedItems
+ if (current.none { it.id == item.id }) {
+ _s.value = _s.value.copy(selectedItems = current + item, itemSearchQuery = "", itemSearchResults = emptyList())
+ }
+ }
+
+ fun removeItem(item: Item) {
+ _s.value = _s.value.copy(selectedItems = _s.value.selectedItems.filter { it.id != item.id })
+ }
+
+ fun onCustomerSearchQuery(v: String) {
+ _s.value = _s.value.copy(customerSearchQuery = v)
+ customerSearchJob?.cancel()
+ customerSearchJob = viewModelScope.launch {
+ delay(300)
+ if (v.isBlank()) {
+ _s.value = _s.value.copy(customerSearchResults = emptyList(), isSearchingCustomers = false)
+ return@launch
+ }
+ _s.value = _s.value.copy(isSearchingCustomers = true)
+ customerRepo.list(v).fold(
+ onSuccess = { _s.value = _s.value.copy(customerSearchResults = it, isSearchingCustomers = false) },
+ onFailure = { _s.value = _s.value.copy(isSearchingCustomers = false) }
+ )
+ }
+ }
+
+ fun addCustomer(c: Customer) {
+ val current = _s.value.selectedCustomers
+ if (current.none { it.id == c.id }) {
+ _s.value = _s.value.copy(selectedCustomers = current + c, customerSearchQuery = "", customerSearchResults = emptyList())
+ }
+ }
+
+ fun removeCustomer(c: Customer) {
+ _s.value = _s.value.copy(selectedCustomers = _s.value.selectedCustomers.filter { it.id != c.id })
+ }
+
+ fun addPhoto(uri: Uri) {
+ val photo = UploadedPhoto(uri = uri, isUploading = true)
+ _s.value = _s.value.copy(photos = _s.value.photos + photo)
+ viewModelScope.launch {
+ val inputStream = context.contentResolver.openInputStream(uri)
+ val bytes = inputStream?.readBytes() ?: return@launch
+ inputStream.close()
+ val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
+ val requestBody = bytes.toRequestBody(mime.toMediaTypeOrNull())
+ val filePart = MultipartBody.Part.createFormData("file", "photo.jpg", requestBody)
+ val cookiePart = sessionManager.cachedCookieValue
+ .toRequestBody("text/plain".toMediaTypeOrNull())
+ val result = try {
+ val response = fileApi.uploadImage(cookiePart, filePart)
+ if (response.errCode == 0 && response.data != null) response.data else null
+ } catch (e: Exception) { null }
+ _s.value = _s.value.copy(photos = _s.value.photos.map {
+ if (it.uri == uri) it.copy(hash = result?.hash, isUploading = false)
+ else it
+ })
+ }
+ }
+
+ fun removePhoto(uri: Uri) {
+ _s.value = _s.value.copy(photos = _s.value.photos.filter { it.uri != uri })
+ }
+
+ fun submit() {
+ if (_s.value.title.isBlank()) { _s.value = _s.value.copy(errorMessage = "标题不能为空"); return }
+ val state = _s.value
+ val data = mutableMapOf(
+ "title" to state.title,
+ "description" to state.description
+ )
+ if (state.selectedItems.isNotEmpty()) {
+ data["item_ids"] = state.selectedItems.map { it.id }
+ }
+ if (state.selectedCustomers.isNotEmpty()) {
+ data["customer_ids"] = state.selectedCustomers.map { it.id }
+ }
+ val photoHashes = state.photos.mapNotNull { it.hash }
+ if (photoHashes.isNotEmpty()) {
+ data["photos"] = photoHashes
+ }
+ viewModelScope.launch {
+ _s.value = _s.value.copy(isSubmitting = true, errorMessage = null)
+ val r = if (editId != null) repo.update(data + ("id" to editId)) else repo.add(data)
+ r.fold(
+ onSuccess = { _s.value = _s.value.copy(isSubmitting = false, success = true) },
+ onFailure = { _s.value = _s.value.copy(isSubmitting = false, errorMessage = it.message) }
+ )
+ }
+ }
+
+ class Factory(
+ private val repo: WorkOrderRepository,
+ private val warehouseRepo: WarehouseRepository,
+ private val customerRepo: CustomerRepository,
+ private val fileApi: FileApi,
+ private val sessionManager: SessionManager,
+ private val context: Context,
+ private val editId: Int? = null,
+ private val prefillItemId: Int? = null
+ ) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(c: Class): T =
+ WorkOrderFormViewModel(repo, warehouseRepo, customerRepo, fileApi, sessionManager, context, editId, prefillItemId) as T
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListScreen.kt b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListScreen.kt
index be741e5..ca82f81 100644
--- a/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListScreen.kt
+++ b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListScreen.kt
@@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -37,29 +38,35 @@ fun WorkOrderListScreen(
floatingActionButton = { FloatingActionButton(onClick = onAddClick, containerColor = Color(0xFF667EEA)) { Icon(Icons.Default.Add, "新建", tint = Color.White) } }
) { padding ->
Column(Modifier.padding(padding)) {
- ScrollableTabRow(selectedTabIndex = WO_STATUSES.indexOfFirst { it.first == uiState.selectedStatus }.coerceAtLeast(0), edgePadding = 8.dp) {
+ PrimaryScrollableTabRow(selectedTabIndex = WO_STATUSES.indexOfFirst { it.first == uiState.selectedStatus }.coerceAtLeast(0), edgePadding = 8.dp) {
WO_STATUSES.forEachIndexed { i, (s, l) ->
Tab(selected = uiState.selectedStatus == s, onClick = { viewModel.selectStatus(s) }, text = { Text(l, fontSize = 12.sp, fontWeight = if (uiState.selectedStatus == s) FontWeight.Bold else FontWeight.Normal) })
}
}
if (uiState.isLoading) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
- else LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
- items(uiState.workOrders, key = { it.id }) { wo ->
- Card(Modifier.fillMaxWidth().clickable { onWorkOrderClick(wo.id) }) {
- Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
- Column(Modifier.weight(1f)) {
- Text(wo.title ?: "无标题", fontWeight = FontWeight.Medium)
- Spacer(Modifier.height(4.dp))
- Text(wo.description ?: "", fontSize = 12.sp, color = Color.Gray, maxLines = 1)
- }
- Surface(shape = MaterialTheme.shapes.small, color = statusColors[wo.status]?.copy(alpha = 0.15f) ?: Color.Gray) {
- Text(statusLabels[wo.status] ?: wo.status ?: "", modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), fontSize = 11.sp, color = statusColors[wo.status] ?: Color.Gray)
+ else PullToRefreshBox(
+ isRefreshing = uiState.isRefreshing,
+ onRefresh = { viewModel.refresh() },
+ modifier = Modifier.fillMaxSize()
+ ) {
+ LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(uiState.workOrders, key = { it.id }) { wo ->
+ Card(Modifier.fillMaxWidth().clickable { onWorkOrderClick(wo.id) }) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(wo.title ?: "无标题", fontWeight = FontWeight.Medium)
+ Spacer(Modifier.height(4.dp))
+ Text(wo.description ?: "", fontSize = 12.sp, color = Color.Gray, maxLines = 1)
+ }
+ Surface(shape = MaterialTheme.shapes.small, color = statusColors[wo.status]?.copy(alpha = 0.15f) ?: Color.Gray) {
+ Text(statusLabels[wo.status] ?: wo.status ?: "", modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), fontSize = 11.sp, color = statusColors[wo.status] ?: Color.Gray)
+ }
}
}
}
+ if (uiState.isLoadingMore) item { Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(24.dp)) } }
+ item { LaunchedEffect(Unit) { viewModel.loadMore() } }
}
- if (uiState.isLoadingMore) item { Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(24.dp)) } }
- item { LaunchedEffect(Unit) { viewModel.loadMore() } }
}
}
}
diff --git a/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListViewModel.kt b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListViewModel.kt
index 2de5452..ae83ed9 100644
--- a/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListViewModel.kt
+++ b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListViewModel.kt
@@ -24,6 +24,7 @@ data class WorkOrderListUiState(
val workOrders: List = emptyList(),
val selectedStatus: String = "",
val isLoading: Boolean = false,
+ val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false,
val hasMore: Boolean = true,
val errorMessage: String? = null
@@ -43,12 +44,12 @@ class WorkOrderListViewModel(
fun refresh() {
viewModelScope.launch {
- _uiState.value = _uiState.value.copy(isLoading = true, workOrders = emptyList())
+ _uiState.value = _uiState.value.copy(isRefreshing = true, workOrders = emptyList())
page = 1
val r = repository.list(status = _uiState.value.selectedStatus.ifEmpty { null })
r.fold(
- onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(workOrders = list, isLoading = false, hasMore = list.size >= pageSize) },
- onFailure = { e -> _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = e.message) }
+ onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(workOrders = list, isRefreshing = false, isLoading = false, hasMore = list.size >= pageSize) },
+ onFailure = { e -> _uiState.value = _uiState.value.copy(isRefreshing = false, isLoading = false, errorMessage = e.message) }
)
}
}
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..dbb848b
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/version.properties b/app/version.properties
index b9d4054..56a91d2 100644
--- a/app/version.properties
+++ b/app/version.properties
@@ -1,3 +1,3 @@
-#Thu Jun 25 11:21:25 CST 2026
-VERSION_CODE=48
+#Thu Jun 25 15:33:48 CST 2026
+VERSION_CODE=82
VERSION_NAME=1.2