diff --git a/app/objectbox-models/default.json b/app/objectbox-models/default.json index 4ed4550..6860508 100644 --- a/app/objectbox-models/default.json +++ b/app/objectbox-models/default.json @@ -5,7 +5,7 @@ "entities": [ { "id": "1:6465153867413579585", - "lastPropertyId": "4:3660727688686055762", + "lastPropertyId": "5:7592685238899911344", "name": "Food", "properties": [ { @@ -30,6 +30,11 @@ "indexId": "1:5497952887792054610", "type": 28, "flags": 8 + }, + { + "id": "5:7592685238899911344", + "name": "version", + "type": 9 } ], "relations": [] diff --git a/app/src/main/java/com/shuwei/dish/match/dialog/FoodSearchDialog.kt b/app/src/main/java/com/shuwei/dish/match/dialog/FoodSearchDialog.kt index cbdd754..a325699 100644 --- a/app/src/main/java/com/shuwei/dish/match/dialog/FoodSearchDialog.kt +++ b/app/src/main/java/com/shuwei/dish/match/dialog/FoodSearchDialog.kt @@ -50,27 +50,27 @@ class FoodSearchDialog( private val binding = DialogFoodSearchBinding.inflate(LayoutInflater.from(activity)) private val list = mutableListOf() - .apply { - add(GoodsItem(goodsId = "200001", goodsName = "土豆丝")) - add(GoodsItem(goodsId = "200002", goodsName = "土豆片")) - add(GoodsItem(goodsId = "200003", goodsName = "土豆丁")) - add(GoodsItem(goodsId = "200004", goodsName = "胡萝卜丝")) - add(GoodsItem(goodsId = "200005", goodsName = "胡萝卜片")) - add(GoodsItem(goodsId = "200006", goodsName = "胡萝卜丁")) - add(GoodsItem(goodsId = "200007", goodsName = "黄瓜丝")) - add(GoodsItem(goodsId = "200008", goodsName = "黄瓜片")) - add(GoodsItem(goodsId = "200009", goodsName = "黄瓜丁")) - add(GoodsItem(goodsId = "200010", goodsName = "洋葱丝")) - add(GoodsItem(goodsId = "200011", goodsName = "洋葱丁")) - add(GoodsItem(goodsId = "200012", goodsName = "A")) - add(GoodsItem(goodsId = "200013", goodsName = "B")) - add(GoodsItem(goodsId = "200014", goodsName = "C")) - add(GoodsItem(goodsId = "200015", goodsName = "D")) - add(GoodsItem(goodsId = "200016", goodsName = "E")) - add(GoodsItem(goodsId = "200017", goodsName = "F")) - add(GoodsItem(goodsId = "200018", goodsName = "G")) - add(GoodsItem(goodsId = "200019", goodsName = "H")) - } +// .apply { +// add(GoodsItem(goodsId = "200001", goodsName = "土豆丝")) +// add(GoodsItem(goodsId = "200002", goodsName = "土豆片")) +// add(GoodsItem(goodsId = "200003", goodsName = "土豆丁")) +// add(GoodsItem(goodsId = "200004", goodsName = "胡萝卜丝")) +// add(GoodsItem(goodsId = "200005", goodsName = "胡萝卜片")) +// add(GoodsItem(goodsId = "200006", goodsName = "胡萝卜丁")) +// add(GoodsItem(goodsId = "200007", goodsName = "黄瓜丝")) +// add(GoodsItem(goodsId = "200008", goodsName = "黄瓜片")) +// add(GoodsItem(goodsId = "200009", goodsName = "黄瓜丁")) +// add(GoodsItem(goodsId = "200010", goodsName = "洋葱丝")) +// add(GoodsItem(goodsId = "200011", goodsName = "洋葱丁")) +// add(GoodsItem(goodsId = "200012", goodsName = "A")) +// add(GoodsItem(goodsId = "200013", goodsName = "B")) +// add(GoodsItem(goodsId = "200014", goodsName = "C")) +// add(GoodsItem(goodsId = "200015", goodsName = "D")) +// add(GoodsItem(goodsId = "200016", goodsName = "E")) +// add(GoodsItem(goodsId = "200017", goodsName = "F")) +// add(GoodsItem(goodsId = "200018", goodsName = "G")) +// add(GoodsItem(goodsId = "200019", goodsName = "H")) +// } private val adapter = FoodAdapter(list).apply { isStateViewEnable = true setOnItemClickListener { _, _, position -> @@ -135,13 +135,13 @@ class FoodSearchDialog( initObserver() -// // 若有默认食材名称,自动填充并触发搜索 -// //if (!defGoodsName.isNullOrBlank()) { -// binding.etSheetInput.setText(defGoodsName) -// pageNo = 1 -// getGoodsList() -// KeyboardUtil.hideKeyboard(binding.root) -// //} + // 若有默认食材名称,自动填充并触发搜索 + //if (!defGoodsName.isNullOrBlank()) { + //binding.etSheetInput.setText(defGoodsName) + pageNo = 1 + getGoodsList() + KeyboardUtil.hideKeyboard(binding.root) + //} } /** 弹窗显示时在 Window.Callback 层提前拦截触摸,解决 BottomSheet 拖拽与列表滑动的手势冲突 */ diff --git a/app/src/main/java/com/shuwei/dish/match/dialog/SeasoningSelectDialog.kt b/app/src/main/java/com/shuwei/dish/match/dialog/SeasoningSelectDialog.kt index 5bff4a2..7f3d896 100644 --- a/app/src/main/java/com/shuwei/dish/match/dialog/SeasoningSelectDialog.kt +++ b/app/src/main/java/com/shuwei/dish/match/dialog/SeasoningSelectDialog.kt @@ -186,25 +186,25 @@ class SeasoningSelectDialog( * 请求调料列表 */ private fun getGoodsList() { -// activity.netViewModel.queryGoodsList( -// goodsType = "1", -// pageNum = pageNo, -// pageSize = PAGE_SIZE, -// goodsName = goodsName.takeUnless { it.isNullOrBlank() } -// ) - - // TODO: 联调时注释掉下方模拟数据,改为真实接口调用 - val nameList = listOf( - "盐", "白砂糖","冰糖", "鸡精", "味精", "陈醋", "老抽", "生抽", "番茄酱", "胡椒粉", "孜然", "十三香", - "料酒", "白醋", "蚝油", "辣椒面", "辣椒酱", "豆瓣酱", "淀粉", "葱", "蒜", "姜", "香菜", "油" + activity.netViewModel.queryGoodsList( + goodsType = "1", + pageNum = pageNo, + pageSize = PAGE_SIZE, + goodsName = goodsName.takeUnless { it.isNullOrBlank() } ) - val mockData = mutableListOf() - nameList.forEachIndexed { index, name -> - val padString = "${index + 1}".padStart(2, '0') - mockData.add(GoodsItem(goodsId = "1000$padString", goodsName = name)) - } - loadGoodsList(mockData) +// // TODO: 联调时注释掉下方模拟数据,改为真实接口调用 +// val nameList = listOf( +// "盐", "白砂糖","冰糖", "鸡精", "味精", "陈醋", "老抽", "生抽", "番茄酱", "胡椒粉", "孜然", "十三香", +// "料酒", "白醋", "蚝油", "辣椒面", "辣椒酱", "豆瓣酱", "淀粉", "葱", "蒜", "姜", "香菜", "油" +// ) +// +// val mockData = mutableListOf() +// nameList.forEachIndexed { index, name -> +// val padString = "${index + 1}".padStart(2, '0') +// mockData.add(GoodsItem(goodsId = "1000$padString", goodsName = name)) +// } +// loadGoodsList(mockData) } /** diff --git a/app/src/main/java/com/shuwei/dish/match/net/ApiService.kt b/app/src/main/java/com/shuwei/dish/match/net/ApiService.kt index 7e590cd..60f2014 100644 --- a/app/src/main/java/com/shuwei/dish/match/net/ApiService.kt +++ b/app/src/main/java/com/shuwei/dish/match/net/ApiService.kt @@ -4,9 +4,14 @@ import com.shuwei.dish.match.base.GlobalData import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.entity.FoodRecord import com.shuwei.dish.match.entity.GoodsItem +import okhttp3.MultipartBody +import okhttp3.RequestBody import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.PartMap import retrofit2.http.Query import retrofit2.http.Url @@ -36,7 +41,7 @@ interface ApiService { */ @POST suspend fun searchFoodList( - @Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryPageList", + @Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryPageList", @Body param: MutableMap ): ApiResponse?> @@ -45,7 +50,7 @@ interface ApiService { */ @POST suspend fun getSamplingList( - @Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryHistoryGoodsInfoList", + @Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryHistoryGoodsInfoList", @Body param: MutableMap ): ApiResponse?> @@ -54,8 +59,18 @@ interface ApiService { */ @POST suspend fun queryGoodsList( - @Url url:String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryGoodsInfoList", + @Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/queryGoodsInfoList", @Body param: MutableMap ): ApiResponse?> + /** + * 提交采集图片数据 + */ + @Multipart + @POST + suspend fun uploadFoodVectorData( + @Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/addWeighingCollectionData", + @PartMap params: Map, + @Part foodPics: List + ): ApiResponse?> } \ No newline at end of file diff --git a/app/src/main/java/com/shuwei/dish/match/net/NetViewModel.kt b/app/src/main/java/com/shuwei/dish/match/net/NetViewModel.kt index fe05810..5bcaa6c 100644 --- a/app/src/main/java/com/shuwei/dish/match/net/NetViewModel.kt +++ b/app/src/main/java/com/shuwei/dish/match/net/NetViewModel.kt @@ -10,6 +10,11 @@ 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 +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File /** * 网络请求 ViewModel,所有网络操作通过 RemoteRepository 发起,不直接依赖 apiService @@ -126,4 +131,44 @@ class NetViewModel( _goodsListState.value = repository.queryGoodsList(param) } } + + /** + * 上传采集菜品信息 UI 状态流,UI 层通过 collect 监听 + */ + private val _uploadCollectState = MutableStateFlow?>>(UiState.Idle) + val uploadCollectState: StateFlow?>> = _uploadCollectState.asStateFlow() + + /** + * 上传采集菜品信息(触发 StateFlow,适合单次上传场景) + */ + fun uploadFoodVectorData( + fileList: List, + params: MutableMap + ) { + val fileParts = fileList.map { file -> + file.asRequestBody("multipart/form-data".toMediaTypeOrNull()) + .let { MultipartBody.Part.createFormData("foodPics", file.name, it) } + } + viewModelScope.launch { + _uploadCollectState.value = UiState.Loading + _uploadCollectState.value = repository.uploadFoodVectorData(params, fileParts) + } + } + + /** + * 上传采集菜品信息(直接返回结果,适合分批循环上传场景) + * @param fileList 图片文件列表 + * @param params 附加参数 + * @return UiState 包装的结果 + */ + suspend fun uploadFoodVectorDataBatch( + fileList: List, + params: MutableMap + ): UiState?> { + val fileParts = fileList.map { file -> + file.asRequestBody("multipart/form-data".toMediaTypeOrNull()) + .let { MultipartBody.Part.createFormData("foodPics", file.name, it) } + } + return repository.uploadFoodVectorData(params, fileParts) + } } diff --git a/app/src/main/java/com/shuwei/dish/match/net/RemoteRepository.kt b/app/src/main/java/com/shuwei/dish/match/net/RemoteRepository.kt index f70b97d..c314db0 100644 --- a/app/src/main/java/com/shuwei/dish/match/net/RemoteRepository.kt +++ b/app/src/main/java/com/shuwei/dish/match/net/RemoteRepository.kt @@ -3,6 +3,9 @@ package com.shuwei.dish.match.net import com.shuwei.dish.match.entity.CookFoodEntity import com.shuwei.dish.match.entity.FoodRecord import com.shuwei.dish.match.entity.GoodsItem +import okhttp3.MultipartBody +import okhttp3.RequestBody +import java.io.File /** * 网络数据仓库,封装所有 ApiService 调用 @@ -90,4 +93,24 @@ class RemoteRepository { } } + /** + * 上次采集数据 + * @param params 参数 + * @param foodPics 图片 + * @return UiState 包装的结果,Success 携带列表数据,Error 携带错误信息 + */ + suspend fun uploadFoodVectorData( + params: Map, + foodPics: List + ): UiState?> { + return try { + val resp = apiService.uploadFoodVectorData(params = params, foodPics = foodPics) + if (resp.isSuccess()) UiState.Success(resp.data) + else UiState.Error(resp.code, resp.msg ?: "") + } catch (e: Exception) { + val ex = getApiException(e) + UiState.Error("-1", ex.errorMsg) + } + } + } diff --git a/app/src/main/java/com/shuwei/dish/match/objbox/Food.kt b/app/src/main/java/com/shuwei/dish/match/objbox/Food.kt index 4ea52b9..8682720 100644 --- a/app/src/main/java/com/shuwei/dish/match/objbox/Food.kt +++ b/app/src/main/java/com/shuwei/dish/match/objbox/Food.kt @@ -11,7 +11,8 @@ data class Food( var name: String? = null, var foodIdx: Int = 0, @HnswIndex(dimensions = 512, distanceType = VectorDistanceType.DOT_PRODUCT) - var foodVector: FloatArray? = null + var foodVector: FloatArray? = null, + var version: String? = null ) { override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/app/src/main/java/com/shuwei/dish/match/ui/fragment/VectorCollectionFragment.kt b/app/src/main/java/com/shuwei/dish/match/ui/fragment/VectorCollectionFragment.kt index fd7047f..4c930d9 100644 --- a/app/src/main/java/com/shuwei/dish/match/ui/fragment/VectorCollectionFragment.kt +++ b/app/src/main/java/com/shuwei/dish/match/ui/fragment/VectorCollectionFragment.kt @@ -28,17 +28,23 @@ import com.shuwei.dish.match.databinding.LayoutCameraPreviewBinding import com.shuwei.dish.match.dialog.Loading import com.shuwei.dish.match.entity.FoodCollectionBean import com.shuwei.dish.match.entity.GoodsItem +import com.shuwei.dish.match.net.UiState +import com.shuwei.dish.match.objbox.Food import com.shuwei.dish.match.objbox.FoodModule +import com.shuwei.dish.match.objbox.ObjectBox import com.shuwei.dish.match.ui.CollectedFoodActivity import com.shuwei.dish.match.ui.SingleFragmentActivity import com.shuwei.dish.match.utils.BitmapSaver import com.shuwei.dish.match.utils.CameraUtils import com.shuwei.dish.match.utils.Debouncer +import com.shuwei.dish.match.utils.ImageUploader import com.shuwei.dish.match.utils.ImageUtil import com.shuwei.dish.match.utils.LogSaveUtil import com.shuwei.dish.match.utils.ext.clickWithDebounce import com.shuwei.dish.match.utils.ext.dp import com.shuwei.dish.match.utils.ext.toast +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody @SuppressLint("NotifyDataSetChanged") class VectorCollectionFragment : BaseFragment() { @@ -159,13 +165,13 @@ class VectorCollectionFragment : BaseFragment() vector to file } catch (e: Exception) { e.printStackTrace() + log("操作失败:${e.message}") null to null } } // 回到主线程处理结果 if (imageVector == null) { toast("操作失败") - log("操作失败") hideWaitingDialog() return } @@ -237,16 +243,16 @@ class VectorCollectionFragment : BaseFragment() } binding.btnSave.setOnClickListener { -// if (checkedItem == null || checkedItem!!.isChecked.not()) { -// toast("请选择菜品名称") -// return@setOnClickListener -// } -// val count = foodCollectionList.count { it.imageFile != null } -// if (count == 0) { -// toast("请拍摄菜品照片") -// return@setOnClickListener -// } -// upload() + if (checkedItem == null || checkedItem!!.isClicked.not()) { + toast("请选择菜品名称") + return@setOnClickListener + } + val count = vectorList.count { it.imageFile != null } + if (count == 0) { + toast("请拍摄菜品照片") + return@setOnClickListener + } + upload() } binding.editFoodName.setOnEditorActionListener { v, actionId, event -> @@ -281,67 +287,54 @@ class VectorCollectionFragment : BaseFragment() searchFood() } - @SuppressLint("NotifyDataSetChanged") private fun upload() { -// lifecycleScope.launch { -// val totalFileCount = foodCollectionList.count { it.imageFile != null } -// showWaitingDialog2("图片上传中0/$totalFileCount") -// val params = HashMap() -// //params["placeId"] = restId.toRequestBody() -// params["foodId"] = checkedItem!!.foodId.toRequestBody() -// params["foodName"] = checkedItem!!.foodName!!.toRequestBody() -// params["version"] = GlobalData.foodModelVersion.toRequestBody() -// -// ImageUploader(totalList = foodCollectionList, uploadImage = { batch -> -// val files = batch.map { it.imageFile } -// val foodVectorList = batch.filter { it.imageVector != null }.map { -// it.imageVector!!.joinToString( -// separator = ",", prefix = "[", postfix = "]" -// ) -// } -// val foodVectorJson = -// foodVectorList.joinToString(separator = ",", prefix = "[", postfix = "]") -// log("json=$foodVectorJson") -// params["foodVector"] = foodVectorJson.toRequestBody() -// viewModel?.uploadCollectFoodPics(files, params) -// }, onProgress = { count, batch, idList -> -// //batch.forEach { -// // it.uploadSuccess = true -// //} -// runBlocking { -// activity?.runOnUiThread { -// showWaitingDialog("图片上传中$count/$totalFileCount") -// } -// val foodList = batch.mapIndexed { index, it -> -// Food( -// collectId = if (index < idList.size) idList[index] else null, -// foodId = checkedItem!!.foodId, -// foodName = checkedItem!!.foodName, -// foodVector = it.imageVector, -// version = GlobalData.foodModelVersion -// ) -// } -// ObjectBox.putAll(foodList) -// activity?.runOnUiThread { -// batch.forEach { it.isFinish = true } -// collectionAdapter.notifyDataSetChanged() -// } -// } -// }, onError = { -// activity?.runOnUiThread { -// binding.root.postDelayed({ -// hideWaitingDialog() -// toast("上传失败,请稍后重试") -// }, 1000) -// } -// }, onComplete = { -// //vectorThread() -// binding.root.postDelayed({ -// hideWaitingDialog() -// toast("上传成功") -// }, 1000) -// }).processUploads() -// } + lifecycleScope.launch { + val totalFileCount = vectorList.count { it.imageFile != null } + showWaitingDialog("图片上传中0/$totalFileCount") + val params = mutableMapOf() + params["goodsId"] = checkedItem!!.goodsId.toRequestBody() + params["goodsName"] = checkedItem!!.goodsName!!.toRequestBody() + //净材种类,逗号分隔 +// params["rawMaterialsTypes"] = "".toRequestBody() + //params["version"] = "1.0.0".toRequestBody() + + ImageUploader(totalList = vectorList, uploadImage = { batch -> + val files = batch.mapNotNull { it.imageFile } + val foodVectorList = batch.filter { it.imageVector != null }.map { + it.imageVector!!.joinToString( + separator = ",", prefix = "[", postfix = "]" + ) + } + val foodVectorJson = + foodVectorList.joinToString(separator = ",", prefix = "[", postfix = "]") + log("json=$foodVectorJson") + params["foodVector"] = foodVectorJson.toRequestBody() + // 直接 await 单批上传结果,成功返回 id 列表,失败返回 null 触发 onError + val result = currentActivity.netViewModel.uploadFoodVectorDataBatch(files, params) + if (result is UiState.Success) result.data else null + }, onProgress = { count, batch, idList -> + showWaitingDialog("图片上传中$count/$totalFileCount") + val foodList = batch.mapIndexed { index, it -> + Food( + name = checkedItem!!.goodsName, + foodVector = it.imageVector, + ) + } + ObjectBox.putAll(foodList) + batch.forEach { it.isFinish = true } + vectorAdapter.notifyDataSetChanged() + }, onError = { + binding.root.postDelayed({ + hideWaitingDialog() + toast("上传失败,请稍后重试") + }, 1000) + }, onComplete = { + binding.root.postDelayed({ + hideWaitingDialog() + toast("上传成功") + }, 1000) + }).processUploads() + } } @SuppressLint("NotifyDataSetChanged") diff --git a/app/src/main/java/com/shuwei/dish/match/utils/ImageUploader.kt b/app/src/main/java/com/shuwei/dish/match/utils/ImageUploader.kt new file mode 100644 index 0000000..d06d21d --- /dev/null +++ b/app/src/main/java/com/shuwei/dish/match/utils/ImageUploader.kt @@ -0,0 +1,38 @@ +package com.shuwei.dish.match.utils + +import com.shuwei.dish.match.entity.FoodCollectionBean +import java.util.concurrent.atomic.AtomicInteger + +class ImageUploader( + private val totalList: List, + private val uploadImage: suspend (List) -> List?, + private val onProgress: suspend (Int, List, List) -> Unit, + private val onError: (List) -> Unit, + private val onComplete: () -> Unit +) { + companion object { + private const val BATCH_SIZE = 3 + } + private val uploadedCount = AtomicInteger(0) + + suspend fun processUploads() { + val batches = totalList.filter { it.imageVector!=null }.chunked(BATCH_SIZE) + + for (batch in batches) { + val idList = uploadImage(batch) + if (idList.isNullOrEmpty()) { + //println("上传失败,终止流程") + onError(batch) + return + } + val fileCount = batch.count { it.imageFile!=null } + uploadedCount.addAndGet(fileCount) + onProgress(uploadedCount.get(), batch, idList) + //println("已上传 ${uploadedCount.get()}/$totalImages") + } + + onComplete() + //println("流程完成,总计上传 ${uploadedCount.get()} 张图片") + } + +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_delete_green.xml b/app/src/main/res/drawable/ic_delete_green.xml index b1c7b31..ce74e7a 100644 --- a/app/src/main/res/drawable/ic_delete_green.xml +++ b/app/src/main/res/drawable/ic_delete_green.xml @@ -1,12 +1,15 @@ - - + - + - + diff --git a/app/src/main/res/layout/list_item_vector_collection.xml b/app/src/main/res/layout/list_item_vector_collection.xml index 8142089..eabd8f9 100644 --- a/app/src/main/res/layout/list_item_vector_collection.xml +++ b/app/src/main/res/layout/list_item_vector_collection.xml @@ -1,10 +1,12 @@ + android:background="@drawable/bg_gray2" + tools:ignore="ContentDescription"> + android:src="@drawable/ic_finish" + app:tint="@color/dish_green" /> \ No newline at end of file