fix: 修复多个API通信和数据展示问题
- 修复仓库容器parent_id/物品container_id为null导致Gson反序列化失败 - 修复ScheduleApi参数名与后端不匹配 - 修复Profile页面username空字符串导致显示未登录 - 修复Retrofit Map类型参数编译后变为通配符被拒绝 - 修复ScrollableTabRow动态tab数量变化时IndexOutOfBounds崩溃 - 修复仓库新建容器字段名name→title匹配后端 - 修复新建物品传入错误的containerId - 所有页面改为下拉刷新,去除首页自动刷新 - 新建工单支持关联物品/客户/图片上传 - 新增物品表单包含数量/关联客户/图片上传 - 物品详情页重构:基本信息/图片/关联工单/客户/移动历史/移动/删除/新建工单预填 - 打印模块添加黑标检测、Mutex并发锁、delay替换Thread.sleep
This commit is contained in:
@@ -36,6 +36,16 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -20,6 +20,7 @@ class AppContainer(context: Context) {
|
||||
val warehouseApi by lazy { apiServiceFactory.create<WarehouseApi>() }
|
||||
val customerApi by lazy { apiServiceFactory.create<CustomerApi>() }
|
||||
val scheduleApi by lazy { apiServiceFactory.create<ScheduleApi>() }
|
||||
val fileApi by lazy { apiServiceFactory.create<FileApi>() }
|
||||
|
||||
val authRepository by lazy { AuthRepository(userApi, sessionManager) }
|
||||
val orderRepository by lazy { OrderRepository(purchaseApi) }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Customer>?
|
||||
)
|
||||
@@ -20,17 +26,17 @@ data class CustomerResponse(
|
||||
interface CustomerApi {
|
||||
|
||||
@POST("/customer/list")
|
||||
suspend fun list(@Body request: Map<String, Any> = emptyMap()): ApiResponse<CustomerListResponse>
|
||||
suspend fun list(@Body request: CustomerListRequest): ApiResponse<CustomerListResponse>
|
||||
|
||||
@POST("/customer/get")
|
||||
suspend fun get(@Body request: CustomerGetRequest): ApiResponse<CustomerResponse>
|
||||
|
||||
@POST("/customer/add")
|
||||
suspend fun add(@Body request: Map<String, Any>): ApiResponse<Any>
|
||||
suspend fun add(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
|
||||
|
||||
@POST("/customer/update")
|
||||
suspend fun update(@Body request: Map<String, Any>): ApiResponse<Any>
|
||||
suspend fun update(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
|
||||
|
||||
@POST("/customer/delete")
|
||||
suspend fun delete(@Body request: Map<String, Int>): ApiResponse<Any>
|
||||
suspend fun delete(@Body request: Map<String, @JvmSuppressWildcards Int>): ApiResponse<Any>
|
||||
}
|
||||
|
||||
@@ -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<FileUploadResponse>
|
||||
}
|
||||
@@ -49,14 +49,14 @@ interface PurchaseApi {
|
||||
suspend fun getOrder(@Body request: GetOrderRequest): ApiResponse<OrderDetailResponse>
|
||||
|
||||
@POST("/purchase/getordercount")
|
||||
suspend fun getOrderCount(@Body request: Map<String, Any> = emptyMap()): ApiResponse<OrderCounts>
|
||||
suspend fun getOrderCount(@Body request: EmptyBody = EmptyBody()): ApiResponse<OrderCounts>
|
||||
|
||||
@POST("/purchase/updatestatus")
|
||||
suspend fun updateOrderStatus(@Body request: UpdateOrderStatusRequest): ApiResponse<Any>
|
||||
|
||||
@POST("/purchase/addorder")
|
||||
suspend fun addOrder(@Body request: Map<String, Any>): ApiResponse<Any>
|
||||
suspend fun addOrder(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
|
||||
|
||||
@POST("/purchase/updateorder")
|
||||
suspend fun updateOrder(@Body request: Map<String, Any>): ApiResponse<Any>
|
||||
suspend fun updateOrder(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
|
||||
}
|
||||
|
||||
@@ -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<EventsResponse>
|
||||
|
||||
@POST("/schedule/addevent")
|
||||
suspend fun addEvent(@Body request: Map<String, Any>): ApiResponse<Any>
|
||||
suspend fun addEvent(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
|
||||
|
||||
@POST("/schedule/editevent")
|
||||
suspend fun editEvent(@Body request: Map<String, Any>): ApiResponse<Any>
|
||||
suspend fun editEvent(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
|
||||
|
||||
@POST("/schedule/deleevent")
|
||||
suspend fun deleteEvent(@Body request: Map<String, Int>): ApiResponse<Any>
|
||||
suspend fun deleteEvent(@Body request: Map<String, @JvmSuppressWildcards Int>): ApiResponse<Any>
|
||||
}
|
||||
|
||||
@@ -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<Any>
|
||||
|
||||
@POST("/users/getinfo")
|
||||
suspend fun getUserInfo(@Body request: Map<String, Any> = emptyMap()): ApiResponse<UserInfoResponse>
|
||||
suspend fun getUserInfo(@Body request: EmptyBody = EmptyBody()): ApiResponse<UserInfoResponse>
|
||||
|
||||
@POST("/users/changePassword")
|
||||
suspend fun changePassword(@Body request: ChangePasswordRequest): ApiResponse<Any>
|
||||
|
||||
@@ -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<TabFileInfo>? = null,
|
||||
val commits: List<ItemCommitDetail>? = null,
|
||||
val work_orders: List<LinkedWorkOrder>? = null,
|
||||
val customers: List<LinkedCustomerInfo>? = 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<ContainerResponse>
|
||||
|
||||
@POST("/warehouse/add_container")
|
||||
suspend fun addContainer(@Body request: Map<String, Any>): ApiResponse<Any>
|
||||
suspend fun addContainer(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
|
||||
|
||||
@POST("/warehouse/update_container")
|
||||
suspend fun updateContainer(@Body request: Map<String, Any>): ApiResponse<Any>
|
||||
suspend fun updateContainer(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
|
||||
|
||||
@POST("/warehouse/delete_container")
|
||||
suspend fun deleteContainer(@Body request: Map<String, Int>): ApiResponse<Any>
|
||||
suspend fun deleteContainer(@Body request: Map<String, @JvmSuppressWildcards Int>): ApiResponse<Any>
|
||||
|
||||
@POST("/warehouse/list_item")
|
||||
suspend fun listItem(@Body request: ListItemRequest): ApiResponse<ListItemResponse>
|
||||
@@ -73,17 +107,17 @@ interface WarehouseApi {
|
||||
suspend fun getItem(@Body request: GetItemRequest): ApiResponse<ItemResponse>
|
||||
|
||||
@POST("/warehouse/add_item")
|
||||
suspend fun addItem(@Body request: Map<String, Any>): ApiResponse<Any>
|
||||
suspend fun addItem(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
|
||||
|
||||
@POST("/warehouse/update_item")
|
||||
suspend fun updateItem(@Body request: Map<String, Any>): ApiResponse<Any>
|
||||
suspend fun updateItem(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
|
||||
|
||||
@POST("/warehouse/delete_item")
|
||||
suspend fun deleteItem(@Body request: Map<String, Int>): ApiResponse<Any>
|
||||
suspend fun deleteItem(@Body request: Map<String, @JvmSuppressWildcards Int>): ApiResponse<Any>
|
||||
|
||||
@POST("/warehouse/move_item")
|
||||
suspend fun moveItem(@Body request: MoveItemRequest): ApiResponse<Any>
|
||||
|
||||
@POST("/warehouse/count")
|
||||
suspend fun getCount(@Body request: Map<String, Any> = emptyMap()): ApiResponse<WarehouseCountResponse>
|
||||
suspend fun getCount(@Body request: EmptyBody = EmptyBody()): ApiResponse<WarehouseCountResponse>
|
||||
}
|
||||
|
||||
@@ -75,16 +75,16 @@ interface WorkOrderApi {
|
||||
suspend fun get(@Body request: WorkOrderGetRequest): ApiResponse<WorkOrderDetailResponse>
|
||||
|
||||
@POST("/work_order/count")
|
||||
suspend fun getCount(@Body request: Map<String, Any> = emptyMap()): ApiResponse<WorkOrderCounts>
|
||||
suspend fun getCount(@Body request: EmptyBody = EmptyBody()): ApiResponse<WorkOrderCounts>
|
||||
|
||||
@POST("/work_order/add")
|
||||
suspend fun add(@Body request: Map<String, Any>): ApiResponse<Any>
|
||||
suspend fun add(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
|
||||
|
||||
@POST("/work_order/update")
|
||||
suspend fun update(@Body request: Map<String, Any>): ApiResponse<Any>
|
||||
suspend fun update(@Body request: Map<String, @JvmSuppressWildcards Any>): ApiResponse<Any>
|
||||
|
||||
@POST("/work_order/delete")
|
||||
suspend fun delete(@Body request: Map<String, Int>): ApiResponse<Any>
|
||||
suspend fun delete(@Body request: Map<String, @JvmSuppressWildcards Int>): ApiResponse<Any>
|
||||
|
||||
@POST("/work_order/commit")
|
||||
suspend fun commit(@Body request: WorkOrderCommitRequest): ApiResponse<Any>
|
||||
|
||||
@@ -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<List<Customer>> {
|
||||
suspend fun list(search: String? = null): Result<List<Customer>> {
|
||||
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 {
|
||||
|
||||
@@ -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<List<Container>> {
|
||||
suspend fun listContainer(parentId: Int? = null): Result<List<Container>> {
|
||||
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<List<Item>> {
|
||||
suspend fun listItem(containerId: Int? = null): Result<List<Item>> {
|
||||
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<Item> {
|
||||
suspend fun searchItems(query: String): Result<List<Item>> {
|
||||
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<ItemResponse> {
|
||||
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<Unit> {
|
||||
suspend fun moveItem(itemId: Int, newContainer: Int?): Result<Unit> {
|
||||
return try {
|
||||
val response = warehouseApi.moveItem(MoveItemRequest(itemId, newContainer))
|
||||
if (response.errCode == 0) Result.success(Unit)
|
||||
|
||||
@@ -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<Unit> = 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<Unit> = 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<Unit> = 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<Unit> = printMutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
|
||||
printerManager.setFontSize(24)
|
||||
printerManager.setBold(true)
|
||||
printerManager.addText("工单: ${workOrder.title ?: "-"}\n")
|
||||
printerManager.setFontSize(20)
|
||||
printerManager.setBold(false)
|
||||
if (workOrder.id > 0) {
|
||||
printerManager.addBarcode("WO-${workOrder.id}")
|
||||
}
|
||||
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<Unit> = 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<Unit> = printMutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
|
||||
printerManager.setFontSize(24)
|
||||
printerManager.setBold(true)
|
||||
printerManager.addText("${item.name ?: "-"}\n")
|
||||
printerManager.setFontSize(18)
|
||||
printerManager.setBold(false)
|
||||
item.serial_number?.let { printerManager.addText("编号: $it\n") }
|
||||
if (item.id > 0) {
|
||||
printerManager.addBarcode("ITEM-${item.id}")
|
||||
}
|
||||
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<Unit> = 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<Unit> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HomeUiState> = _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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -138,7 +138,7 @@ fun SearchScreen(
|
||||
)
|
||||
|
||||
// Category tabs
|
||||
ScrollableTabRow(
|
||||
PrimaryScrollableTabRow(
|
||||
selectedTabIndex = SearchCategory.entries.indexOf(uiState.category),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
edgePadding = 8.dp
|
||||
|
||||
@@ -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<ItemDetailUiState> = _s.asStateFlow()
|
||||
|
||||
class ItemDetailViewModel(repo: WarehouseRepository, private val id: Int) : ViewModel() {
|
||||
private val _s = MutableStateFlow(ItemDetailUiState()); val s: StateFlow<ItemDetailUiState> = _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 <T : ViewModel> create(c: Class<T>): 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 <T : ViewModel> create(c: Class<T>): 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<String?>(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<List<com.example.ops_android.data.model.Container>>(emptyList()) }
|
||||
var selectedId by remember { mutableStateOf<Int?>(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("取消") } }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Customer> = emptyList(),
|
||||
val photos: List<ItemPhoto> = emptyList(),
|
||||
val customerSearchQuery: String = "",
|
||||
val customerSearchResults: List<Customer> = 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<ItemFormUiState> = _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 <T : ViewModel> create(c: Class<T>): 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<String, Any>(
|
||||
"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 <T : ViewModel> create(c: Class<T>): 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<Uri?>(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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,7 @@ data class WarehouseUiState(
|
||||
val breadcrumb: List<Pair<Int, String>> = 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<WarehouseUiState> = _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<String, Any>("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) }
|
||||
)
|
||||
|
||||
@@ -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<WorkOrderFormUiState> = _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 <T : ViewModel> create(c: Class<T>): 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<Uri?>(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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Item> = emptyList(),
|
||||
val selectedCustomers: List<Customer> = emptyList(),
|
||||
val photos: List<UploadedPhoto> = emptyList(),
|
||||
val itemSearchQuery: String = "",
|
||||
val itemSearchResults: List<Item> = emptyList(),
|
||||
val isSearchingItems: Boolean = false,
|
||||
val customerSearchQuery: String = "",
|
||||
val customerSearchResults: List<Customer> = 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<WorkOrderFormUiState> = _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<String, Any>(
|
||||
"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 <T : ViewModel> create(c: Class<T>): T =
|
||||
WorkOrderFormViewModel(repo, warehouseRepo, customerRepo, fileApi, sessionManager, context, editId, prefillItemId) as T
|
||||
}
|
||||
}
|
||||
@@ -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() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ data class WorkOrderListUiState(
|
||||
val workOrders: List<WorkOrder> = 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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="photos" path="photos/" />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user