feat(network): 新增菜品向量采集数据上传接口及调用链路

- ApiService/RemoteRepository/NetViewModel 新增 uploadFoodVectorData 三层实现,新增 uploadFoodVectorDataBatch 供分批循环调用直接 await 结果
- VectorCollectionFragment 启用 upload() 链路:调用 uploadFoodVectorDataBatch 分批上传,去掉 runBlocking/runOnUiThread,统一在协程主线程更新 UI
- ImageUploader 修正包路径并将 onProgress 改为 suspend lambda
- Food 实体新增 version 字段,同步 ObjectBox model
- SeasoningSelectDialog 切换为真实接口,FoodSearchDialog 注释模拟数据
- 调整删除按钮 drawable 为圆形并对列表 ItemView 增加 tint
This commit is contained in:
2026-05-09 13:51:15 +08:00
parent bb1ecbde43
commit bb319ee4ee
11 changed files with 253 additions and 127 deletions
+6 -1
View File
@@ -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": []
@@ -50,27 +50,27 @@ class FoodSearchDialog(
private val binding = DialogFoodSearchBinding.inflate(LayoutInflater.from(activity))
private val list = mutableListOf<GoodsItem>()
.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 拖拽与列表滑动的手势冲突 */
@@ -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<GoodsItem>()
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<GoodsItem>()
// nameList.forEachIndexed { index, name ->
// val padString = "${index + 1}".padStart(2, '0')
// mockData.add(GoodsItem(goodsId = "1000$padString", goodsName = name))
// }
// loadGoodsList(mockData)
}
/**
@@ -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<String, Any>
): ApiResponse<MutableList<FoodRecord>?>
@@ -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<String, Any>
): ApiResponse<MutableList<FoodRecord>?>
@@ -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<String, Any>
): ApiResponse<MutableList<GoodsItem>?>
/**
* 提交采集图片数据
*/
@Multipart
@POST
suspend fun uploadFoodVectorData(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/matching/app/addWeighingCollectionData",
@PartMap params: Map<String, RequestBody>,
@Part foodPics: List<MultipartBody.Part>
): ApiResponse<List<String>?>
}
@@ -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<List<String>?>>(UiState.Idle)
val uploadCollectState: StateFlow<UiState<List<String>?>> = _uploadCollectState.asStateFlow()
/**
* 上传采集菜品信息(触发 StateFlow,适合单次上传场景)
*/
fun uploadFoodVectorData(
fileList: List<File>,
params: MutableMap<String, RequestBody>
) {
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<File>,
params: MutableMap<String, RequestBody>
): UiState<List<String>?> {
val fileParts = fileList.map { file ->
file.asRequestBody("multipart/form-data".toMediaTypeOrNull())
.let { MultipartBody.Part.createFormData("foodPics", file.name, it) }
}
return repository.uploadFoodVectorData(params, fileParts)
}
}
@@ -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<String, RequestBody>,
foodPics: List<MultipartBody.Part>
): UiState<List<String>?> {
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)
}
}
}
@@ -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
@@ -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<FragmentVectorCollectionBinding>() {
@@ -159,13 +165,13 @@ class VectorCollectionFragment : BaseFragment<FragmentVectorCollectionBinding>()
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<FragmentVectorCollectionBinding>()
}
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<FragmentVectorCollectionBinding>()
searchFood()
}
@SuppressLint("NotifyDataSetChanged")
private fun upload() {
// lifecycleScope.launch {
// val totalFileCount = foodCollectionList.count { it.imageFile != null }
// showWaitingDialog2("图片上传中0/$totalFileCount")
// val params = HashMap<String, RequestBody>()
// //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<String, RequestBody>()
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")
@@ -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<FoodCollectionBean>,
private val uploadImage: suspend (List<FoodCollectionBean>) -> List<String>?,
private val onProgress: suspend (Int, List<FoodCollectionBean>, List<String>) -> Unit,
private val onError: (List<FoodCollectionBean>) -> 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()} 张图片")
}
}
@@ -1,12 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 绿色圆角背景 + 白色垃圾桶图标,替代红色的 ic_delete_red.png -->
<?xml version="1.0" encoding="utf-8"?><!-- 绿色圆角背景 + 白色垃圾桶图标,替代红色的 ic_delete_red.png -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 绿色圆角背景 -->
<item>
<shape android:shape="rectangle">
<shape android:shape="oval">
<solid android:color="#00BC71" />
<corners android:radius="10dp" />
<padding
android:bottom="5dp"
android:left="5dp"
android:right="5dp"
android:top="5dp" />
</shape>
</item>
@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="212dp"
android:layout_height="159dp"
android:layout_margin="16dp"
android:background="@drawable/bg_gray2">
android:background="@drawable/bg_gray2"
tools:ignore="ContentDescription">
<ImageView
android:id="@+id/imageView"
@@ -27,5 +29,6 @@
android:layout_height="36dp"
android:layout_gravity="end|bottom"
android:padding="5dp"
android:src="@drawable/ic_finish" />
android:src="@drawable/ic_finish"
app:tint="@color/dish_green" />
</FrameLayout>