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:
2026-06-25 15:43:56 +08:00
parent 8b4f8096ea
commit c493dbc31c
32 changed files with 1514 additions and 306 deletions
+10
View File
@@ -36,6 +36,16 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </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> </application>
</manifest> </manifest>
@@ -20,6 +20,7 @@ class AppContainer(context: Context) {
val warehouseApi by lazy { apiServiceFactory.create<WarehouseApi>() } val warehouseApi by lazy { apiServiceFactory.create<WarehouseApi>() }
val customerApi by lazy { apiServiceFactory.create<CustomerApi>() } val customerApi by lazy { apiServiceFactory.create<CustomerApi>() }
val scheduleApi by lazy { apiServiceFactory.create<ScheduleApi>() } val scheduleApi by lazy { apiServiceFactory.create<ScheduleApi>() }
val fileApi by lazy { apiServiceFactory.create<FileApi>() }
val authRepository by lazy { AuthRepository(userApi, sessionManager) } val authRepository by lazy { AuthRepository(userApi, sessionManager) }
val orderRepository by lazy { OrderRepository(purchaseApi) } val orderRepository by lazy { OrderRepository(purchaseApi) }
@@ -5,7 +5,7 @@ import com.google.gson.annotations.SerializedName
data class Container( data class Container(
@SerializedName("ID") val id: Int = 0, @SerializedName("ID") val id: Int = 0,
@SerializedName("Title") val name: String? = null, @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("Remark") val remark: String? = null,
@SerializedName("CreatedAt") val created_at: String? = null, @SerializedName("CreatedAt") val created_at: String? = null,
@SerializedName("CreatorID") val user_id: Int? = null, @SerializedName("CreatorID") val user_id: Int? = null,
@@ -8,8 +8,9 @@ data class Item(
@SerializedName("SerialNumber") val serial_number: String? = null, @SerializedName("SerialNumber") val serial_number: String? = null,
@SerializedName("Remark") val remark: String? = null, @SerializedName("Remark") val remark: String? = null,
@SerializedName("Quantity") val quantity: Int = 1, @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("CreatedAt") val created_at: String? = null,
@SerializedName("UpdatedAt") val updated_at: String? = null,
@SerializedName("CreatorID") val user_id: Int? = null, @SerializedName("CreatorID") val user_id: Int? = null,
@SerializedName("barcode") private val _barcode: String? = null, @SerializedName("barcode") private val _barcode: String? = null,
@SerializedName("spec") private val _spec: String? = null, @SerializedName("spec") private val _spec: String? = null,
@@ -17,6 +17,12 @@ class CookieInterceptor(
return chain.proceed(originalRequest) 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 cookieValue = sessionManager.cachedCookieValue
val gson = Gson() val gson = Gson()
@@ -5,6 +5,12 @@ import com.example.ops_android.data.remote.ApiResponse
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.POST import retrofit2.http.POST
data class CustomerListRequest(
val search: String? = null,
val page: Int = 1,
val page_size: Int = 10
)
data class CustomerListResponse( data class CustomerListResponse(
val customers: List<Customer>? val customers: List<Customer>?
) )
@@ -20,17 +26,17 @@ data class CustomerResponse(
interface CustomerApi { interface CustomerApi {
@POST("/customer/list") @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") @POST("/customer/get")
suspend fun get(@Body request: CustomerGetRequest): ApiResponse<CustomerResponse> suspend fun get(@Body request: CustomerGetRequest): ApiResponse<CustomerResponse>
@POST("/customer/add") @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") @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") @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> suspend fun getOrder(@Body request: GetOrderRequest): ApiResponse<OrderDetailResponse>
@POST("/purchase/getordercount") @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") @POST("/purchase/updatestatus")
suspend fun updateOrderStatus(@Body request: UpdateOrderStatusRequest): ApiResponse<Any> suspend fun updateOrderStatus(@Body request: UpdateOrderStatusRequest): ApiResponse<Any>
@POST("/purchase/addorder") @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") @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 import retrofit2.http.POST
data class GetEventsRequest( data class GetEventsRequest(
val start_date: String? = null, val start: String? = null,
val end_date: String? = null val end: String? = null
) )
data class EventsResponse( data class EventsResponse(
@@ -20,11 +20,11 @@ interface ScheduleApi {
suspend fun getEvents(@Body request: GetEventsRequest): ApiResponse<EventsResponse> suspend fun getEvents(@Body request: GetEventsRequest): ApiResponse<EventsResponse>
@POST("/schedule/addevent") @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") @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") @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? val userInfo: UserDetail?
) )
class EmptyBody
interface UserApi { interface UserApi {
@POST("/users/login") @POST("/users/login")
@@ -52,7 +54,7 @@ interface UserApi {
suspend fun register(@Body request: RegisterRequest): ApiResponse<Any> suspend fun register(@Body request: RegisterRequest): ApiResponse<Any>
@POST("/users/getinfo") @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") @POST("/users/changePassword")
suspend fun changePassword(@Body request: ChangePasswordRequest): ApiResponse<Any> 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.Container
import com.example.ops_android.data.model.Item 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 com.example.ops_android.data.remote.ApiResponse
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.POST import retrofit2.http.POST
data class ListContainerRequest( data class ListContainerRequest(
val parent_id: Int = 0 val parent_id: Int? = null
) )
data class ListContainerResponse( data class ListContainerResponse(
@@ -23,7 +24,10 @@ data class ContainerResponse(
) )
data class ListItemRequest( 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( data class ListItemResponse(
@@ -35,12 +39,42 @@ data class GetItemRequest(
) )
data class ItemResponse( 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( data class MoveItemRequest(
val item_id: Int, val item_id: Int,
val new_container: Int val new_container: Int? = null
) )
data class WarehouseCountResponse( data class WarehouseCountResponse(
@@ -58,13 +92,13 @@ interface WarehouseApi {
suspend fun getContainer(@Body request: GetContainerRequest): ApiResponse<ContainerResponse> suspend fun getContainer(@Body request: GetContainerRequest): ApiResponse<ContainerResponse>
@POST("/warehouse/add_container") @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") @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") @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") @POST("/warehouse/list_item")
suspend fun listItem(@Body request: ListItemRequest): ApiResponse<ListItemResponse> suspend fun listItem(@Body request: ListItemRequest): ApiResponse<ListItemResponse>
@@ -73,17 +107,17 @@ interface WarehouseApi {
suspend fun getItem(@Body request: GetItemRequest): ApiResponse<ItemResponse> suspend fun getItem(@Body request: GetItemRequest): ApiResponse<ItemResponse>
@POST("/warehouse/add_item") @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") @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") @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") @POST("/warehouse/move_item")
suspend fun moveItem(@Body request: MoveItemRequest): ApiResponse<Any> suspend fun moveItem(@Body request: MoveItemRequest): ApiResponse<Any>
@POST("/warehouse/count") @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> suspend fun get(@Body request: WorkOrderGetRequest): ApiResponse<WorkOrderDetailResponse>
@POST("/work_order/count") @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") @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") @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") @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") @POST("/work_order/commit")
suspend fun commit(@Body request: WorkOrderCommitRequest): ApiResponse<Any> 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.model.Customer
import com.example.ops_android.data.remote.api.CustomerApi 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.CustomerGetRequest
import com.example.ops_android.data.remote.api.CustomerListRequest
class CustomerRepository( class CustomerRepository(
private val customerApi: CustomerApi private val customerApi: CustomerApi
) { ) {
suspend fun list(): Result<List<Customer>> { suspend fun list(search: String? = null): Result<List<Customer>> {
return try { 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) { if (response.errCode == 0 && response.data != null) {
Result.success(response.data.customers ?: emptyList()) Result.success(response.data.customers ?: emptyList())
} else { } 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.GetContainerRequest
import com.example.ops_android.data.remote.api.ListItemRequest 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.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.MoveItemRequest
import com.example.ops_android.data.remote.api.WarehouseCountResponse import com.example.ops_android.data.remote.api.WarehouseCountResponse
class WarehouseRepository( class WarehouseRepository(
private val warehouseApi: WarehouseApi private val warehouseApi: WarehouseApi
) { ) {
suspend fun listContainer(parentId: Int = 0): Result<List<Container>> { suspend fun listContainer(parentId: Int? = null): Result<List<Container>> {
return try { return try {
val response = warehouseApi.listContainer(ListContainerRequest(parentId)) val response = warehouseApi.listContainer(ListContainerRequest(parentId))
if (response.errCode == 0 && response.data != null) { 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 { return try {
val response = warehouseApi.listItem(ListItemRequest(containerId)) val response = warehouseApi.listItem(ListItemRequest(containerId))
if (response.errCode == 0 && response.data != null) { 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 { return try {
val response = warehouseApi.getItem(GetItemRequest(id)) val response = warehouseApi.getItem(GetItemRequest(id))
if (response.errCode == 0 && response.data?.item != null) { if (response.errCode == 0 && response.data != null) {
Result.success(response.data.item) Result.success(response.data)
} else { } else {
Result.failure(Exception("获取物品详情失败")) 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 { return try {
val response = warehouseApi.moveItem(MoveItemRequest(itemId, newContainer)) val response = warehouseApi.moveItem(MoveItemRequest(itemId, newContainer))
if (response.errCode == 0) Result.success(Unit) if (response.errCode == 0) Result.success(Unit)
@@ -4,14 +4,20 @@ import com.example.ops_android.data.model.Item
import com.example.ops_android.data.model.PurchaseOrder import com.example.ops_android.data.model.PurchaseOrder
import com.example.ops_android.data.model.WorkOrder import com.example.ops_android.data.model.WorkOrder
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
object PrintHelper { object PrintHelper {
private val printMutex = Mutex()
suspend fun printOrderLabel( suspend fun printOrderLabel(
printerManager: PrinterManager, printerManager: PrinterManager,
order: PurchaseOrder order: PurchaseOrder
): Result<Unit> = withContext(Dispatchers.IO) { ): Result<Unit> = printMutex.withLock {
withContext(Dispatchers.IO) {
try { try {
printerManager.initDevice().onFailure { return@withContext Result.failure(it) } printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
printerManager.setFontSize(24) printerManager.setFontSize(24)
@@ -20,15 +26,13 @@ object PrintHelper {
printerManager.setFontSize(20) printerManager.setFontSize(20)
printerManager.setBold(false) printerManager.setBold(false)
if (order.id > 0) { if (order.id > 0) {
printerManager.addBarcode("PO-${order.id}").fold( printerManager.addBarcode("PO-${order.id}")
onSuccess = {},
onFailure = {}
)
} }
printerManager.addLineFeed(2) printerManager.addLineFeed(2)
printerManager.enableMarkDetection(true)
printerManager.commit().fold( printerManager.commit().fold(
onSuccess = { onSuccess = {
Thread.sleep(1000) delay(1000)
printerManager.closeDevice() printerManager.closeDevice()
Result.success(Unit) Result.success(Unit)
}, },
@@ -38,11 +42,13 @@ object PrintHelper {
Result.failure(e) Result.failure(e)
} }
} }
}
suspend fun printWorkOrderLabel( suspend fun printWorkOrderLabel(
printerManager: PrinterManager, printerManager: PrinterManager,
workOrder: WorkOrder workOrder: WorkOrder
): Result<Unit> = withContext(Dispatchers.IO) { ): Result<Unit> = printMutex.withLock {
withContext(Dispatchers.IO) {
try { try {
printerManager.initDevice().onFailure { return@withContext Result.failure(it) } printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
printerManager.setFontSize(24) printerManager.setFontSize(24)
@@ -51,15 +57,13 @@ object PrintHelper {
printerManager.setFontSize(20) printerManager.setFontSize(20)
printerManager.setBold(false) printerManager.setBold(false)
if (workOrder.id > 0) { if (workOrder.id > 0) {
printerManager.addBarcode("WO-${workOrder.id}").fold( printerManager.addBarcode("WO-${workOrder.id}")
onSuccess = {},
onFailure = {}
)
} }
printerManager.addLineFeed(2) printerManager.addLineFeed(2)
printerManager.enableMarkDetection(true)
printerManager.commit().fold( printerManager.commit().fold(
onSuccess = { onSuccess = {
Thread.sleep(1000) delay(1000)
printerManager.closeDevice() printerManager.closeDevice()
Result.success(Unit) Result.success(Unit)
}, },
@@ -69,11 +73,13 @@ object PrintHelper {
Result.failure(e) Result.failure(e)
} }
} }
}
suspend fun printItemLabel( suspend fun printItemLabel(
printerManager: PrinterManager, printerManager: PrinterManager,
item: Item item: Item
): Result<Unit> = withContext(Dispatchers.IO) { ): Result<Unit> = printMutex.withLock {
withContext(Dispatchers.IO) {
try { try {
printerManager.initDevice().onFailure { return@withContext Result.failure(it) } printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
printerManager.setFontSize(24) printerManager.setFontSize(24)
@@ -82,17 +88,14 @@ object PrintHelper {
printerManager.setFontSize(18) printerManager.setFontSize(18)
printerManager.setBold(false) printerManager.setBold(false)
item.serial_number?.let { printerManager.addText("编号: $it\n") } item.serial_number?.let { printerManager.addText("编号: $it\n") }
item.spec?.let { printerManager.addText("规格: $it\n") }
if (item.id > 0) { if (item.id > 0) {
printerManager.addBarcode("ITEM-${item.id}").fold( printerManager.addBarcode("ITEM-${item.id}")
onSuccess = {},
onFailure = {}
)
} }
printerManager.addLineFeed(2) printerManager.addLineFeed(2)
printerManager.enableMarkDetection(true)
printerManager.commit().fold( printerManager.commit().fold(
onSuccess = { onSuccess = {
Thread.sleep(1000) delay(1000)
printerManager.closeDevice() printerManager.closeDevice()
Result.success(Unit) Result.success(Unit)
}, },
@@ -102,12 +105,14 @@ object PrintHelper {
Result.failure(e) Result.failure(e)
} }
} }
}
suspend fun printContainerLabel( suspend fun printContainerLabel(
printerManager: PrinterManager, printerManager: PrinterManager,
containerName: String, containerName: String,
barcode: String? = null barcode: String? = null
): Result<Unit> = withContext(Dispatchers.IO) { ): Result<Unit> = printMutex.withLock {
withContext(Dispatchers.IO) {
try { try {
printerManager.initDevice().onFailure { return@withContext Result.failure(it) } printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
printerManager.setFontSize(24) printerManager.setFontSize(24)
@@ -116,15 +121,13 @@ object PrintHelper {
if (barcode != null) { if (barcode != null) {
printerManager.setFontSize(18) printerManager.setFontSize(18)
printerManager.setBold(false) printerManager.setBold(false)
printerManager.addBarcode(barcode).fold( printerManager.addBarcode(barcode)
onSuccess = {},
onFailure = {}
)
} }
printerManager.addLineFeed(2) printerManager.addLineFeed(2)
printerManager.enableMarkDetection(true)
printerManager.commit().fold( printerManager.commit().fold(
onSuccess = { onSuccess = {
Thread.sleep(1000) delay(1000)
printerManager.closeDevice() printerManager.closeDevice()
Result.success(Unit) Result.success(Unit)
}, },
@@ -135,3 +138,4 @@ object PrintHelper {
} }
} }
} }
}
@@ -8,6 +8,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -43,10 +44,16 @@ fun HomeScreen(
) )
} }
) { padding -> ) { padding ->
LazyColumn( PullToRefreshBox(
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.refresh() },
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
@@ -183,6 +190,7 @@ fun HomeScreen(
} }
} }
} }
}
@Composable @Composable
private fun StatCard(label: String, count: Int, color: Color, modifier: Modifier = Modifier) { private fun StatCard(label: String, count: Int, color: Color, modifier: Modifier = Modifier) {
@@ -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.OrderRepository
import com.example.ops_android.data.repository.ScheduleRepository import com.example.ops_android.data.repository.ScheduleRepository
import com.example.ops_android.data.repository.WorkOrderRepository 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
@@ -24,6 +21,7 @@ data class HomeUiState(
val orderCounts: OrderCounts? = null, val orderCounts: OrderCounts? = null,
val workOrderCounts: WorkOrderCounts? = null, val workOrderCounts: WorkOrderCounts? = null,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val errorMessage: String? = null val errorMessage: String? = null
) )
@@ -36,11 +34,8 @@ class HomeViewModel(
private val _uiState = MutableStateFlow(HomeUiState()) private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow() val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
private var autoRefreshJob: Job? = null
init { init {
loadData() loadData()
startAutoRefresh()
} }
fun loadData() { fun loadData() {
@@ -67,18 +62,21 @@ class HomeViewModel(
} }
} }
private fun startAutoRefresh() { fun refresh() {
autoRefreshJob = viewModelScope.launch { viewModelScope.launch {
while (isActive) { _uiState.value = _uiState.value.copy(isRefreshing = true)
delay(5000) val today = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
loadData() val eventsResult = scheduleRepository.getEvents(today, today)
} val orderCounts = orderRepository.getOrderCount()
} val workOrderCounts = workOrderRepository.getCount()
}
override fun onCleared() { _uiState.value = _uiState.value.copy(
super.onCleared() todayEvents = eventsResult.getOrNull() ?: emptyList(),
autoRefreshJob?.cancel() orderCounts = orderCounts.getOrNull(),
workOrderCounts = workOrderCounts.getOrNull(),
isRefreshing = false
)
}
} }
class Factory( class Factory(
@@ -45,7 +45,7 @@ object Routes {
const val ORDER_DETAIL = "order_detail/{orderId}" const val ORDER_DETAIL = "order_detail/{orderId}"
const val ORDER_FORM = "order_form?editId={editId}" const val ORDER_FORM = "order_form?editId={editId}"
const val WORK_ORDER_DETAIL = "work_order_detail/{id}" 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_DETAIL = "item_detail/{id}"
const val ITEM_FORM = "item_form/{containerId}?editId={editId}" const val ITEM_FORM = "item_form/{containerId}?editId={editId}"
const val CUSTOMER_LIST = "customer_list" const val CUSTOMER_LIST = "customer_list"
@@ -53,7 +53,8 @@ object Routes {
fun orderDetail(orderId: Int) = "order_detail/$orderId" fun orderDetail(orderId: Int) = "order_detail/$orderId"
fun orderForm(editId: Int? = null) = if (editId != null) "order_form?editId=$editId" else "order_form" fun orderForm(editId: Int? = null) = if (editId != null) "order_form?editId=$editId" else "order_form"
fun workOrderDetail(id: Int) = "work_order_detail/$id" 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 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" 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 val id = backStackEntry.arguments?.getInt("id") ?: return@composable
WorkOrderDetailScreen(id = id, onBack = { rootNavController.popBackStack() }) 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 } 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 -> composable(Routes.ITEM_DETAIL, arguments = listOf(navArgument("id") { type = NavType.IntType })) { backStackEntry ->
val id = backStackEntry.arguments?.getInt("id") ?: return@composable 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 -> 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 val containerId = backStackEntry.arguments?.getInt("containerId") ?: 0
@@ -186,7 +193,8 @@ fun MainScreen(onLogout: () -> Unit, onNavigate: (String) -> Unit) {
} }
composable(BottomTabs.WAREHOUSE) { composable(BottomTabs.WAREHOUSE) {
WarehouseScreen( WarehouseScreen(
onItemClick = { id -> onNavigate(Routes.itemDetail(id)) } onItemClick = { id -> onNavigate(Routes.itemDetail(id)) },
onAddItemClick = { containerId -> onNavigate(Routes.itemForm(containerId)) }
) )
} }
composable(BottomTabs.PROFILE) { composable(BottomTabs.PROFILE) {
@@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -53,7 +54,7 @@ fun OrderListScreen(
) { padding -> ) { padding ->
Column(modifier = Modifier.padding(padding)) { Column(modifier = Modifier.padding(padding)) {
// Status filter tabs // Status filter tabs
ScrollableTabRow( PrimaryScrollableTabRow(
selectedTabIndex = ORDER_STATUSES.indexOfFirst { it.first == uiState.selectedStatus }.coerceAtLeast(0), selectedTabIndex = ORDER_STATUSES.indexOfFirst { it.first == uiState.selectedStatus }.coerceAtLeast(0),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
edgePadding = 8.dp edgePadding = 8.dp
@@ -89,6 +90,11 @@ fun OrderListScreen(
} }
} }
else -> { else -> {
PullToRefreshBox(
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.refresh() },
modifier = Modifier.fillMaxSize()
) {
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp), contentPadding = PaddingValues(8.dp),
@@ -119,6 +125,7 @@ fun OrderListScreen(
} }
} }
} }
}
@Composable @Composable
private fun OrderListItem(order: PurchaseOrder, onClick: () -> Unit) { private fun OrderListItem(order: PurchaseOrder, onClick: () -> Unit) {
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -35,6 +36,10 @@ fun ProfileScreen(
val uiState by viewModel.uiState.collectAsState() val uiState by viewModel.uiState.collectAsState()
var showLogoutDialog by remember { mutableStateOf(false) } var showLogoutDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.loadProfile()
}
LaunchedEffect(uiState.isLoggedOut) { LaunchedEffect(uiState.isLoggedOut) {
if (uiState.isLoggedOut) { if (uiState.isLoggedOut) {
onLogout() onLogout()
@@ -52,12 +57,35 @@ fun ProfileScreen(
) )
} }
) { padding -> ) { padding ->
Column( PullToRefreshBox(
isRefreshing = uiState.isRefreshing,
onRefresh = { viewModel.refresh() },
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()) .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( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -129,6 +157,7 @@ fun ProfileScreen(
) )
} }
} }
}
if (showLogoutDialog) { if (showLogoutDialog) {
AlertDialog( AlertDialog(
@@ -16,6 +16,7 @@ data class ProfileUiState(
val user: UserInfo? = null, val user: UserInfo? = null,
val userInfo: UserDetail? = null, val userInfo: UserDetail? = null,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val isLoggedOut: Boolean = false 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() { fun logout() {
viewModelScope.launch { viewModelScope.launch {
authRepository.logout() authRepository.logout()
@@ -66,7 +89,7 @@ class ProfileViewModel(
get() { get() {
val info = _uiState.value.userInfo val info = _uiState.value.userInfo
val user = _uiState.value.user val user = _uiState.value.user
return info?.Username ?: user?.Name ?: "" return info?.Username?.ifBlank { null } ?: user?.Name ?: ""
} }
val avatarPath: String? val avatarPath: String?
@@ -138,7 +138,7 @@ fun SearchScreen(
) )
// Category tabs // Category tabs
ScrollableTabRow( PrimaryScrollableTabRow(
selectedTabIndex = SearchCategory.entries.indexOf(uiState.category), selectedTabIndex = SearchCategory.entries.indexOf(uiState.category),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
edgePadding = 8.dp edgePadding = 8.dp
@@ -1,23 +1,32 @@
package com.example.ops_android.ui.warehouse package com.example.ops_android.ui.warehouse
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel 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.data.repository.WarehouseRepository
import com.example.ops_android.ui.LocalAppContainer import com.example.ops_android.ui.LocalAppContainer
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -25,36 +34,201 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch 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() } 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ItemDetailScreen(id: Int, onBack: () -> Unit) { fun ItemDetailScreen(id: Int, onBack: () -> Unit, onCreateWorkOrder: (Int) -> Unit = {}, onWorkOrderClick: (Int) -> Unit = {}) {
val vm: ItemDetailViewModel = viewModel(key = "item_$id", factory = ItemDetailViewModel.Factory(LocalAppContainer.current.warehouseRepository, id)) val app = LocalAppContainer.current
val s by vm.s.collectAsState(); val item = s.item 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 (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)) { Column(Modifier.fillMaxSize().padding(padding)) {
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 ?: "-") } } } 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 -> // Photos
if (history.isNotEmpty()) { val photos = resp.photos
item { Text("移动记录", fontWeight = FontWeight.Bold, fontSize = 16.sp) } if (!photos.isNullOrEmpty()) {
items(history) { h -> 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()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) { 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) } 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) }
} }
} }
} }
@@ -63,8 +237,118 @@ 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 @Composable
private fun DetailRow(label: String, value: String) { private fun WorkOrderRow(wo: LinkedWorkOrder, onClick: () -> Unit = {}) {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text(label, color = Color.Gray, modifier = Modifier.width(80.dp)); Text(value) } 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 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.layout.*
import androidx.compose.foundation.rememberScrollState 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.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel 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.data.repository.WarehouseRepository
import com.example.ops_android.ui.LocalAppContainer import com.example.ops_android.ui.LocalAppContainer
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
data class ItemFormUiState( data class ItemPhoto(
val name: String = "", val serialNumber: String = "", val spec: String = "", val remark: String = "", val uri: Uri,
val isSubmitting: Boolean = false, val errorMessage: String? = null, val success: Boolean = false 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 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 onName(v: String) { _s.value = _s.value.copy(name = v) }
fun onSerial(v: String) { _s.value = _s.value.copy(serialNumber = v) } fun onSerial(v: String) { _s.value = _s.value.copy(serialNumber = v) }
fun onSpec(v: String) { _s.value = _s.value.copy(spec = v) } fun onQuantity(v: String) {
if (v.isEmpty() || v.all { it.isDigit() }) {
_s.value = _s.value.copy(quantity = v)
}
}
fun onRemark(v: String) { _s.value = _s.value.copy(remark = 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 } fun onCustomerSearchQuery(v: String) {
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) _s.value = _s.value.copy(customerSearchQuery = v)
viewModelScope.launch { customerSearchJob?.cancel()
_s.value = _s.value.copy(isSubmitting = true) customerSearchJob = viewModelScope.launch {
val r = if (editId != null) repo.updateItem(data + ("id" to editId)) else repo.addItem(data) delay(300)
r.fold(onSuccess = { _s.value = _s.value.copy(isSubmitting = false, success = true) }, onFailure = { _s.value = _s.value.copy(isSubmitting = false, errorMessage = it.message) }) 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) }
)
} }
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 }
} }
@OptIn(ExperimentalMaterial3Api::class) 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
}
}
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 @Composable
fun ItemFormScreen(containerId: Int = 0, editId: Int? = null, onBack: () -> Unit, onSaved: () -> Unit) { 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() val s by vm.s.collectAsState()
LaunchedEffect(s.success) { if (s.success) onSaved() } 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)) { 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.name, vm::onName, label = { Text("物品名称 *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(s.serialNumber, vm::onSerial, 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.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) 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) } 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.Folder
import androidx.compose.material.icons.filled.Inventory import androidx.compose.material.icons.filled.Inventory
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -26,20 +27,21 @@ import com.example.ops_android.ui.LocalAppContainer
@Composable @Composable
fun WarehouseScreen( fun WarehouseScreen(
onItemClick: (Int) -> Unit, onItemClick: (Int) -> Unit,
onAddItemClick: (Int) -> Unit = {},
viewModel: WarehouseViewModel = viewModel(factory = WarehouseViewModel.Factory(LocalAppContainer.current.warehouseRepository)) viewModel: WarehouseViewModel = viewModel(factory = WarehouseViewModel.Factory(LocalAppContainer.current.warehouseRepository))
) { ) {
val s by viewModel.s.collectAsState() 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 newContainerName by remember { mutableStateOf("") }
var tabIndex by remember { mutableStateOf(0) }
Scaffold( Scaffold(
topBar = { TopAppBar(title = { Text("仓库") }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) }, 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 -> ) { padding ->
Column(Modifier.padding(padding)) { Column(Modifier.padding(padding)) {
// Breadcrumb // Breadcrumb
ScrollableTabRow(selectedTabIndex = s.breadcrumb.size - 1) { PrimaryScrollableTabRow(selectedTabIndex = s.breadcrumb.size - 1) {
s.breadcrumb.forEachIndexed { idx, (id, name) -> s.breadcrumb.forEachIndexed { idx, (id, name) ->
Tab(selected = idx == s.breadcrumb.size - 1, onClick = { viewModel.navigateToBreadcrumb(idx) }, text = { Text(name, fontSize = 12.sp) }) Tab(selected = idx == s.breadcrumb.size - 1, onClick = { viewModel.navigateToBreadcrumb(idx) }, text = { Text(name, fontSize = 12.sp) })
} }
@@ -51,8 +53,23 @@ fun WarehouseScreen(
FilterChip(selected = s.showItemsOnly, onClick = { viewModel.toggleItemsOnly() }, label = { Text("仅物品") }) FilterChip(selected = s.showItemsOnly, onClick = { viewModel.toggleItemsOnly() }, label = { Text("仅物品") })
} }
if (s.isLoading) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } // Error
else LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { 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)
}
}
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) { if (!s.showItemsOnly) {
items(s.containers) { container -> items(s.containers) { container ->
ContainerItem(container) { viewModel.navigateTo(container.id, container.name ?: "未命名") } ContainerItem(container) { viewModel.navigateTo(container.id, container.name ?: "未命名") }
@@ -64,15 +81,57 @@ fun WarehouseScreen(
} }
} }
} }
}
if (showAddDialog) AlertDialog( // FAB menu dialog
onDismissRequest = { showAddDialog = false }, 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("新建容器") }, title = { Text("新建容器") },
text = { OutlinedTextField(newContainerName, { newContainerName = it }, label = { Text("容器名称") }, singleLine = true) }, text = { OutlinedTextField(newContainerName, { newContainerName = it }, label = { Text("容器名称") }, singleLine = true) },
confirmButton = { Button(onClick = { if (newContainerName.isNotBlank()) { viewModel.addContainer(newContainerName); showAddDialog = false; newContainerName = "" } }) { Text("创建") } }, confirmButton = {
dismissButton = { TextButton(onClick = { showAddDialog = false }) { Text("取消") } } Button(onClick = {
if (newContainerName.isNotBlank()) {
viewModel.addContainer(newContainerName)
showAddContainer = false
newContainerName = ""
}
}) { Text("创建") }
},
dismissButton = { TextButton(onClick = { showAddContainer = false }) { Text("取消") } }
) )
} }
}
@Composable @Composable
private fun ContainerItem(container: Container, onClick: () -> Unit) { private fun ContainerItem(container: Container, onClick: () -> Unit) {
@@ -17,6 +17,7 @@ data class WarehouseUiState(
val breadcrumb: List<Pair<Int, String>> = listOf(0 to "根目录"), val breadcrumb: List<Pair<Int, String>> = listOf(0 to "根目录"),
val showItemsOnly: Boolean = false, val showItemsOnly: Boolean = false,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val errorMessage: String? = null val errorMessage: String? = null
) )
@@ -25,19 +26,20 @@ class WarehouseViewModel(
) : ViewModel() { ) : ViewModel() {
private val _s = MutableStateFlow(WarehouseUiState()); val s: StateFlow<WarehouseUiState> = _s.asStateFlow() 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 { viewModelScope.launch {
_s.value = _s.value.copy(isLoading = true) _s.value = _s.value.copy(isLoading = true, isRefreshing = true)
repo.listContainer(parentId).fold( repo.listContainer(parentId).fold(
onSuccess = { _s.value = _s.value.copy(containers = it, isLoading = false) }, onSuccess = { _s.value = _s.value.copy(containers = it) },
onFailure = { _s.value = _s.value.copy(isLoading = false, errorMessage = it.message) } onFailure = { _s.value = _s.value.copy(errorMessage = it.message) }
) )
repo.listItem(parentId).fold( repo.listItem(parentId).fold(
onSuccess = { _s.value = _s.value.copy(items = it) }, onSuccess = { _s.value = _s.value.copy(items = it) },
onFailure = { } onFailure = { }
) )
_s.value = _s.value.copy(isLoading = false, isRefreshing = false)
} }
} }
@@ -51,17 +53,22 @@ class WarehouseViewModel(
fun navigateToBreadcrumb(index: Int) { fun navigateToBreadcrumb(index: Int) {
val entry = _s.value.breadcrumb[index] val entry = _s.value.breadcrumb[index]
_s.value = _s.value.copy(breadcrumb = _s.value.breadcrumb.take(index + 1)) _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 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) { fun addContainer(name: String) {
val parentId = _s.value.breadcrumb.last().first val parentId = _s.value.breadcrumb.last().first
val parentIdOrNull: Int? = parentId.takeIf { it != 0 }
viewModelScope.launch { 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() }, onSuccess = { refresh() },
onFailure = { _s.value = _s.value.copy(errorMessage = it.message) } onFailure = { _s.value = _s.value.copy(errorMessage = it.message) }
) )
@@ -1,64 +1,250 @@
package com.example.ops_android.ui.workorder 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.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel import androidx.core.content.FileProvider
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel 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 com.example.ops_android.ui.LocalAppContainer
import kotlinx.coroutines.flow.MutableStateFlow import java.io.File
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
data class WorkOrderFormUiState( private fun createTempImageFile(context: android.content.Context): File {
val title: String = "", val description: String = "", val isSubmitting: Boolean = false, val errorMessage: String? = null, val success: Boolean = false val dir = File(context.cacheDir, "photos")
) if (!dir.exists()) dir.mkdirs()
return File(dir, "capture_${System.currentTimeMillis()}.jpg")
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
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun WorkOrderFormScreen(editId: Int? = null, onBack: () -> Unit, onSaved: () -> Unit) { fun WorkOrderFormScreen(editId: Int? = null, prefillItemId: Int? = null, onBack: () -> Unit, onSaved: () -> Unit) {
val vm: WorkOrderFormViewModel = viewModel(factory = WorkOrderFormViewModel.Factory(LocalAppContainer.current.workOrderRepository, editId)) 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() val s by vm.s.collectAsState()
LaunchedEffect(s.success) { if (s.success) onSaved() } 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) }
Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { 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.title, vm::onTitle, label = { Text("标题 *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(s.description, vm::onDesc, label = { Text("描述") }, modifier = Modifier.fillMaxWidth(), minLines = 3) 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) } s.errorMessage?.let { Text(it, color = Color.Red, fontSize = 14.sp) }
Button(onClick = { vm.submit() }, Modifier.fillMaxWidth().height(48.dp), enabled = !s.isSubmitting) { Button(
if (s.isSubmitting) CircularProgressIndicator(Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp) else Text("提交") 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.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -37,13 +38,18 @@ fun WorkOrderListScreen(
floatingActionButton = { FloatingActionButton(onClick = onAddClick, containerColor = Color(0xFF667EEA)) { Icon(Icons.Default.Add, "新建", tint = Color.White) } } floatingActionButton = { FloatingActionButton(onClick = onAddClick, containerColor = Color(0xFF667EEA)) { Icon(Icons.Default.Add, "新建", tint = Color.White) } }
) { padding -> ) { padding ->
Column(Modifier.padding(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) -> 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) }) 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() } if (uiState.isLoading) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
else LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { 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 -> items(uiState.workOrders, key = { it.id }) { wo ->
Card(Modifier.fillMaxWidth().clickable { onWorkOrderClick(wo.id) }) { Card(Modifier.fillMaxWidth().clickable { onWorkOrderClick(wo.id) }) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
@@ -64,3 +70,4 @@ fun WorkOrderListScreen(
} }
} }
} }
}
@@ -24,6 +24,7 @@ data class WorkOrderListUiState(
val workOrders: List<WorkOrder> = emptyList(), val workOrders: List<WorkOrder> = emptyList(),
val selectedStatus: String = "", val selectedStatus: String = "",
val isLoading: Boolean = false, val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
val hasMore: Boolean = true, val hasMore: Boolean = true,
val errorMessage: String? = null val errorMessage: String? = null
@@ -43,12 +44,12 @@ class WorkOrderListViewModel(
fun refresh() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true, workOrders = emptyList()) _uiState.value = _uiState.value.copy(isRefreshing = true, workOrders = emptyList())
page = 1 page = 1
val r = repository.list(status = _uiState.value.selectedStatus.ifEmpty { null }) val r = repository.list(status = _uiState.value.selectedStatus.ifEmpty { null })
r.fold( r.fold(
onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(workOrders = list, isLoading = false, hasMore = list.size >= pageSize) }, onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(workOrders = list, isRefreshing = false, isLoading = false, hasMore = list.size >= pageSize) },
onFailure = { e -> _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = e.message) } onFailure = { e -> _uiState.value = _uiState.value.copy(isRefreshing = false, isLoading = false, errorMessage = e.message) }
) )
} }
} }
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="photos" path="photos/" />
</paths>
+2 -2
View File
@@ -1,3 +1,3 @@
#Thu Jun 25 11:21:25 CST 2026 #Thu Jun 25 15:33:48 CST 2026
VERSION_CODE=48 VERSION_CODE=82
VERSION_NAME=1.2 VERSION_NAME=1.2