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