From c493dbc31c7c441a64a36b7f5d9aba142e4a39e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=A0=E9=97=BB=E9=A3=8E?= Date: Thu, 25 Jun 2026 15:43:56 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A=E4=B8=AAAPI?= =?UTF-8?q?=E9=80=9A=E4=BF=A1=E5=92=8C=E6=95=B0=E6=8D=AE=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复仓库容器parent_id/物品container_id为null导致Gson反序列化失败 - 修复ScheduleApi参数名与后端不匹配 - 修复Profile页面username空字符串导致显示未登录 - 修复Retrofit Map类型参数编译后变为通配符被拒绝 - 修复ScrollableTabRow动态tab数量变化时IndexOutOfBounds崩溃 - 修复仓库新建容器字段名name→title匹配后端 - 修复新建物品传入错误的containerId - 所有页面改为下拉刷新,去除首页自动刷新 - 新建工单支持关联物品/客户/图片上传 - 新增物品表单包含数量/关联客户/图片上传 - 物品详情页重构:基本信息/图片/关联工单/客户/移动历史/移动/删除/新建工单预填 - 打印模块添加黑标检测、Mutex并发锁、delay替换Thread.sleep --- app/src/main/AndroidManifest.xml | 10 + .../com/example/ops_android/AppContainer.kt | 1 + .../ops_android/data/model/Container.kt | 2 +- .../example/ops_android/data/model/Item.kt | 3 +- .../data/remote/CookieInterceptor.kt | 6 + .../data/remote/api/CustomerApi.kt | 14 +- .../ops_android/data/remote/api/FileApi.kt | 24 ++ .../data/remote/api/PurchaseApi.kt | 6 +- .../data/remote/api/ScheduleApi.kt | 10 +- .../ops_android/data/remote/api/UserApi.kt | 4 +- .../data/remote/api/WarehouseApi.kt | 56 ++- .../data/remote/api/WorkOrderApi.kt | 8 +- .../data/repository/CustomerRepository.kt | 5 +- .../data/repository/WarehouseRepository.kt | 27 +- .../ops_android/printer/PrintHelper.kt | 192 +++++----- .../example/ops_android/ui/home/HomeScreen.kt | 14 +- .../ops_android/ui/home/HomeViewModel.kt | 32 +- .../ops_android/ui/navigation/OPSNavGraph.kt | 20 +- .../ops_android/ui/order/OrderListScreen.kt | 45 ++- .../ops_android/ui/profile/ProfileScreen.kt | 33 +- .../ui/profile/ProfileViewModel.kt | 25 +- .../ops_android/ui/search/SearchScreen.kt | 2 +- .../ui/warehouse/ItemDetailScreen.kt | 330 ++++++++++++++++-- .../ui/warehouse/ItemFormScreen.kt | 302 ++++++++++++++-- .../ui/warehouse/WarehouseScreen.kt | 97 ++++- .../ui/warehouse/WarehouseViewModel.kt | 23 +- .../ui/workorder/WorkOrderFormScreen.kt | 256 ++++++++++++-- .../ui/workorder/WorkOrderFormViewModel.kt | 223 ++++++++++++ .../ui/workorder/WorkOrderListScreen.kt | 35 +- .../ui/workorder/WorkOrderListViewModel.kt | 7 +- app/src/main/res/xml/file_paths.xml | 4 + app/version.properties | 4 +- 32 files changed, 1514 insertions(+), 306 deletions(-) create mode 100644 app/src/main/java/com/example/ops_android/data/remote/api/FileApi.kt create mode 100644 app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderFormViewModel.kt create mode 100644 app/src/main/res/xml/file_paths.xml 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