Author SHA1 Message Date
lvmeng c76164fa66 feat(inbound): 添加规格下拉选择功能
- 新增 SpecOption 数据模型定义
- 在 GlobalData 中添加 specOptionList 全局变量
- 实现 InboundApiService 中的 getSpecOptions 接口
- 添加 InboundRepository 中的 getSpecOptions 方法
- 在 StoreViewModel 中添加 specOptions 相关状态管理
- 在 StoreActivity 预加载规格选项数据
- 将入库规格字段从文本框改为下拉选择框
- 修复代码中的空字符串初始化问题
2026-06-08 14:38:03 +08:00
mazengfei 74fcd65f1b fix(inbound): 修正入库时间处理逻辑并添加保质期时间戳
- 提取入库时间变量避免重复计算
- 为保质期字段添加时间部分,格式化为日期+时间
- 确保时间格式与系统ISO标准保持一致
- 优化参数传递过程中的数据处理流程
2026-06-04 18:09:54 +08:00
mazengfei 0dfe84898b chore(app): 更新应用名称
- 将应用名称从"入库客户端"更改为"入库新"
2026-06-04 11:20:26 +08:00
mazengfei 5284cebf5e 修改包名 2026-06-03 18:09:59 +08:00
lvmeng 15e1f87913 refactor(network): 重构网络请求配置以支持动态环境切换
- 将布局文件中的 Button 替换为 AppCompatButton 组件
- 更新环境切换对话框中的 URL 映射关系
- 添加 LOCAL_BASE_URL 和修改 TEST_BASE_URL 的常量定义
- 启用并实现环境切换功能的时间点击事件处理
- 在 API 接口定义中使用 @Url 注解实现动态 URL 设置
- 创建 DynamicBaseUrlInterceptor 拦截器实现实时域名替换
- 更新依赖注入模块以使用新的拦截器和全局基础 URL
- 修改仓库层调用以使用具名参数传递
2026-06-03 16:48:50 +08:00
lvmeng ee84189fdf refactor(form): 重构表单字段模型和映射逻辑
- 移除 FormField 中的 submitValueId 和 valueId 字段
- 在 DictType 中添加操作符函数支持字段名取值
- 简化下拉框选择时的 extraValues 映射逻辑
- 更新日期选择器只保存显示格式值
- 调整表单提交时的参数构建逻辑
- 为入库时间和操作时间字段移除 ISO 格式提交配置
- 添加 materId 参数到入库提交数据中
2026-06-02 17:17:06 +08:00
lvmeng 285238ed9c fix(inbound): 修复入库商品存储对话框返回逻辑
- 移除商品为空时自动关闭活动页面的逻辑
- 防止在对话框取消时意外结束父页面
- 确保页面导航的一致性体验
2026-05-27 15:32:37 +08:00
lvmeng aa24059caa feat(inbound): 新增入库秤毛菜/净菜入库功能完整实现
- StoreActivity 进入时统一弹出 InboundGoodsSearchDialog,关闭后退出页面
- InboundGoodsStoreDialog 支持 goods 可空,适配无溯源码直接入库场景
- FormField 新增 extraApiKeys/extraValues 支持下拉多字段提交(供应商id+type、区域id+name)
- DictType 新增 type 字段,供应商选项携带类型信息
- FormFieldAdapter 下拉选中时按 extraApiKeys 映射写入 extraValues
- InboundApiService 使用 @JvmSuppressWildcards 修复 Retrofit Map 泛型通配符异常
- BaseActivity 改为继承 AppCompatActivity,主题切换为 MaterialComponents
- 日期格式统一为 yyyy-MM-dd HH:mm:ss(去除 ISO 8601 的 T 分隔符)
- 搜索列表选中逻辑修复,改用 notifyDataSetChanged 触发刷新
2026-05-27 15:24:37 +08:00
lvmeng e63078dfd0 fix(inbound): 修复API响应码类型不匹配问题
- 将InboundApiResponse中的code字段从Int类型改为String?类型
- 更新isSuccess方法判断逻辑,由code == 200改为code == "00000"
- 修改错误处理中返回的响应码格式,统一使用字符串"-1"
2026-05-27 09:23:07 +08:00
lvmeng 8b0f850d27 feat(activity): StoreActivity 进入时直接弹出入库弹窗,关闭后退出页面
- InboundGoodsStoreDialog.goods 改为可空类型,支持毛菜无需预搜索直接录入
- StoreActivity 根据 storeType 自动弹出对应弹窗(毛菜→录入弹窗,净菜→检索弹窗)
- GoodsListActivity.recognizeFood 标记为 open,StoreActivity override 为空以关闭识别
- 修复相机预览被 cvImageBg 白色遮罩遮挡的问题
2026-05-27 09:21:39 +08:00
lvmeng 080f505c5d refactor(inbound): 将净材相关功能调整为净菜入库功能
- 将界面中的"净材"文本全部替换为"净菜"
- 更新按钮ID命名从btnOrderPurchase改为btnCleanDishStore
- 更新毛菜按钮ID从btnOrderSelfProcurement改为btnRawDishStore
- 修改入库弹窗类注释说明,将净材改为净菜
- 更新storeType参数说明,将净材改为净菜
- 重构动态表单字段配置,统一字段提示文本为"请输入/请选择"格式
- 将净菜类型字段设置为固定类型并添加规格下拉选项
- 更新RecyclerView设置方式,优化代码结构
- 调整标题显示逻辑,根据入库类型动态显示净菜或毛菜入库
- 优化相机预览布局,隐藏图片背景视图
- 更新StoreActivity中关于净菜类型的注释说明
2026-05-27 09:08:18 +08:00
lvmeng 37289d43f3 fix(ui): 将表单布局从 FlexboxLayoutManager 改为 GridLayoutManager 两列,溯源码改为必填 2026-05-26 21:34:38 +08:00
lvmengandClaude Sonnet 4.6 fabc4d780a feat(inbound): 新增入库秤毛菜/净材入库功能
- 新增入库专用网络层(InboundApiService、InboundRepository、InboundNetworkModule、InboundInterceptor)
- 新增入库相关数据模型(IngredientByTrace、SupplierOption、ZoneOption、GoodsSearchModel、CleanInboundParam、RawInboundParam、InboundApiResponse)
- 新增 FormField 动态表单模型(FieldType 枚举、apiKey 字段映射、submitValueId 控制提交值)
- 新增 FormFieldAdapter,基于 FlexboxLayoutManager 实现两列自动换行表单,支持五种字段类型
- 新增 InboundGoodsSearchDialog(溯源码检索食材)和 InboundGoodsStoreDialog(入库录入,表单转 Map 提交)
- 新增 StoreViewModel,提交接口入参改为 Map<String, Any>
- 重构 StoreActivity:注入 StoreViewModel,onResume 预加载供应商/区域列表到 GlobalData
- 修改 GoodsListActivity:抽取 onGoodsSearchRequested() 供子类重写
- 修改 GoodsRecognizeDialog:StoreActivity 场景下打开 InboundGoodsStoreDialog
- GlobalData 新增 inboundSupplierList / inboundZoneList 全局缓存
- build.gradle.kts 新增 flexbox:3.0.0 依赖

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:18:09 +08:00
lvmengandClaude Sonnet 4.6 ac9494a0f3 feat: 新增首页净材/毛菜入口按钮及StoreActivity称重识别页面
- 首页新增净材、毛菜两个渐变色按钮(蓝/橙),隐藏原ImageView入口
- 新增StoreActivity,继承GoodsListActivity,复用秤监听、相机、识别弹窗能力
- GoodsListActivity.replaceIncludeContent()新增else分支,非采购/自采页面隐藏flContainer
- BaseDialog设备列表提取为常量,优化小屏幕判断逻辑
- 更新本地测试环境地址及环境切换弹窗文案

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 18:54:30 +08:00
lvmeng fa58304ca1 refactor(network): 移除设备认证和登录相关API接口
- 注释掉设备获取token的API接口和服务端点
- 移除设备信息查询相关的API接口和服务端点
- 删除登录功能的API接口和服务端点
- 移除部分收货功能的API接口和服务端点
- 注释掉添加物品采购单位的API接口和服务端点
- 相应移除ViewModel中的设备认证和登录逻辑
- 删除Repository中对应的网络请求方法
2026-05-26 14:10:00 +08:00
mazengfei 1092a7c43d 接口报错显示具体信息 2026-05-25 10:33:33 +08:00
mazengfei 0c0aba2aeb 接口报错显示具体信息 2026-05-20 14:21:03 +08:00
mazengfei e81df4f55c 优化 2026-05-13 09:55:11 +08:00
lvmeng 07e0a1996f 入库单数据提交入库新增采购单号字段purCode 2026-05-11 18:20:35 +08:00
lvmeng 79f58a36e3 fix(activity): 修复拿走物品后识别弹窗未自动关闭的问题
- 将关闭阈值从 weight < 10 调整为 weight < 20,覆盖轻微零点漂移
- 将 size == 1 判断改为 none { isShowing && it != goodsRecognizeDialog },避免 DialogManager 中存在幽灵条目时导致关闭逻辑失效
- GoodsStoreDialog:无论 funcType 值,均拍照上传图片以增加样本数据
- BaseViewModel:recognizeGoodsList 增加异常捕获,异常时回调空列表
2026-05-09 15:44:05 +08:00
lvmeng 7c5278bc88 Merge remote-tracking branch 'origin/main_lvmeng_0131' into main_lvmeng_0131
# Conflicts:
#	app/src/main/assets/best_embedding_model_mobile.pt
#	app/src/main/assets/data/class_info.json
#	app/src/main/assets/data/embeddings.json
#	app/src/main/assets/data/labels.json
2026-04-28 15:42:47 +08:00
lvmeng c995daf48d 优化已采集页面数据统计、更新初始化页面加载动画资源、替换新识别模型 2026-04-28 15:29:53 +08:00
mazengfeiandClaude Sonnet 4.6 d95a00b8a9 feat(global): 更新UAT端口并在切换环境时自动重置本地数据库及识别模型
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:03:45 +08:00
mazengfei 13f893cd0c 生产域名配置 2026-03-31 19:46:39 +08:00
lvmengandClaude Sonnet 4.6 8eae8c2b3c feat(global): 新增多环境切换弹窗及启动时自动读取已保存环境配置
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 09:25:16 +08:00
lvmeng 532f840a78 feat(model): 添加移动端嵌入模型文件
- 新增 best_embedding_model_mobile.pt 模型文件
- 该模型用于移动端设备的嵌入计算功能
- 文件大小约为 431 行二进制数据
2026-03-26 09:15:09 +08:00
mazengfei 21b9b03014 测试配置 2026-03-23 10:20:17 +08:00
mazengfei 4047cd67d8 Merge remote-tracking branch 'origin/main_lvmeng_0131' into main_lvmeng_0131 2026-03-23 10:12:23 +08:00
mazengfei 441d40b584 增加异常日志记录,接口报错提示,app启动时有网络不再进行倒计时处理 2026-03-23 10:12:11 +08:00
lvmeng 915e20cd23 Merge remote-tracking branch 'origin/main_lvmeng_0131' into main_lvmeng_0131 2026-03-20 15:58:40 +08:00
lvmeng d0fa46a8c8 增加测试环境配置 2026-03-20 15:58:30 +08:00
mazengfei 8a83de3238 优化 2026-03-16 10:33:09 +08:00
lvmeng 4b241ba81b feat(activity): 新增 InitActivity 初始化页面,实现网络检测和倒计时功能 2026-03-13 17:36:46 +08:00
lvmeng 75306af483 fix: 修正 Toast 扩展方法导入和更新下载地址
- 恢复 ToastUtils.kt 为原始注释状态(Compose 版本)
- 修正 FunActivity 导入,使用 Common.kt 中已定义的 Context.toast 扩展方法
- 更新下载地址为真实的资源包地址
2026-03-13 14:16:16 +08:00
lvmeng c697b4aa65 feat(activity): 添加 Zip 文件下载解压工具和趣味页面
- 新增 ZipDownloadUtils:支持大文件下载、进度回调、断点续传
- 新增 ZipExtractUtils:支持 Zip 文件解压、进度回调、自动创建目录
- 新增 ModelResourceManager:高层资源管理器,协调下载和解压流程
- 新增 FunActivity:趣味测试页面,包含 3 个按钮
  - 按钮 1:下载 Zip 文件
  - 按钮 2:解压 Zip 文件
  - 按钮 3:读取 JSON 并保存到 ObjectBox 数据库
- 新增 activity_fun.xml:趣味页面布局
- 新增 ZipToolsUsageExample:工具使用示例文档
- 改进 ToastUtils:改为 Context 扩展方法 (toast/toastLong)
- 暂时注释 HomeActivity 中 tvTime 的点击事件
2026-03-13 11:27:53 +08:00
lvmeng baee019444 ObjectBox数据库操作增加线程资源清理 2026-03-12 17:25:23 +08:00
lvmeng e50d6ceb60 入库列表页增加采集入库,方法采集完成直接入库和验证识别效果 2026-03-12 14:39:41 +08:00
lvmengandClaude Sonnet 4.6 7563209cf1 refactor(activity): 优化 FoodCollectionActivity 性能与稳定性
- 修复 runBlocking 阻塞主线程,改为 lifecycleScope.launch 协程异步执行
- 耗时操作(I/O、图像裁剪、TFLite 推理、ObjectBox 读写)全部切换至 Dispatchers.IO
- 修复 bitmap 内存泄漏:用 try/finally 保证所有退出路径均回收
- 修复 uriToBitmap 返回 null 时 loading 转圈永不消失的 Bug
- 修复 DbException 块、index==-1 分支缺少 hideWaitingDialog() 的 Bug
- 移除重复 ReceiptViewModel,统一使用单例;移除空 initBox() 函数
- 新增 searchJob 取消上一次未完成搜索,修复 clearData() 遗漏取消的 Bug
- 提取 createWaitingDialog() 消除 dialog 创建代码重复
- 修复 clearCoilCache() 使用新实例无法清缓存,改用 context.imageLoader 单例
- upload() 两处强制解包 !! 改为安全调用,避免 NPE 崩溃
- 清理无用注释、废弃代码块、冗余注解及无用 import

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 18:04:21 +08:00
mazengfei 05015a7a18 优化采购的数据的重量清除问题 2026-03-10 17:24:25 +08:00
lvmeng 1cc2358d72 优化向量查询,替换新模型 2026-03-10 13:33:31 +08:00
lvmeng 1206ecd575 优化向量查询结果数据 2026-03-10 13:00:45 +08:00
92 changed files with 5647 additions and 937 deletions
+52
View File
@@ -0,0 +1,52 @@
请按以下步骤为当前改动生成 Git 提交信息并执行提交:
## 步骤
1. 并行运行以下命令,了解当前改动:
- `git diff --staged` 查看已暂存的变更内容
- `git diff` 查看未暂存的变更内容
- `git status` 查看变更文件列表
2. 若存在未暂存的文件,询问用户是否需要一起提交。
3. 根据变更内容,按以下规范生成提交信息:
**格式:** `<类型>(<范围>): <简短描述>`
| 类型 | 适用场景 |
|------|---------|
| feat | 新增功能 |
| fix | 修复 bug |
| refactor | 代码重构(不改变行为) |
| chore | 构建/配置/依赖调整 |
| docs | 文档更新 |
| perf | 性能优化 |
**范围参考本项目模块:**
- `face` — 人脸识别相关(LoginByFaceActivity、RecognizeViewModel
- `serial` — 串口/开柜相关(SerialApi、PlateUtils
- `network` — 网络层(ApiService、RemoteRepository、RequestInterceptor
- `activity` — Activity 界面逻辑
- `fragment` — Fragment 界面逻辑
- `viewmodel` — ViewModel 层
- `ui` — 布局/样式调整
- `global` — GlobalData、MyApp 等全局配置
**示例:**
- `feat(face): 新增识别失败后自动采集人脸流程`
- `fix(serial): 修复开柜编号解析为负数导致串口指令异常`
- `refactor(network): 统一 API 地址使用 GlobalData.appBaseUrl 前缀`
4. 向用户展示拟提交的信息,确认后执行:
```
git add <相关文件>
git commit -m "<提交信息>"
```
5. 提交完成后运行 `git log --oneline -3` 确认结果。
## 约束
- 提交信息必须使用**简体中文**描述
- 不要使用 `--no-verify` 跳过钩子
- 不要在未经用户确认的情况下执行 `git push`
+96
View File
@@ -0,0 +1,96 @@
在本项目中新增一个后端 API 接口,需要同时修改三层:ApiService → RemoteRepository → ViewModel。
## 步骤
### 1. 收集信息
询问用户以下信息(如已在指令中提供则跳过):
- 接口名称/功能描述
- 请求方式:GET / POST / POST + FormUrlEncoded / Multipart
- 接口路径(相对路径,如 `/sideboard/app/xxx`
- 请求参数字段和类型
- 响应数据类型(已有类 or 需新建)
- 归属哪个 ViewModel`SettingViewModel` / `UserViewModel` / `DeviceViewModel`
### 2. 创建数据模型(如需要)
**请求参数类:** `app/src/main/java/com/sw/platecabinet/model/request/<XxxParam>.kt`
**响应数据类:** `app/src/main/java/com/sw/platecabinet/model/response/<XxxModel>.kt`
数据类规范:
```kotlin
/**
* <描述>
*/
data class XxxParam(
val fieldName: String // 字段说明
)
```
### 3. 在 ApiService 中添加接口方法
文件:`app/src/main/java/com/sw/platecabinet/network/api/ApiService.kt`
**关键规则:**
- 必须使用 `@Url` 注解,URL 格式为:`"${GlobalData.appBaseUrl}/terminal/neglect/<路径>"`
- 不要使用相对路径(baseUrl 拦截器不适用于本项目)
- 方法必须为 `suspend` 函数,返回 `ApiResponse<T>`
```kotlin
/**
* <接口功能描述>
*/
@POST // 或 @GET / @FormUrlEncoded @POST / @Multipart @POST
suspend fun <methodName>(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/<路径>",
@Body param: XxxParam // 或 @Query / @Field / @Part
): ApiResponse<XxxModel?>
```
### 4. 在 RemoteRepository 中添加调用方法
文件:`app/src/main/java/com/sw/platecabinet/repository/RemoteRepository.kt`
```kotlin
/**
* <功能描述>
*/
suspend fun <methodName>(/* 参数 */): ApiResponse<XxxModel?> {
return safeApiCall {
apiService.<methodName>(/* 参数传递 */)
}
}
```
约束:
- 必须用 `safeApiCall { }` 包裹,不要裸调用 apiService
- 参数直接透传,不在 Repository 层做业务逻辑
### 5. 在 ViewModel 中添加调用逻辑
文件:`app/src/main/java/com/sw/platecabinet/viewmodel/<XxxViewModel>.kt`
```kotlin
/**
* <功能描述>
* @param callback 成功时回调,参数为响应数据
*/
fun <methodName>(/* 业务参数 */, callback: (XxxModel?) -> Unit) {
launch { // 不需要 loading 时用 launch;需要 loading 时用 launchWithLoading
val response = repository.<methodName>(/* 参数 */)
if (parseResponse(response)) {
runOnMainThread { callback(response.result) }
}
}
}
```
**注意:** `launch` / `launchWithLoading` 在协程中执行,回调若需更新 UI 需切换到主线程(使用 `withContext(Dispatchers.Main)` 或在 Activity 中用 `runOnUiThread`)。
### 6. 在 Activity/Fragment 中调用
```kotlin
viewModel.<methodName>(/* 参数 */) { result ->
// 处理结果
}
```
## 注意事项
- 所有注释使用简体中文
- 向用户展示将要修改的文件清单(含 Diff 预览),等待确认后再写文件
- `RequestInterceptor` 已自动注入认证 header,无需在方法中手动添加
- 响应体统一为 `ApiResponse<T>`,通过 `response.isSuccess()``response.result` 取数据
+5 -2
View File
@@ -26,7 +26,7 @@ android {
compileSdk = 35
defaultConfig {
applicationId = "com.sw.inbound"
applicationId = "com.sw.inbound.new"
minSdk = 24
targetSdk = 35
versionCode = 1
@@ -147,7 +147,7 @@ dependencies {
implementation("io.github.scwang90:refresh-layout-kernel:3.0.0-alpha")
implementation("io.github.scwang90:refresh-header-classics:3.0.0-alpha")
val objectboxVersion = "5.0.1"
val objectboxVersion:String by rootProject.extra
if (isDebug) {
debugImplementation("io.objectbox:objectbox-android-objectbrowser:$objectboxVersion")
} else {
@@ -156,6 +156,9 @@ dependencies {
implementation("org.greenrobot:eventbus:3.3.1")
// flexbox 布局,用于动态表单两列排列
implementation("com.google.android.flexbox:flexbox:3.0.0")
}
apply(plugin = "io.objectbox")
+30 -27
View File
@@ -13,6 +13,7 @@
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".MyApp"
@@ -40,6 +41,35 @@
<activity
android:name=".activity.HomeActivity"
android:configChanges="orientation|screenSize"
android:exported="false"
android:label="@string/app_name"
android:screenOrientation="landscape"
android:theme="@style/Theme.Inbound" />
<activity android:name="com.sw.inbound.activity.ReceiptActivity"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustPan" />
<activity android:name="com.sw.inbound.activity.SelfProcurementActivity"
android:windowSoftInputMode="adjustPan" />
<activity android:name="com.sw.inbound.activity.FoodCollectionActivity"
android:windowSoftInputMode="adjustPan" />
<activity android:name="com.sw.inbound.activity.LocalImagePreviewActivity" />
<activity android:name="com.sw.inbound.activity.PurchaseOrderActivity" />
<activity
android:name="com.sw.inbound.activity.StoreActivity"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustPan" />
<activity android:name="com.sw.inbound.activity.FunActivity"
android:screenOrientation="landscape" />
<!-- 首页 ActivityXML 版本,对应 HomeScreen.kt Compose 版本) -->
<activity
android:name="com.sw.inbound.MainActivity"
android:screenOrientation="landscape" />
<!-- 初始化页面 Activity -->
<activity
android:name=".activity.InitActivity"
android:configChanges="orientation|screenSize"
android:exported="true"
android:label="@string/app_name"
android:screenOrientation="landscape"
@@ -50,33 +80,6 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="com.sw.inbound.activity.ReceiptActivity"
android:screenOrientation="landscape"
android:windowSoftInputMode="adjustPan" />
<activity android:name="com.sw.inbound.activity.SelfProcurementActivity"
android:windowSoftInputMode="adjustPan" />
<activity android:name="com.sw.inbound.activity.FoodCollectionActivity"
android:windowSoftInputMode="adjustPan" />
<activity android:name="com.sw.inbound.activity.LocalImagePreviewActivity" />
<activity android:name="com.sw.inbound.activity.PurchaseOrderActivity" />
<!-- 首页 ActivityXML 版本,对应 HomeScreen.kt Compose 版本) -->
<activity
android:name="com.sw.inbound.MainActivity"
android:screenOrientation="landscape" />
<!-- <activity-->
<!-- android:name=".InitActivity"-->
<!-- android:configChanges="orientation|screenSize"-->
<!-- android:exported="true"-->
<!-- android:label="@string/app_name"-->
<!-- android:screenOrientation="landscape"-->
<!-- android:theme="@style/Theme.Inbound">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.intent.action.MAIN" />-->
<!-- <category android:name="android.intent.category.LAUNCHER" />-->
<!-- </intent-filter>-->
<!-- </activity>-->
</application>
</manifest>
Binary file not shown.
+393 -189
View File
@@ -2,268 +2,472 @@
"class_names": [
"三黄鸡",
"上海青WP2026020200041",
"丝瓜WP2026032300114",
"乳瓜WP2026032000107",
"冬瓜WP2026032300115",
"冰糖",
"冰草WP2026020900054",
"刀豆",
"北豆腐WP2025111900007",
"南瓜[倭瓜,番瓜]",
"味精WP2026013000034",
"圆生菜WP2026020900053",
"圆白菜[卷心菜,包菜]WP2025111000063",
"基围虾WP2025110700019",
"多宝鱼WP2025112600011",
"大白菜(代表值)WP2026022800638",
"大葱WP2026013000025",
"大白菜(代表值)WP2026031700085",
"大葱WP2026013000026",
"大蒜(去皮)WP2026020200011",
"大蒜(带皮)WP2026020300046",
"奶白菜",
"姜(子姜)[嫩姜]",
"姜[黄姜]WP2026022800956",
"娃娃菜WP2026020200005",
"小白菜WP2026022800031",
"小米辣椒WP2026013000027",
"小葱WP2026013000029",
"小鸡腿(带骨)",
"山药(带皮)WP2026020200008",
"巨人笋",
"平菇[糙皮侧耳,青蘑]WP2026031700086",
"广东菜心WP2025120100014",
"新世纪哈密瓜",
"明虾",
"木耳(干)[黑木耳,云耳]WP2026022801182",
"杏鲍菇WP2026022700030",
"松花蛋",
"松茸菌WP2025120100016",
"槐花[洋槐花,豆槐花]",
"橙",
"沙葱",
"洋葱(鲜)[葱头]WP2026022800612",
"油麦菜WP2026031200077",
"海带(浸)[江白菜,昆布]WP2026022800035",
"淀粉(马铃薯)",
"湿米线过桥米线",
"火龙果(带皮)",
"炒饼丝",
"牛肉(腹部肉)[牛腩]",
"牛肚",
"牛腩WP2025110700043",
"猪五花肉WP2026020300048",
"猪腿肉WP2025110700040",
"猪小排(带骨)",
"猪排(带骨)WP2026031200078",
"猪肉(瘦)",
"猪肉(腿)WP2026022800033",
"猪肉(里脊)WP2026021000025",
"猪肝(鲜)WP2026032000105",
"猪肾(fat8g[猪腰子]",
"玉米WP2025120100017",
"玉米糁(黄)WP2026022800038",
"甜椒[灯笼椒,柿子椒]WP2026020500018",
"生菜(叶用莴苣)WP2026020500020",
"生蚝",
"番茄[西红柿]WP2026021000028",
"白吉饼WP2026013000024",
"白玉菇WP2026031100072",
"白菜薹[菜薹,菜心]WP2026031100068",
"白萝卜WP2026031200079",
"石斑鱼(整条鱼)",
"笋",
"粉丝",
"紫甘蓝",
"素鸡WP2026031700096",
"红心萝卜[心里美]WP2026022800368",
"红提子WP2025111900006",
"红椒WP2026030300055",
"红洋葱WP2026030600054",
"红美人椒WP2026020300049",
"红薯",
"红薯粉条WP2026031700091",
"红薯粉条(袋)",
"结球甘蓝(紫)[圆白菜]WP2026020500017",
"结球甘蓝(绿)[圆白菜]WP2026032300116",
"绿豆芽WP2026031100073",
"羊排WP2025120300021",
"羊血WP2026013000038",
"胡萝卜WP2025110600008",
"腰果(熟)",
"芋头",
"芥蓝[甘蓝菜,盖蓝菜]",
"芦笋(绿)[石刁柏,龙须菜]",
"花卷",
"芹菜WP2025111000065",
"花椒(粉)",
"花生仁(生)WP2026032000102",
"花螺",
"芹菜(茎)[旱芹,药芹]WP2026022800032",
"苦菊",
"苹果(代表值)WP2026032300117",
"茄子(紫皮,长)WP2025110700024",
"茼蒿WP2026020200045",
"草鱼",
"荷兰豆WP2026032000104",
"莲藕(带皮)",
"莴笋(鲜)[莴苣]WP2026020200009",
"菜花(白色)[花椰菜]",
"菜花(白色)[花椰菜]WP2026030600057",
"菠菜(鲜)[赤根菜]WP2026022800700",
"蒜WP2025111400003",
"菠萝[凤梨,地菠萝]",
"蒜苗WP2026013000028",
"蒜薹(圆)",
"蒸鱼豉油WP2026013000032",
"蕹菜[空心菜,藤藤菜]",
"薏米[薏仁米,苡米]WP2026022800037",
"西兰花[绿菜花]WP2025110700029",
"西葫芦",
"豆腐皮WP2025111000059",
"豆角",
"虫草花(干)WP2026032000108",
"蛏子",
"西兰花[绿菜花]WP2026031100071",
"西瓜(代表值)",
"西芹[西洋芹菜,美芹]WP2026031100069",
"西葫芦WP2026021000027",
"豆腐干(代表值)",
"豆腐皮WP2026031700097",
"豆角豇豆WP2026021000026",
"赤小豆[小豆,红小豆](干)WP2026032300118",
"辣椒(红,小)WP2025111000066",
"辣椒(青,尖)WP2026022800504",
"辣椒(青,尖)WP2026030600056",
"辣椒段(干)",
"醪糟",
"金针菇WP2025111900010",
"金针菇[(鲜)[智力菇]WP2026030600051",
"青线椒WP2026013000031",
"青美人椒辣椒(青,尖)",
"青花椒鲜花椒",
"面条(生,代表值)WP2026030600055",
"面条(生,炒细面",
"韭菜WP2026020200044",
"韭薹",
"韭黄",
"馒头(代表值)WP2026030600056",
"香椿[香椿芽]WP2026022801086",
"香菇",
"香菇(鲜)[香蕈,冬菇]WP2026030600050",
"香菜WP2026013000030",
"马铃薯[土豆、洋芋]WP2026012900001",
"鱿鱼(鲜,中国枪乌贼)[枪乌贼]",
"鲍鱼(带壳)",
"鲑鱼[大马哈鱼,三文鱼]",
"鲜猪精排",
"鸡全腿(带骨)",
"鸡翅(带骨)",
"鸡胸脯肉WP2026022802698",
"鸡腿肉WP2025110700048",
"鸡腿(带骨)",
"鸡蛋(代表值)WP2026013000039",
"麦芹",
"鸡蛋WP2026013000039",
"鸭(均值)",
"鸭蛋黄",
"黄瓜(鲜)[胡瓜]WP2025110700031",
"黄豆",
"黄豆芽WP2026020200042",
"黑米WP2026031200081",
"黑袋子",
"黑鱼(整条鱼)WP2025110600012"
],
"class_to_idx": {
"三黄鸡": 0,
"上海青WP2026020200041": 1,
"冰草WP2026020900054": 2,
"刀豆": 3,
"北豆腐WP2025111900007": 4,
"味精WP2026013000034": 5,
"圆生菜WP2026020900053": 6,
"圆白菜[卷心菜,包菜]WP2025111000063": 7,
"基围虾WP2025110700019": 8,
"多宝鱼WP2025112600011": 9,
"大白菜(代表值)WP2026022800638": 10,
"大葱WP2026013000025": 11,
"姜[黄姜]WP2026022800956": 12,
"娃娃菜WP2026020200005": 13,
"白菜WP2026022800031": 14,
"小米辣椒WP2026013000027": 15,
"小葱WP2026013000029": 16,
"山药(带皮)WP2026020200008": 17,
"巨人笋": 18,
"广东菜心WP2025120100014": 19,
"木耳(干)[黑木耳,云耳]WP2026022801182": 20,
"杏鲍菇WP2026022700030": 21,
"松花蛋": 22,
"松茸菌WP2025120100016": 23,
"沙葱": 24,
"洋葱(鲜)[葱头]WP2026022800612": 25,
"海带(浸)[江白菜,昆布]WP2026022800035": 26,
"牛腩WP2025110700043": 27,
"猪五花肉WP2026020300048": 28,
"猪腿肉WP2025110700040": 29,
"玉米WP2025120100017": 30,
"玉米糁(黄)WP2026022800038": 31,
"番茄[西红柿]WP2026021000028": 32,
"白吉饼WP2026013000024": 33,
"": 34,
"粉丝": 35,
"紫甘蓝": 36,
"红心萝卜[心里美]WP2026022800368": 37,
"红提子WP2025111900006": 38,
"红椒WP2026030300055": 39,
"红美人椒WP2026020300049": 40,
"红薯": 41,
"红薯粉条(袋)": 42,
"羊排WP2025120300021": 43,
"羊血WP2026013000038": 44,
"胡萝卜WP2025110600008": 45,
"芋头": 46,
"花卷": 47,
"芹菜WP2025111000065": 48,
"苦菊": 49,
"茄子(紫皮,长)WP2025110700024": 50,
"茼蒿WP2026020200045": 51,
"莲藕(带皮)": 52,
"莴笋(鲜)[莴苣]WP2026020200009": 53,
"菜花(白色)[花椰菜]": 54,
"菠菜(鲜)[赤根菜]WP2026022800700": 55,
"WP2025111400003": 56,
"蒜苗WP2026013000028": 57,
"蒜薹(圆)": 58,
"蒸鱼豉油WP2026013000032": 59,
"薏米[薏仁米,苡米]WP2026022800037": 60,
"西兰花[绿菜花]WP2025110700029": 61,
"西葫芦": 62,
"豆腐皮WP2025111000059": 63,
"豆角": 64,
"辣椒(红,小)WP2025111000066": 65,
"辣椒(青,尖)WP2026022800504": 66,
"醪糟": 67,
"金针菇WP2025111900010": 68,
"青线椒WP2026013000031": 69,
"韭菜WP2026020200044": 70,
"韭黄": 71,
"香椿[香椿芽]WP2026022801086": 72,
"香菇": 73,
"香菜WP2026013000030": 74,
"马铃薯[土豆、洋芋]WP2026012900001": 75,
"鸡全腿(带骨)": 76,
"鸡翅(带骨": 77,
"鸡腿肉WP2025110700048": 78,
"鸡腿(带骨)": 79,
"鸡蛋(代表值)WP2026013000039": 80,
"麦芹": 81,
"黄瓜(鲜)[胡瓜]WP2025110700031": 82,
"黄豆芽WP2026020200042": 83,
"黑袋子": 84,
"黑鱼(整条鱼)WP2025110600012": 85
"丝瓜WP2026032300114": 2,
"乳瓜WP2026032000107": 3,
"冬瓜WP2026032300115": 4,
"冰糖": 5,
"冰草WP2026020900054": 6,
"刀豆": 7,
"北豆腐WP2025111900007": 8,
"南瓜[倭瓜,番瓜]": 9,
"味精WP2026013000034": 10,
"圆生菜WP2026020900053": 11,
"基围虾WP2025110700019": 12,
"多宝鱼WP2025112600011": 13,
"白菜(代表值)WP2026031700085": 14,
"大葱WP2026013000026": 15,
"大蒜(去皮)WP2026020200011": 16,
"大蒜(带皮)WP2026020300046": 17,
"奶白菜": 18,
"姜(子姜)[嫩姜]": 19,
"姜[黄姜]WP2026022800956": 20,
"娃娃菜WP2026020200005": 21,
"小白菜WP2026022800031": 22,
"小米辣椒WP2026013000027": 23,
"小葱WP2026013000029": 24,
"小鸡腿(带骨)": 25,
"山药(带皮)WP2026020200008": 26,
"巨人笋": 27,
"平菇[糙皮侧耳,青蘑]WP2026031700086": 28,
"广东菜心WP2025120100014": 29,
"新世纪哈密瓜": 30,
"明虾": 31,
"木耳(干)[黑木耳,云耳]WP2026022801182": 32,
"杏鲍菇WP2026022700030": 33,
"松花蛋": 34,
"松茸菌WP2025120100016": 35,
"槐花[洋槐花,豆槐花]": 36,
"": 37,
"沙葱": 38,
"油麦菜WP2026031200077": 39,
"海带(浸)[江白菜,昆布]WP2026022800035": 40,
"淀粉(马铃薯)": 41,
"湿米线过桥米线": 42,
"火龙果(带皮)": 43,
"炒饼丝": 44,
"牛肉(腹部肉)[牛腩]": 45,
"牛肚": 46,
"牛腩WP2025110700043": 47,
"猪五花肉WP2026020300048": 48,
"猪小排(带骨)": 49,
"猪排(带骨)WP2026031200078": 50,
"猪肉(瘦)": 51,
"猪肉(腿)WP2026022800033": 52,
"猪肉(里脊)WP2026021000025": 53,
"猪肝(鲜)WP2026032000105": 54,
"猪肾(fat8g[猪腰子]": 55,
"玉米WP2025120100017": 56,
"玉米糁(黄)WP2026022800038": 57,
"甜椒[灯笼椒,柿子椒]WP2026020500018": 58,
"生菜(叶用莴苣)WP2026020500020": 59,
"生蚝": 60,
"番茄[西红柿]WP2026021000028": 61,
"白吉饼WP2026013000024": 62,
"白玉菇WP2026031100072": 63,
"白菜薹[菜薹,菜心]WP2026031100068": 64,
"白萝卜WP2026031200079": 65,
"石斑鱼(整条鱼)": 66,
"": 67,
"粉丝": 68,
"素鸡WP2026031700096": 69,
"红心萝卜[心里美]WP2026022800368": 70,
"红提子WP2025111900006": 71,
"红椒WP2026030300055": 72,
"红洋葱WP2026030600054": 73,
"红美人椒WP2026020300049": 74,
"红薯": 75,
"红薯粉条WP2026031700091": 76,
"红薯粉条(袋": 77,
"结球甘蓝(紫)[圆白菜]WP2026020500017": 78,
"结球甘蓝(绿)[圆白菜]WP2026032300116": 79,
"绿豆芽WP2026031100073": 80,
"羊排WP2025120300021": 81,
"羊血WP2026013000038": 82,
"胡萝卜WP2025110600008": 83,
"腰果(熟)": 84,
"芋头": 85,
"芥蓝[甘蓝菜,盖蓝菜]": 86,
"芦笋(绿)[石刁柏,龙须菜]": 87,
"花卷": 88,
"花椒(粉)": 89,
"花生仁(生)WP2026032000102": 90,
"花螺": 91,
"芹菜(茎)[旱芹,药芹]WP2026022800032": 92,
"苦菊": 93,
"苹果(代表值)WP2026032300117": 94,
"茄子(紫皮,长)WP2025110700024": 95,
"茼蒿WP2026020200045": 96,
"草鱼": 97,
"荷兰豆WP2026032000104": 98,
"莲藕(带皮)": 99,
"莴笋(鲜)[莴苣]WP2026020200009": 100,
"菜花(白色)[花椰菜]WP2026030600057": 101,
"菠菜(鲜)[赤根菜]WP2026022800700": 102,
"菠萝[凤梨,地菠萝]": 103,
"蒜苗WP2026013000028": 104,
"蒜薹(圆)": 105,
"蒸鱼豉油WP2026013000032": 106,
"蕹菜[空心菜,藤藤菜]": 107,
"薏米[薏仁米,苡米]WP2026022800037": 108,
"虫草花(干)WP2026032000108": 109,
"蛏子": 110,
"西兰花[绿菜花]WP2026031100071": 111,
"西瓜(代表值)": 112,
"西芹[西洋芹菜,美芹]WP2026031100069": 113,
"西葫芦WP2026021000027": 114,
"豆腐干(代表值)": 115,
"豆腐皮WP2026031700097": 116,
"豆角豇豆WP2026021000026": 117,
"赤小豆[小豆,红小豆](干)WP2026032300118": 118,
"辣椒(红,小)WP2025111000066": 119,
"辣椒(青,尖)WP2026030600056": 120,
"辣椒段(干)": 121,
"醪糟": 122,
"金针菇[(鲜)[智力菇]WP2026030600051": 123,
"青线椒WP2026013000031": 124,
"青美人椒辣椒(青,尖)": 125,
"青花椒鲜花椒": 126,
"面条(生,代表值)WP2026030600055": 127,
"面条(生,炒细面": 128,
"韭菜WP2026020200044": 129,
"韭薹": 130,
"韭黄": 131,
"馒头(代表值)WP2026030600056": 132,
"香椿[香椿芽]WP2026022801086": 133,
"香菇(鲜)[香蕈,冬菇]WP2026030600050": 134,
"香菜WP2026013000030": 135,
"马铃薯[土豆、洋芋]WP2026012900001": 136,
"鱿鱼(鲜,中国枪乌贼)[枪乌贼]": 137,
"鲍鱼(带壳)": 138,
"鲑鱼[大马哈鱼,三文鱼]": 139,
"鲜猪精排": 140,
"鸡全腿(带骨)": 141,
"鸡翅(带骨)": 142,
"鸡胸脯肉WP2026022802698": 143,
"鸡腿肉WP2025110700048": 144,
"鸡蛋WP2026013000039": 145,
"鸭(均值)": 146,
"鸭蛋黄": 147,
"黄瓜(鲜)[胡瓜]WP2025110700031": 148,
"黄豆": 149,
"黄豆芽WP2026020200042": 150,
"黑米WP2026031200081": 151,
"黑袋子": 152,
"黑鱼(整条鱼)WP2025110600012": 153
},
"idx_to_class": {
"0": "三黄鸡",
"1": "上海青WP2026020200041",
"2": "冰草WP2026020900054",
"3": "刀豆",
"4": "北豆腐WP2025111900007",
"5": "味精WP2026013000034",
"6": "圆生菜WP2026020900053",
"7": "圆白菜[卷心菜,包菜]WP2025111000063",
"8": "基围虾WP2025110700019",
"9": "多宝鱼WP2025112600011",
"10": "大白菜(代表值)WP2026022800638",
"11": "大葱WP2026013000025",
"12": "姜[黄姜]WP2026022800956",
"13": "娃娃菜WP2026020200005",
"14": "白菜WP2026022800031",
"15": "小米辣椒WP2026013000027",
"16": "小葱WP2026013000029",
"17": "山药(带皮)WP2026020200008",
"18": "巨人笋",
"19": "广东菜心WP2025120100014",
"20": "木耳(干)[黑木耳,云耳]WP2026022801182",
"21": "杏鲍菇WP2026022700030",
"22": "松花蛋",
"23": "松茸菌WP2025120100016",
"24": "沙葱",
"25": "洋葱(鲜)[葱头]WP2026022800612",
"26": "海带(浸)[江白菜,昆布]WP2026022800035",
"27": "牛腩WP2025110700043",
"28": "猪五花肉WP2026020300048",
"29": "猪腿肉WP2025110700040",
"30": "玉米WP2025120100017",
"31": "玉米糁(黄)WP2026022800038",
"32": "番茄[西红柿]WP2026021000028",
"33": "白吉饼WP2026013000024",
"34": "",
"35": "粉丝",
"36": "紫甘蓝",
"37": "红心萝卜[心里美]WP2026022800368",
"38": "红提子WP2025111900006",
"39": "红椒WP2026030300055",
"40": "红美人椒WP2026020300049",
"41": "红薯",
"42": "红薯粉条(袋)",
"43": "羊排WP2025120300021",
"44": "羊血WP2026013000038",
"45": "胡萝卜WP2025110600008",
"46": "芋头",
"47": "花卷",
"48": "芹菜WP2025111000065",
"49": "苦菊",
"50": "茄子(紫皮,长)WP2025110700024",
"51": "茼蒿WP2026020200045",
"52": "莲藕(带皮)",
"53": "莴笋(鲜)[莴苣]WP2026020200009",
"54": "菜花(白色)[花椰菜]",
"55": "菠菜(鲜)[赤根菜]WP2026022800700",
"56": "WP2025111400003",
"57": "蒜苗WP2026013000028",
"58": "蒜薹(圆)",
"59": "蒸鱼豉油WP2026013000032",
"60": "薏米[薏仁米,苡米]WP2026022800037",
"61": "西兰花[绿菜花]WP2025110700029",
"62": "西葫芦",
"63": "豆腐皮WP2025111000059",
"64": "豆角",
"65": "辣椒(红,小)WP2025111000066",
"66": "辣椒(青,尖)WP2026022800504",
"67": "醪糟",
"68": "金针菇WP2025111900010",
"69": "青线椒WP2026013000031",
"70": "韭菜WP2026020200044",
"71": "韭黄",
"72": "香椿[香椿芽]WP2026022801086",
"73": "香菇",
"74": "香菜WP2026013000030",
"75": "马铃薯[土豆、洋芋]WP2026012900001",
"76": "鸡全腿(带骨)",
"77": "鸡翅(带骨",
"78": "鸡腿肉WP2025110700048",
"79": "鸡腿(带骨)",
"80": "鸡蛋(代表值)WP2026013000039",
"81": "麦芹",
"82": "黄瓜(鲜)[胡瓜]WP2025110700031",
"83": "黄豆芽WP2026020200042",
"84": "黑袋子",
"85": "黑鱼(整条鱼)WP2025110600012"
"2": "丝瓜WP2026032300114",
"3": "乳瓜WP2026032000107",
"4": "冬瓜WP2026032300115",
"5": "冰糖",
"6": "冰草WP2026020900054",
"7": "刀豆",
"8": "北豆腐WP2025111900007",
"9": "南瓜[倭瓜,番瓜]",
"10": "味精WP2026013000034",
"11": "圆生菜WP2026020900053",
"12": "基围虾WP2025110700019",
"13": "多宝鱼WP2025112600011",
"14": "白菜(代表值)WP2026031700085",
"15": "大葱WP2026013000026",
"16": "大蒜(去皮)WP2026020200011",
"17": "大蒜(带皮)WP2026020300046",
"18": "奶白菜",
"19": "姜(子姜)[嫩姜]",
"20": "姜[黄姜]WP2026022800956",
"21": "娃娃菜WP2026020200005",
"22": "小白菜WP2026022800031",
"23": "小米辣椒WP2026013000027",
"24": "小葱WP2026013000029",
"25": "小鸡腿(带骨)",
"26": "山药(带皮)WP2026020200008",
"27": "巨人笋",
"28": "平菇[糙皮侧耳,青蘑]WP2026031700086",
"29": "广东菜心WP2025120100014",
"30": "新世纪哈密瓜",
"31": "明虾",
"32": "木耳(干)[黑木耳,云耳]WP2026022801182",
"33": "杏鲍菇WP2026022700030",
"34": "松花蛋",
"35": "松茸菌WP2025120100016",
"36": "槐花[洋槐花,豆槐花]",
"37": "",
"38": "沙葱",
"39": "油麦菜WP2026031200077",
"40": "海带(浸)[江白菜,昆布]WP2026022800035",
"41": "淀粉(马铃薯)",
"42": "湿米线过桥米线",
"43": "火龙果(带皮)",
"44": "炒饼丝",
"45": "牛肉(腹部肉)[牛腩]",
"46": "牛肚",
"47": "牛腩WP2025110700043",
"48": "猪五花肉WP2026020300048",
"49": "猪小排(带骨)",
"50": "猪排(带骨)WP2026031200078",
"51": "猪肉(瘦)",
"52": "猪肉(腿)WP2026022800033",
"53": "猪肉(里脊)WP2026021000025",
"54": "猪肝(鲜)WP2026032000105",
"55": "猪肾(fat8g[猪腰子]",
"56": "玉米WP2025120100017",
"57": "玉米糁(黄)WP2026022800038",
"58": "甜椒[灯笼椒,柿子椒]WP2026020500018",
"59": "生菜(叶用莴苣)WP2026020500020",
"60": "生蚝",
"61": "番茄[西红柿]WP2026021000028",
"62": "白吉饼WP2026013000024",
"63": "白玉菇WP2026031100072",
"64": "白菜薹[菜薹,菜心]WP2026031100068",
"65": "白萝卜WP2026031200079",
"66": "石斑鱼(整条鱼)",
"67": "",
"68": "粉丝",
"69": "素鸡WP2026031700096",
"70": "红心萝卜[心里美]WP2026022800368",
"71": "红提子WP2025111900006",
"72": "红椒WP2026030300055",
"73": "红洋葱WP2026030600054",
"74": "红美人椒WP2026020300049",
"75": "红薯",
"76": "红薯粉条WP2026031700091",
"77": "红薯粉条(袋",
"78": "结球甘蓝(紫)[圆白菜]WP2026020500017",
"79": "结球甘蓝(绿)[圆白菜]WP2026032300116",
"80": "绿豆芽WP2026031100073",
"81": "羊排WP2025120300021",
"82": "羊血WP2026013000038",
"83": "胡萝卜WP2025110600008",
"84": "腰果(熟)",
"85": "芋头",
"86": "芥蓝[甘蓝菜,盖蓝菜]",
"87": "芦笋(绿)[石刁柏,龙须菜]",
"88": "花卷",
"89": "花椒(粉)",
"90": "花生仁(生)WP2026032000102",
"91": "花螺",
"92": "芹菜(茎)[旱芹,药芹]WP2026022800032",
"93": "苦菊",
"94": "苹果(代表值)WP2026032300117",
"95": "茄子(紫皮,长)WP2025110700024",
"96": "茼蒿WP2026020200045",
"97": "草鱼",
"98": "荷兰豆WP2026032000104",
"99": "莲藕(带皮)",
"100": "莴笋(鲜)[莴苣]WP2026020200009",
"101": "菜花(白色)[花椰菜]WP2026030600057",
"102": "菠菜(鲜)[赤根菜]WP2026022800700",
"103": "菠萝[凤梨,地菠萝]",
"104": "蒜苗WP2026013000028",
"105": "蒜薹(圆)",
"106": "蒸鱼豉油WP2026013000032",
"107": "蕹菜[空心菜,藤藤菜]",
"108": "薏米[薏仁米,苡米]WP2026022800037",
"109": "虫草花(干)WP2026032000108",
"110": "蛏子",
"111": "西兰花[绿菜花]WP2026031100071",
"112": "西瓜(代表值)",
"113": "西芹[西洋芹菜,美芹]WP2026031100069",
"114": "西葫芦WP2026021000027",
"115": "豆腐干(代表值)",
"116": "豆腐皮WP2026031700097",
"117": "豆角豇豆WP2026021000026",
"118": "赤小豆[小豆,红小豆](干)WP2026032300118",
"119": "辣椒(红,小)WP2025111000066",
"120": "辣椒(青,尖)WP2026030600056",
"121": "辣椒段(干)",
"122": "醪糟",
"123": "金针菇[(鲜)[智力菇]WP2026030600051",
"124": "青线椒WP2026013000031",
"125": "青美人椒辣椒(青,尖)",
"126": "青花椒鲜花椒",
"127": "面条(生,代表值)WP2026030600055",
"128": "面条(生,炒细面",
"129": "韭菜WP2026020200044",
"130": "韭薹",
"131": "韭黄",
"132": "馒头(代表值)WP2026030600056",
"133": "香椿[香椿芽]WP2026022801086",
"134": "香菇(鲜)[香蕈,冬菇]WP2026030600050",
"135": "香菜WP2026013000030",
"136": "马铃薯[土豆、洋芋]WP2026012900001",
"137": "鱿鱼(鲜,中国枪乌贼)[枪乌贼]",
"138": "鲍鱼(带壳)",
"139": "鲑鱼[大马哈鱼,三文鱼]",
"140": "鲜猪精排",
"141": "鸡全腿(带骨)",
"142": "鸡翅(带骨)",
"143": "鸡胸脯肉WP2026022802698",
"144": "鸡腿肉WP2025110700048",
"145": "鸡蛋WP2026013000039",
"146": "鸭(均值)",
"147": "鸭蛋黄",
"148": "黄瓜(鲜)[胡瓜]WP2025110700031",
"149": "黄豆",
"150": "黄豆芽WP2026020200042",
"151": "黑米WP2026031200081",
"152": "黑袋子",
"153": "黑鱼(整条鱼)WP2025110600012"
},
"embedding_dim": 512,
"total_images": 2070,
"build_time": "2026-03-06T09:38:05.028631"
"total_images": 3180,
"build_time": "2026-04-27T14:13:11.594418"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+27 -3
View File
@@ -2,7 +2,10 @@ package com.sw.inbound
import android.net.Uri
import com.sw.inbound.model.response.DictType
import com.sw.inbound.model.response.SpecOption
import com.sw.inbound.model.response.SupplierOption
import com.sw.inbound.model.response.User
import com.sw.inbound.model.response.ZoneOption
object GlobalData {
/**
@@ -41,10 +44,30 @@ object GlobalData {
var unitTypeList: List<DictType> = arrayListOf()
/**
* 具体业务baseurl
* 入库供应商列表(入库秤使用)
*/
//var appBaseUrl: String = "http://192.168.1.201:14801"
var appBaseUrl: String = "https://dev.yixiong-tech.com:8081"
var inboundSupplierList: List<SupplierOption> = emptyList()
/**
* 入库仓库区域列表(入库秤使用)
*/
var inboundZoneList: List<ZoneOption> = emptyList()
/**
* 规格
*/
var specOptionList: List<SpecOption> = emptyList()
/**
* 具体业务 BaseUrl
*/
// const val TEST_BASE_URL = "http://192.168.1.201:14801"
// const val LOCAL_BASE_URL = "http://192.168.10.118:14801"
const val LOCAL_BASE_URL = "http://192.168.10.101:24801";
// const val TEST_BASE_URL = "https://dev.yixiong-tech.com:8083"
const val TEST_BASE_URL = "https://dev.yixiong-tech.com:8081"
const val PROD_BASE_URL = "https://api.dm.yixiong-tech.com:8443"
var appBaseUrl: String = ""
/**
* 设备id
@@ -66,4 +89,5 @@ object GlobalKey {
const val KEY_TOKEN = "tokenKey"
const val KEY_USER_INFO = "userInfoKey"
const val KEY_USER_NAME = "userNameKey"
const val KEY_BASE_URL = "baseUrlKey"
}
@@ -1,14 +1,12 @@
package com.sw.inbound.activity
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.graphics.Bitmap
import android.os.Bundle
import android.text.TextUtils
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import com.sw.inbound.base.BaseActivity
import androidx.activity.viewModels
@@ -17,8 +15,8 @@ import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import coil.ImageLoader
import coil.annotation.ExperimentalCoilApi
import coil.imageLoader
import com.sw.inbound.R
import com.sw.inbound.adapter.FoodCollectionAdapter
import com.sw.inbound.adapter.GoodsSearchAdapter
@@ -39,6 +37,8 @@ import com.sw.inbound.utils.BitmapSaver
import com.sw.inbound.utils.CameraUtils
import com.sw.inbound.utils.ImageUploader
import com.sw.inbound.utils.ImageUtil
import com.sw.inbound.utils.LogSaveUtil
import com.sw.inbound.utils.NetworkUtils
import com.sw.inbound.utils.ext.addOnActionSearchListener
import com.sw.inbound.utils.ext.clickWithDebounce
import com.sw.inbound.utils.ext.dp
@@ -47,21 +47,20 @@ import com.sw.inbound.utils.ext.hideKeyboard
import com.sw.inbound.utils.ext.invisible
import com.sw.inbound.utils.ext.startActivity
import com.sw.inbound.utils.ext.toJsonString
import com.sw.inbound.utils.ext.toObject
import com.sw.inbound.utils.ext.toast
import com.sw.inbound.utils.ext.visible
import com.sw.inbound.viewmodel.ReceiptViewModel
import com.sw.inbound.viewmodel.UserViewModel
import dagger.hilt.android.AndroidEntryPoint
import io.objectbox.Box
import io.objectbox.exception.DbException
import io.objectbox.exception.FileCorruptException
import io.objectbox.kotlin.boxFor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import timber.log.Timber
import java.io.File
/**
* 采集菜品
@@ -76,13 +75,12 @@ class FoodCollectionActivity : BaseActivity() {
const val PAGE_SIZE = 30
}
// private var box: Box<Food>? = null
private val collectList: MutableList<FoodCollectionBean> = mutableListOf<FoodCollectionBean>()
private val collectList: MutableList<FoodCollectionBean> = mutableListOf()
private lateinit var binding: ActivityFoodCollectionBinding
private lateinit var previewView: PreviewView
val viewModel: ReceiptViewModel by viewModels()
private val receiptViewModel: ReceiptViewModel by viewModels()
val userViewModel: UserViewModel by viewModels()
private val cameraUtils: CameraUtils by lazy {
CameraUtils(this)
}
@@ -90,9 +88,14 @@ class FoodCollectionActivity : BaseActivity() {
FoodCollectionAdapter(collectList).apply {
setOnItemClickListener { adapter, view, position ->
val file = collectList[position].imageFile ?: return@setOnItemClickListener
ImageUtil.decodeFromFile(file)?.let { bitmap ->
val imageVector = FoodModule.bitmap2FloatArray(bitmap, false)
Timber.tag(TAG).d("向量:${imageVector.toJsonString()}")
// 磁盘读取和 TFLite 推理均为耗时操作,切换到 IO 线程执行
lifecycleScope.launch(Dispatchers.IO) {
ImageUtil.decodeFromFile(file)?.let { bitmap ->
val imageVector = FoodModule.bitmap2FloatArray(bitmap, false)
Timber.tag(TAG).d("向量:${imageVector.toJsonString()}")
// 向量提取完毕后立即回收,避免内存泄漏
if (bitmap.isRecycled.not()) bitmap.recycle()
}
}
}
addOnItemChildClickListener(R.id.ivDelete) { _, _, position ->
@@ -105,8 +108,6 @@ class FoodCollectionActivity : BaseActivity() {
it.uploadSuccess = false
}
collectionAdapter.notifyItemChanged(position)
// val count = collectList.count { it.imageFile != null && it.uploadSuccess.not() }
// binding.btnUploadImage.text = "待上传图片${count}张"
}
}
}
@@ -128,7 +129,6 @@ class FoodCollectionActivity : BaseActivity() {
binding.llCameraFlag.visible()
}
@SuppressLint("NotifyDataSetChanged")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityFoodCollectionBinding.inflate(layoutInflater)
@@ -163,14 +163,6 @@ class FoodCollectionActivity : BaseActivity() {
searchGoods()
}
}
// binding.btnUploadImage.setOnClickListener {
// val count = collectList.count { it.imageFile!=null && it.uploadSuccess.not() }
// if (count == 0) {
// toast("已全部上传成功")
// return@setOnClickListener
// }
// uploadAllImage()
// }
binding.btnSave.setOnClickListener { _ ->
if (clickIndex == -1) {
toast("请选择物品名称")
@@ -181,8 +173,6 @@ class FoodCollectionActivity : BaseActivity() {
toast("请拍摄物品照片")
return@setOnClickListener
}
// vectorThread()
// uploadAllImage()
showUploadDialog()
}
binding.btnTakePhoto.clickWithDebounce(800) {
@@ -215,18 +205,6 @@ class FoodCollectionActivity : BaseActivity() {
startActivity<LocalImagePreviewActivity> { }
}
binding.tvTitle.setOnClickListener {
// startActivity<LocalImagePreviewActivity> { }
// val goodsImage = GoodsImage(goodsId = "1993493094888140802", goodsName = "多宝鱼", startTime = 1764120531856L, endTime = 1764121842825L)
// uploadImage(goodsImage)
// val goodsImage = GoodsImage(goodsId = "1986366073649287170", goodsName = "黑鱼(整条鱼)", startTime = 1764122703093L, endTime = 1764123156022L)
// uploadImage(goodsImage)
// val goodsImage = GoodsImage(goodsId = "1986699063868801025", goodsName = "鸡腿肉", startTime = 1764123375841L, endTime = 1764124039162L)
// uploadImage(goodsImage)
}
}
private fun showUploadDialog() {
@@ -238,13 +216,19 @@ class FoodCollectionActivity : BaseActivity() {
},
confirmBlock = {
if (NetworkUtils.isNetworkConnected(this).not()) {
toast("网络异常,请稍后重试")
return@WarnDialog
}
upload()
}
) .show()
).show()
}
private var pageNo = 1
private var clickIndex = -1
// 保存当前搜索任务,用于在新搜索发起时取消上一次未完成的请求
private var searchJob: Job? = null
private val searchGoodsList: MutableList<SearchGoodsInfo.Record> = mutableListOf()
private val searchAdapter by lazy {
@@ -274,7 +258,6 @@ class FoodCollectionActivity : BaseActivity() {
}
}
@SuppressLint("NotifyDataSetChanged")
private fun takePhoto() {
val count = collectList.count { it.imageFile != null }
if (count == MAX_COUNT) {
@@ -285,10 +268,12 @@ class FoodCollectionActivity : BaseActivity() {
cameraUtils.takePhoto(cameraCallback)
}
@SuppressLint("NotifyDataSetChanged")
private fun clearData() {
val count = collectList.count { it.imageFile != null }
if (count == 0) return
// 取消正在进行的搜索,防止清空后回调重新填充数据
searchJob?.cancel()
searchJob = null
clearCoilCache(this)
collectList.forEach {
try {
@@ -313,7 +298,6 @@ class FoodCollectionActivity : BaseActivity() {
searchAdapter.notifyDataSetChanged()
loadEmptyView()
binding.ivFinish.invisible()
// binding.btnUploadImage.text = "待上传图片${0}张"
}
private val cameraCallback: (Uri) -> Unit = { uri ->
@@ -326,68 +310,88 @@ class FoodCollectionActivity : BaseActivity() {
val index = collectList.indexOfFirst { it.imageFile == null }
if (index == -1) {
toast("每次只允许保存${MAX_COUNT}条数据")
showWaitingDialog2("加载中")
hideWaitingDialog()
return
}
// toast("index=$index")
// 将 bitmap/cropBitmap 提到外层,确保任意退出路径都能在 finally 中回收,避免内存泄漏
var bitmap: Bitmap? = null
var cropBitmap: Bitmap? = null
try {
ImageUtil.uriToBitmap(this, uri)?.let { bitmap ->
val cropBitmap = BitmapCropper.cropCenter(
// 所有耗时操作(磁盘 I/O、图像裁剪、TFLite 推理)切换到 IO 线程,避免阻塞主线程
bitmap = withContext(Dispatchers.IO) {
ImageUtil.uriToBitmap(this@FoodCollectionActivity, uri)
}
if (bitmap == null) {
// uriToBitmap 返回 null 时关闭 loading,否则转圈永不消失
hideWaitingDialog()
toast("图片读取失败,请重试")
LogSaveUtil.saveLogFile("图片读取失败,请重试")
return
}
cropBitmap = withContext(Dispatchers.IO) {
BitmapCropper.cropCenter(
original = bitmap,
targetWidth = 1000, targetHeight = 1300,
offsetX = 30, offsetY = 100
)
initBox()
val imageVector = try {
}
val imageVector = try {
// TFLite 推理为 CPU 密集型,切换到 IO 线程执行
withContext(Dispatchers.IO) {
FoodModule.bitmap2FloatArray(cropBitmap, false)
}
} catch (e: Exception) {
e.printStackTrace()
hideWaitingDialog()
toast("操作失败")
LogSaveUtil.saveLogFile("操作失败:${e.message}")
return
}
Timber.tag(TAG).d("向量:${imageVector.toJsonString()}")
val foodList = try {
// 数据库查询切换到 IO 线程
withContext(Dispatchers.IO) {
FoodModule.queryFoodNameScore(imageVector)
}
} catch (e: DbException) {
hideWaitingDialog()
try {
// ObjectBox 重新初始化为磁盘 I/O,切换到 IO 线程
withContext(Dispatchers.IO) {
ObjectBox.init(this@FoodCollectionActivity)
}
} catch (e: Exception) {
e.printStackTrace()
hideWaitingDialog()
toast("操作失败")
return@let
}
Timber.tag(TAG).d("向量:${imageVector.toJsonString()}")
val foodList = try {
FoodModule.queryFoodNameScore(imageVector)
} catch (e: DbException) {
try {
ObjectBox.init(this)
} catch (e: Exception) {
e.printStackTrace()
}
toast("数据库异常,请稍后重试")
return@let
}
val filterList =
foodList.filter { it.score < FoodModule.BAG_RATE && it.name.contains("黑袋子") }
if (filterList.isNotEmpty()) {
hideWaitingDialog()
toast("当前物品可能被袋子遮挡,请检查后重试")
return@let
}
val file = BitmapSaver.saveToAppFilesDir(
cropBitmap, this, "IMG_CROP_${System.currentTimeMillis()}.jpg"
)
Timber.d("${this.javaClass.simpleName}-searchFood-裁剪bitmap保存文件路径:${file?.absolutePath}")
collectList[index].let {
it.imageVector = imageVector
it.bitmap = null
it.imageFile = file
it.isShowCamera = false
it.isFinish = false
it.uploadSuccess = false
}
collectionAdapter.notifyItemChanged(index)
// val count = collectList.count { it.imageFile != null && it.uploadSuccess.not() }
// binding.btnUploadImage.text = "待上传图片${count}张"
if (cropBitmap.isRecycled.not()) {
cropBitmap.recycle()
}
if (bitmap.isRecycled.not()) {
bitmap.recycle()
}
hideWaitingDialog()
toast("数据库异常,请稍后重试")
LogSaveUtil.saveLogFile("数据库异常,请稍后重试:${e.message}")
return
}
val filterList =
foodList.filter { it.score < FoodModule.BAG_RATE && it.name.contains("黑袋子") }
if (filterList.isNotEmpty()) {
hideWaitingDialog()
toast("当前物品可能被袋子遮挡,请检查后重试")
LogSaveUtil.saveLogFile("当前物品可能被袋子遮挡,请检查后重试")
return
}
// 图片保存为磁盘 I/O,切换到 IO 线程
val file = withContext(Dispatchers.IO) {
BitmapSaver.saveToAppFilesDir(
cropBitmap, this@FoodCollectionActivity, "IMG_CROP_${System.currentTimeMillis()}.jpg"
)
}
Timber.d("${this.javaClass.simpleName}-searchFood-裁剪bitmap保存文件路径:${file?.absolutePath}")
collectList[index].let {
it.imageVector = imageVector
it.bitmap = null
it.imageFile = file
it.isShowCamera = false
it.isFinish = false
it.uploadSuccess = false
}
collectionAdapter.notifyItemChanged(index)
hideWaitingDialog()
} catch (e: Exception) {
e.printStackTrace()
hideWaitingDialog()
@@ -397,14 +401,23 @@ class FoodCollectionActivity : BaseActivity() {
e is DbException
) {
try {
ObjectBox.init(this)
// ObjectBox 重新初始化为磁盘 I/O,切换到 IO 线程
withContext(Dispatchers.IO) {
ObjectBox.init(this@FoodCollectionActivity)
}
} catch (e: Exception) {
e.printStackTrace()
}
toast("请重试")
LogSaveUtil.saveLogFile("请重试:${e.message}")
} else {
toast("程序异常$errMsg")
LogSaveUtil.saveLogFile("程序异常$errMsg:${e.message}")
}
} finally {
// 无论成功、失败还是提前返回,均在此回收 bitmap,彻底消除内存泄漏
if (cropBitmap?.isRecycled == false) cropBitmap.recycle()
if (bitmap?.isRecycled == false) bitmap.recycle()
}
}
@@ -412,15 +425,13 @@ class FoodCollectionActivity : BaseActivity() {
Loading.show(this)
lifecycleScope.launch {
collectList
//.filter { it.bitmap != null }
// .filter { it.imageVector != null }
.forEachIndexed { index, it ->
//image2VectorTask(it.bitmap!!, index)
it.imageVector?.let { imageVector ->
image2VectorTask(imageVector = imageVector, position = index)
}
}
runOnUiThread {
// lifecycleScope 默认在主线程,直接切换到主线程调度器更新 UI
withContext(Dispatchers.Main) {
window.decorView.postDelayed({
Loading.dismiss()
toast("保存成功")
@@ -430,30 +441,19 @@ class FoodCollectionActivity : BaseActivity() {
}
private suspend fun image2VectorTask(
bitmap: Bitmap? = null,
imageVector: FloatArray? = null,
imageVector: FloatArray,
position: Int
) {
try {
initBox()
// val imageVector = try {
// FoodModule.bitmap2FloatArray(bitmap)
// } catch (e: Exception) {
// e.printStackTrace()
// //toast("保存失败")
// return
// }
val goods = searchGoodsList[clickIndex]
val saveName = goods.goodsName + goods.goodsCode
// ObjectBox.boxStore.runInTx {
// box?.put(Food(name = saveName, foodIdx = 0, foodVector = imageVector))
// }
ObjectBox.put(Food(name = saveName, foodIdx = 0, foodVector = imageVector))
collectList[position].let {
// it.imageVector = imageVector
it.isFinish = true
// ObjectBox 写入为磁盘 I/O,切换到 IO 线程避免主线程卡顿
withContext(Dispatchers.IO) {
ObjectBox.put(Food(name = saveName, foodIdx = 0, foodVector = imageVector))
}
runOnUiThread {
collectList[position].isFinish = true
// 切换到主线程刷新列表项,与协程调度器保持一致
withContext(Dispatchers.Main) {
collectionAdapter.notifyItemChanged(position)
}
} catch (e: Exception) {
@@ -461,7 +461,6 @@ class FoodCollectionActivity : BaseActivity() {
}
}
@SuppressLint("NotifyDataSetChanged")
private fun searchGoods() {
val searchName = binding.etGoodsInput.text.toString().trim()
if (searchName.isBlank()) {
@@ -516,77 +515,54 @@ class FoodCollectionActivity : BaseActivity() {
pageSize: Int = PAGE_SIZE,
callback: (List<SearchGoodsInfo.Record>) -> Unit
) {
runBlocking {
val records = viewModel.searchGoodsInfoList2(
// 取消上一次未完成的搜索,避免并发竞争导致结果乱序
searchJob?.cancel()
searchJob = lifecycleScope.launch {
val records = receiptViewModel.searchGoodsInfoList2(
goodsName = searchName,
pageNo = pageNo,
pageSize = pageSize
)
runOnUiThread {
callback(records)
}
callback(records)
}
}
private fun initBox() {
// if (box == null) {
// box = ObjectBox.boxStore.boxFor(Food::class)
// }
}
// private fun uploadAllImage() {
// Loading.show(this)
// val files = collectList.filter { it.imageFile!=null }.map { it.imageFile!! }
// if (files.isEmpty()) {
// return
// }
// val goods = searchGoodsList[clickIndex]
// for (index in collectList.indices step 5) {
// val end = if(index + 5 < collectList.size - 1) index + 5 else collectList.size - 1
// val subList = collectList.subList(index, end)
// val subFiles = subList.map { it.imageFile }
// viewModel.uploadMultipleImages(goods.id!!, goods.goodsName!!, subFiles) {isSuccess->
// Timber.tag(TAG).d("uploadMultipleImages: ${isSuccess}")
// subList.filter { it.imageFile!=null }.forEach { it.uploadSuccess = isSuccess }
//
// val count = collectList.count { it.imageFile!=null && it.uploadSuccess.not() }
// runOnUiThread {
// binding.btnUploadImage.text = "待上传图片${count}张"
// if (count == 0) {
// Loading.dismiss()
// }
// }
// }
// }
// }
private fun upload() {
lifecycleScope.launch {
val totalFileCount = collectList.count { it.imageFile != null }
showWaitingDialog2("图片上传中0/$totalFileCount")
val goods = searchGoodsList[clickIndex]
val params = HashMap<String, RequestBody>()
params["goodsId"] = goods.id!!.toRequestBody()
params["goodsName"] = goods.goodsName!!.toRequestBody()
//params["foodVector"] = foodVector.toRequestBody()
// 使用安全调用避免 id/goodsName 为 null 时崩溃,字段缺失则跳过上传
val goodsId = goods.id ?: run {
hideWaitingDialog()
toast("商品数据异常,请重新选择")
return@launch
}
val goodsName = goods.goodsName ?: run {
hideWaitingDialog()
toast("商品数据异常,请重新选择")
return@launch
}
params["goodsId"] = goodsId.toRequestBody()
params["goodsName"] = goodsName.toRequestBody()
ImageUploader(
totalList = collectList,
uploadImage = { batch ->
val files = batch.map { it.imageFile }
viewModel.uploadMultipleImages(files = files, params)
receiptViewModel.uploadMultipleImages(files = files, params)
},
onProgress = { count, batch ->
runOnUiThread {
showWaitingDialog2("图片上传中$count/$totalFileCount")
batch.forEach {
it.uploadSuccess = true
}
// lifecycleScope 默认在主线程执行,无需 runOnUiThread
showWaitingDialog2("图片上传中$count/$totalFileCount")
batch.forEach {
it.uploadSuccess = true
}
},
onError = {
runOnUiThread {
hideWaitingDialog()
toast("上传失败,请稍后重试")
}
// lifecycleScope 默认在主线程执行,无需 runOnUiThread
hideWaitingDialog()
toast("上传失败,请稍后重试")
},
onComplete = {
hideWaitingDialog()
@@ -596,10 +572,9 @@ class FoodCollectionActivity : BaseActivity() {
}
}
private val receiptViewModel: ReceiptViewModel by viewModels()
fun searchGoodsInfoList(goodsName: String, block: (List<SearchGoodsInfo.Record>) -> Unit) {
runBlocking {
// 使用协程异步执行,避免阻塞主线程
lifecycleScope.launch {
val list = searchGoodsInfoList2(goodsName)
block(list)
}
@@ -610,25 +585,35 @@ class FoodCollectionActivity : BaseActivity() {
pageNo: Int = 1,
pageSize: Int = 10
): List<SearchGoodsInfo.Record> {
// 统一使用类顶部声明的 viewModel,避免重复创建 ViewModel 实例
return receiptViewModel.searchGoodsInfoList2(goodsName, pageNo, pageSize)
}
private var mDialogWaiting: CustomDialog? = null
/**
* 显示等待提示框
* 内部方法:创建并显示等待提示框(调用前须确保已在主线程)
* showWaitingDialog 与 showWaitingDialog2 共用此创建逻辑,避免重复代码
*/
private fun createWaitingDialog(tip: String?) {
mDialogWaiting?.dismiss()
mDialogWaiting = null
val view = View.inflate(this, R.layout.dialog_waiting, null)
if (!TextUtils.isEmpty(tip)) {
(view.findViewById<View>(R.id.tvTip) as TextView).text = tip
}
mDialogWaiting = CustomDialog(this, view, R.style.MyDialog).apply {
show()
setCanceledOnTouchOutside(true)
setCancelable(true)
}
}
/**
* 显示等待提示框(每次调用都重新创建对话框)
*/
fun showWaitingDialog(tip: String?) {
runOnUiThread {
hideWaitingDialog()
val view = View.inflate(this, R.layout.dialog_waiting, null)
if (!TextUtils.isEmpty(tip)) (view.findViewById<View?>(R.id.tvTip) as TextView).text =
tip
mDialogWaiting = CustomDialog(this, view, R.style.MyDialog)
mDialogWaiting!!.show()
mDialogWaiting!!.setCanceledOnTouchOutside(true)
mDialogWaiting!!.setCancelable(true)
}
runOnUiThread { createWaitingDialog(tip) }
}
/**
@@ -641,19 +626,18 @@ class FoodCollectionActivity : BaseActivity() {
}
}
/**
* 显示或更新等待提示框
* 若对话框已存在则仅更新文字,否则创建新对话框
*/
fun showWaitingDialog2(tip: String?) {
runOnUiThread {
if (mDialogWaiting == null) {
hideWaitingDialog()
val view = View.inflate(this, R.layout.dialog_waiting, null)
mDialogWaiting = CustomDialog(this, view, R.style.MyDialog)
mDialogWaiting!!.show()
mDialogWaiting!!.setCanceledOnTouchOutside(true)
mDialogWaiting!!.setCancelable(true)
createWaitingDialog(tip)
} else {
// 对话框已显示,仅更新提示文字
mDialogWaiting?.findViewById<TextView>(R.id.tvTip)?.text = tip
}
val contentView = mDialogWaiting?.findViewById<ViewGroup>(android.R.id.content)
val tvTip = contentView?.findViewById<TextView>(R.id.tvTip)
tvTip?.text = tip
}
}
@@ -662,128 +646,9 @@ class FoodCollectionActivity : BaseActivity() {
*/
@OptIn(ExperimentalCoilApi::class)
fun clearCoilCache(context: Context) {
val imageLoader = ImageLoader.Builder(context).build()
// 清除内存缓存
imageLoader.memoryCache?.clear()
// 清除磁盘缓存
imageLoader.diskCache?.clear()
// 使用 Coil 全局单例,避免每次新建 ImageLoader 实例导致清除无效
context.imageLoader.memoryCache?.clear()
context.imageLoader.diskCache?.clear()
}
data class RespData(
var code: String? = null,
var data: String? = null,
var msg: String? = null,
)
data class GoodsImage(
var goodsId: String,
var goodsName: String,
var startTime: Long,
var endTime: Long
)
//
// val goodsImageList = mutableListOf<GoodsImage>().apply {
// add(
// GoodsImage(
// goodsId = "1986353555971297282",
// goodsName = "马铃薯[土豆、洋芋]",
// startTime = 1763692477786L,
// endTime = 1763693153825L
// )
// )
// add(GoodsImage(
// goodsId = "1986607476769869825",
// goodsName = "辣椒(青辣椒,螺丝椒)",
// startTime = 1763693182929L,
// endTime = 1763693621664L
// ))
// add(GoodsImage(
// goodsId = "1987880591453859842",
// goodsName = "辣椒(红,小)",
// startTime = 1763693683837L,
// endTime = 1763694154521L
// ))
// add(GoodsImage(
// goodsId = "1986699063868801025",
// goodsName = "鸡腿肉",
// startTime = 1763694221267L,
// endTime = 1763695052924L
// ))
//
// add(GoodsImage(
// goodsId = "1986689570300809217",
// goodsName = "猪腿肉",
// startTime = 1763695212428L,
// endTime = 1763695810578L
// ))
// add(GoodsImage(
// goodsId = "1986673470079029249",
// goodsName = "西兰花[绿菜花]",
// startTime = 1763695924575L,
// endTime = 1763696252669L
// ))
// add(GoodsImage(
// goodsId = "1991473021965070338",
// goodsName = "姜[黄姜]",
// startTime = 1763696258001L,
// endTime = 1763696294831L
// ))
// add(GoodsImage(
// goodsId = "00000",
// goodsName = "其它",
// startTime = 1763696442577L,
// endTime = 1763696990541L
// ))
// }
// private fun uploadImage(goodsImage: GoodsImage) {
// Loading.show(this)
// val cropFile = File(this.cacheDir, "crop")
// val list = mutableListOf<File>()
// val startTime = goodsImage.startTime
// val endTime = goodsImage.endTime
// cropFile.listFiles()?.forEach {
// if (it.isDirectory) {
// return@forEach
// }
// val start = "IMG_CROP_".length
// val end = it.name.indexOf(".jpg")
// val time = it.name.substring(start, end).toLong()
// if (time in startTime..endTime) {
// list.add(it)
// }
// if (list.size < 5) {
// return@forEach
// }
// viewModel.uploadMultipleImages(
// goodsId = goodsImage.goodsId,
// goodsName = goodsImage.goodsName,
// files = list
// ) { isSuccess ->
//// val resp = data?.toObject<RespData>()
//// val isSuccess = resp?.code == "00000"
// binding.ivFinish.run {
// if (isSuccess) visible() else invisible()
// }
// }
// list.clear()
// }
// if (list.isNotEmpty()) {
// viewModel.uploadMultipleImages(
// goodsId = goodsImage.goodsId,
// goodsName = goodsImage.goodsName,
// files = list
// ) { isSuccess ->
//// val resp = data?.toObject<RespData>()
//// val isSuccess = resp?.code == "00000"
// binding.ivFinish.run {
// if (isSuccess) visible() else invisible()
// }
// }
// }
// Loading.dismiss()
// }
}
@@ -0,0 +1,298 @@
package com.sw.inbound.activity
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.sw.inbound.base.BaseActivity
import com.sw.inbound.databinding.ActivityFunBinding
import com.sw.inbound.objbox.Food
import com.sw.inbound.objbox.FoodClassInfo
import com.sw.inbound.objbox.ObjectBox
import com.sw.inbound.utils.ext.toast
import com.sw.inbound.utils.ZipDownloadUtils
import com.sw.inbound.utils.ZipExtractUtils
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
/**
* 趣味页面 Activity
* 用于测试 Zip 文件下载、解压和数据库操作
*/
@AndroidEntryPoint
class FunActivity : BaseActivity() {
private lateinit var binding: ActivityFunBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityFunBinding.inflate(layoutInflater)
setContentView(binding.root)
initViews()
}
/**
* 初始化各控件的点击事件
*/
private fun initViews() {
// 返回按钮点击事件
binding.ivBack.setOnClickListener {
finish()
}
// 下载按钮
binding.btnDownload.setOnClickListener {
downloadZipFile()
}
// 解压按钮
binding.btnExtract.setOnClickListener {
extractZipFile()
}
// 读取并保存按钮
binding.btnLoadAndSave.setOnClickListener {
loadAndSaveToDatabase()
}
}
/**
* 下载 Zip 文件
*/
private fun downloadZipFile() {
lifecycleScope.launch {
try {
updateProgress("开始下载...")
binding.btnDownload.isEnabled = false
val zipFile = File(cacheDir, "model_resources.zip")
// 假的下载地址
val downloadUrl = "https://dev.yixiong-tech.com:8081/common/files/20260313/embedding_model_resources.zip"
withContext(Dispatchers.IO) {
ZipDownloadUtils.downloadZip(
downloadUrl = downloadUrl,
targetFile = zipFile,
listener = object : ZipDownloadUtils.DownloadProgressListener {
override fun onProgress(currentSize: Long, totalSize: Long, progress: Int) {
updateProgress("下载中... $progress%")
}
override fun onSuccess(file: File) {
updateProgress("下载完成!文件大小: ${formatFileSize(file.length())}")
Timber.d("Zip 文件下载完成: ${file.absolutePath}")
}
override fun onFailure(exception: Exception) {
updateProgress("下载失败: ${exception.message}")
Timber.e(exception, "下载失败")
this@FunActivity.toast("下载失败: ${exception.message}")
}
}
)
}
} catch (e: Exception) {
Timber.e(e, "下载异常")
updateProgress("异常: ${e.message}")
this@FunActivity.toast("异常: ${e.message}")
} finally {
binding.btnDownload.isEnabled = true
}
}
}
/**
* 解压 Zip 文件
*/
private fun extractZipFile() {
lifecycleScope.launch {
try {
updateProgress("开始解压...")
binding.btnExtract.isEnabled = false
val zipFile = File(cacheDir, "model_resources.zip")
val targetDir = File(cacheDir, "model_resources")
if (!zipFile.exists()) {
updateProgress("Zip 文件不存在,请先下载")
this@FunActivity.toast("Zip 文件不存在,请先下载")
return@launch
}
withContext(Dispatchers.IO) {
ZipExtractUtils.extractZip(
zipFile = zipFile,
targetDir = targetDir,
listener = object : ZipExtractUtils.ExtractProgressListener {
override fun onProgress(
currentFile: String,
currentIndex: Int,
totalFiles: Int,
progress: Int
) {
updateProgress("解压中... $progress% ($currentIndex/$totalFiles)")
}
override fun onSuccess(targetDir: File) {
updateProgress("解压完成!目录: ${targetDir.absolutePath}")
Timber.d("Zip 文件解压完成: ${targetDir.absolutePath}")
}
override fun onFailure(exception: Exception) {
updateProgress("解压失败: ${exception.message}")
Timber.e(exception, "解压失败")
this@FunActivity.toast("解压失败: ${exception.message}")
}
}
)
}
} catch (e: Exception) {
Timber.e(e, "解压异常")
updateProgress("异常: ${e.message}")
this@FunActivity.toast("异常: ${e.message}")
} finally {
binding.btnExtract.isEnabled = true
}
}
}
/**
* 读取解压后的文件并保存到 ObjectBox 数据库
*/
private fun loadAndSaveToDatabase() {
lifecycleScope.launch {
try {
updateProgress("开始读取数据...")
binding.btnLoadAndSave.isEnabled = false
val resourcesDir = File(cacheDir, "model_resources")
if (!resourcesDir.exists()) {
updateProgress("资源目录不存在,请先解压")
this@FunActivity.toast("资源目录不存在,请先解压")
return@launch
}
withContext(Dispatchers.IO) {
// 清除已有数据
updateProgress("清除已有数据...")
clearFoodData()
// 读取 JSON 文件
updateProgress("读取 JSON 文件...")
val embeddingsJson = readFileContent(File(resourcesDir, "data/embeddings.json"))
val labelsJson = readFileContent(File(resourcesDir, "data/labels.json"))
val classInfoJson = readFileContent(File(resourcesDir, "data/class_info.json"))
if (embeddingsJson.isEmpty() || labelsJson.isEmpty() || classInfoJson.isEmpty()) {
updateProgress("JSON 文件读取失败")
this@FunActivity.toast("JSON 文件读取失败")
return@withContext
}
// 解析 JSON 数据
updateProgress("解析 JSON 数据...")
val embeddingsList: List<List<Float>> =
Gson().fromJson(embeddingsJson, object : TypeToken<List<List<Float>>>() {}.type)
val labelsList: IntArray = Gson().fromJson(labelsJson, IntArray::class.java)
val classInfo: FoodClassInfo =
Gson().fromJson(classInfoJson, FoodClassInfo::class.java)
// 构建 Food 对象列表
updateProgress("构建数据对象...")
val foodMap = classInfo.idx_to_class
val foodList = mutableListOf<Food>()
val size = embeddingsList.size
Timber.d("开始构建 Food 对象,总数: $size")
embeddingsList.forEachIndexed { index, floatList ->
val classIdx = labelsList[index]
val foodName = foodMap["$classIdx"]
val array = floatList.toFloatArray()
val food = Food(name = foodName, foodVector = array, foodIdx = -1)
foodList.add(food)
// 每 100 条更新一次进度
if ((index + 1) % 100 == 0) {
val progress = ((index + 1) * 100) / size
updateProgress("构建数据中... $progress% (${index + 1}/$size)")
}
}
// 保存到数据库
updateProgress("保存到数据库...")
ObjectBox.putAll(foodList)
updateProgress("完成!已保存 ${foodList.size} 条数据到数据库")
Timber.d("数据保存完成,总数: ${foodList.size}")
}
} catch (e: Exception) {
Timber.e(e, "读取和保存异常")
updateProgress("异常: ${e.message}")
this@FunActivity.toast("异常: ${e.message}")
} finally {
binding.btnLoadAndSave.isEnabled = true
}
}
}
/**
* 清除 Food 表中的所有数据
*/
private suspend fun clearFoodData() {
try {
ObjectBox.safeDbOp {
val box = ObjectBox.getBox<Food>()
box?.removeAll()
Timber.d("Food 表数据已清除")
}
} catch (e: Exception) {
Timber.e(e, "清除数据失败")
}
}
/**
* 读取文件内容
*/
private fun readFileContent(file: File): String {
return try {
if (!file.exists()) {
Timber.w("文件不存在: ${file.absolutePath}")
""
} else {
file.readText()
}
} catch (e: Exception) {
Timber.e(e, "读取文件失败: ${file.absolutePath}")
""
}
}
/**
* 更新进度显示
*/
private fun updateProgress(message: String) {
runOnUiThread {
binding.tvProgress.text = message
Timber.d("进度: $message")
}
}
/**
* 格式化文件大小
*/
private fun formatFileSize(bytes: Long): String {
return when {
bytes <= 0 -> "0 B"
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> String.format("%.2f KB", bytes / 1024.0)
bytes < 1024 * 1024 * 1024 -> String.format("%.2f MB", bytes / (1024.0 * 1024))
else -> String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024))
}
}
}
@@ -41,6 +41,7 @@ import com.sw.inbound.model.response.SearchGoodsInfo
import com.sw.inbound.objbox.FoodModule
import com.sw.inbound.objbox.FoodModule.IdNameScore
import com.sw.inbound.sdk.SensorScaleUtils
import com.sw.inbound.sdk.WeightCallback
import com.sw.inbound.utils.BitmapCropper
import com.sw.inbound.utils.BitmapSaver
import com.sw.inbound.utils.CameraUtils
@@ -52,6 +53,7 @@ import com.sw.inbound.utils.ext.dp
import com.sw.inbound.utils.ext.gone
import com.sw.inbound.utils.ext.hideKeyboard
import com.sw.inbound.utils.ext.roundedDecimalPlace
import com.sw.inbound.utils.ext.startActivity
import com.sw.inbound.utils.ext.toJsonString
import com.sw.inbound.utils.ext.toast
import com.sw.inbound.viewmodel.ReceiptViewModel
@@ -76,6 +78,7 @@ abstract class GoodsListActivity : BaseActivity() {
private const val TAG = "GoodsListActivity"
public const val ID = "id"
public const val SUPPLIER_ID = "supplierId"
public const val PUR_CODE = "purCode"
public const val IS_RECEIPT_PAGE = "isReceiptPage"
// public const val IS_NEW_RECEIPT = "isNewReceipt"
@@ -89,6 +92,7 @@ abstract class GoodsListActivity : BaseActivity() {
// var isNewReceipt: Boolean = false
var id: String = ""
var supplierId: String = ""
var purCode: String = ""
lateinit var binding: ActivityGoodsListBinding
val receiptViewModel: ReceiptViewModel by viewModels()
val userViewModel: UserViewModel by viewModels()
@@ -108,12 +112,14 @@ abstract class GoodsListActivity : BaseActivity() {
super.onCreate(savedInstanceState)
binding = ActivityGoodsListBinding.inflate(layoutInflater)
setContentView(binding.root)
cameraUtils.initCamera()
initBackDispatcher()
isReceiptPage = intent.getBooleanExtra(IS_RECEIPT_PAGE, true)
//isNewReceipt = intent.getBooleanExtra(IS_NEW_RECEIPT, false)
id = intent.getStringExtra(ID) ?: ""
supplierId = intent.getStringExtra(SUPPLIER_ID) ?: ""
cameraUtils.initCamera()
purCode = intent.getStringExtra(PUR_CODE) ?: ""
replaceIncludeContent()
initRecyclerView()
initView()
@@ -121,7 +127,9 @@ abstract class GoodsListActivity : BaseActivity() {
it.hideKeyboard()
}
binding.btnBack.setOnClickListener { handleBackEvent() }
binding.tvTitle.setOnClickListener {
startActivity<FoodCollectionActivity>()
}
binding.flWarehouseDropdown.setOnClickListener { v ->
if (GlobalData.warehouseTypeList.isEmpty()) {
toast("暂无仓库数据")
@@ -161,64 +169,34 @@ abstract class GoodsListActivity : BaseActivity() {
height = 1
}
}
cameraUtils.setPreviewController(previewView)
//startTime = System.currentTimeMillis()
binding.root.postDelayed({
SensorScaleUtils.addWeightListener {
runOnUiThread {
try {
weightCallback?.invoke(it)
recognizeWeight(weight = it)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}, 1 * 1000)
binding.btnClearZero.setOnClickListener {
SensorScaleUtils.tare()
}
//val array = arrayOf("1","2","qwe")
//Timber.tag(TAG).d("测试:${array[2].toInt()}")
userViewModel.getInitInfo()
if (this !is StoreActivity) {
userViewModel.getInitInfo()
}
}
// lifecycleScope.launch {
// ObjectBox.put(
// Food(
// name = "测试",
// foodIdx = -100,
// foodVector = floatArrayOf(
// 0.015068196f,0.0f,0.0f,0.0f,0.0037743463f,0.0f,0.0f,0.07893528f,0.0f,0.0f,0.0f,0.0f,0.052832354f,0.0f,0.045785826f,0.0f,6.156802E-4f,0.04242764f,0.0f,0.0f,0.0f,0.0f,0.11469748f,0.13930011f,0.005731758f,0.03159248f,0.018799406f,0.028927676f,0.03472497f,0.01896616f,0.078822434f,0.0f,0.015187703f,0.0f,0.033177234f,0.07078301f,0.0f,0.0f,0.012238876f,0.0f,0.0f,0.0f,0.0f,0.0f,0.10691055f,0.008628067f,0.0f,0.0f,0.0f,0.0f,0.0f,0.06886323f,0.0f,0.0f,0.10460233f,0.0f,0.0f,0.0f,0.0f,0.0f,0.012850592f,0.0f,0.0f,0.103946045f,0.0f,0.018065454f,0.046292588f,0.063853905f,0.0f,0.10074362f,0.110680245f,0.10660499f,0.0f,0.06775707f,0.0f,0.0f,0.0f,0.0f,0.01639176f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.024850294f,0.0f,0.05308442f,0.0f,0.0f,0.0f,0.028708044f,0.02787582f,0.0f,0.0f,0.0f,0.0f,0.0f,0.027483912f,0.11719905f,0.04781422f,0.09033941f,0.09655397f,0.0032244714f,0.0f,0.0f,0.09580024f,0.07075415f,0.0876085f,0.0f,0.08756652f,2.2919098E-4f,0.019845076f,0.0f,0.0f,0.0f,0.04727701f,0.007276267f,0.0f,0.12660357f,0.03222255f,0.00182945f,0.0f,0.0f,0.0f,0.0f,0.0f,0.09080463f,0.0043369643f,0.0f,0.0f,0.0f,0.0f,0.0f,0.073994264f,0.0f,0.030294247f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.04890032f,0.0f,0.05318369f,0.0f,0.0f,0.0510094f,0.0f,0.0f,0.0f,0.12696978f,0.0f,0.21870367f,0.0f,0.0f,0.0f,0.0f,0.022256335f,0.13343737f,0.0f,0.0f,0.0f,0.0f,0.025192281f,0.1621841f,0.031685397f,0.0f,0.0f,0.03057069f,0.011333849f,0.0f,0.0f,0.11482361f,0.10645431f,0.0f,0.0f,0.0f,0.039949007f,0.0f,0.018850718f,0.0f,0.0f,0.0f,0.026081126f,0.0f,0.07934742f,0.0f,0.0f,0.0f,0.0f,0.0f,0.007998445f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.098366655f,0.0f,0.01606297f,0.16557153f,0.0f,0.0f,0.0f,0.0f,0.042638786f,0.024310581f,0.0f,0.027677892f,5.940642E-4f,0.0f,0.0f,0.0f,0.0f,0.05605889f,0.0f,0.0f,0.05216231f,0.080610394f,0.0f,0.0f,0.0f,0.0f,0.0f,0.07294793f,0.0f,0.02029506f,0.0f,0.0f,0.0f,0.12797904f,0.16668096f,0.083295606f,0.07768397f,0.07725612f,0.0f,0.0f,0.07259326f,0.110221304f,0.0f,0.0f,0.0f,0.0f,0.0f,0.027140126f,0.0f,0.053474665f,0.0f,0.018286237f,0.0f,0.0f,0.12152021f,0.0f,0.0f,0.0822232f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.04146023f,0.0f,0.034810524f,0.0f,0.00393797f,0.0f,0.032150663f,0.0f,0.0f,0.0f,0.0f,0.097777024f,0.0f,0.0f,0.0f,0.078797914f,0.0f,0.1292709f,0.0f,0.010623558f,0.0f,0.08360209f,0.0f,0.0f,0.122683614f,0.0f,0.0f,0.0f,0.0f,0.10111214f,0.10537941f,0.0f,0.0f,0.0f,0.0f,0.06272928f,0.0f,0.029668529f,0.049171254f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.032665383f,0.0f,0.0f,0.044802308f,0.0f,0.0f,0.05388319f,0.0f,0.0f,0.079517856f,0.012373011f,0.0f,0.01629987f,0.014890053f,0.0f,0.027798127f,0.0f,0.051028676f,0.15331483f,0.024111472f,0.0f,0.0f,0.0f,0.0f,0.0f,0.013699274f,0.0f,0.0f,0.07011116f,0.03022409f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.10939847f,0.0f,0.0f,0.0f,0.030306451f,0.0f,0.0f,0.12335773f,0.0f,0.0f,0.09099765f,0.0f,0.0f,0.0073862355f,0.0f,0.027847463f,0.0840776f,0.04207977f,0.046206567f,0.06679146f,0.0f,0.0f,0.01625976f,0.022998374f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.059722353f,0.0f,0.012246756f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.04984296f,0.0f,0.1435546f,0.0f,0.14667304f,0.079418436f,0.1305641f,0.014777453f,0.0f,0.020115053f,0.05619832f,0.011520876f,0.0f,0.11993748f,0.009492612f,0.019984039f,0.0f,0.0f,0.0f,0.0f,0.043548502f,0.0f,0.0f,0.0f,0.0f,0.15310751f,0.0f,0.0f,0.0f,0.0f,0.0f,0.018038996f,0.0021733972f,0.0f,0.0f,0.0f,0.0f,0.060995787f,0.06326715f,0.0f,0.06896745f,0.013745607f,5.018817E-4f,0.0f,0.0f,0.14184067f,0.019447664f,0.047488734f,0.13301876f,0.0f,0.0f,0.06715185f,0.0f,0.0f,0.0025059544f,0.0013816518f,0.0f,0.00415278f,0.17231767f,0.0073697185f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0017814273f,0.040113796f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.016738398f,0.0f,0.10465313f,0.0f,0.057283692f,0.0f,0.05406501f,0.0f,0.012632326f,0.0f,0.0f,0.0f,0.10440692f,0.0f,0.0047152108f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.11544036f
// )
// )
// )
// ObjectBox.remove("测试")
// ObjectBox.safeDbOp {
// val vector2 = floatArrayOf(
// 0.029274145f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.04849013f,0.0f,0.0f,0.0030461443f,0.0f,0.04333778f,0.0f,0.036458552f,0.0f,0.0f,0.032146808f,0.0f,0.0f,0.0f,0.0f,0.12563168f,0.16337962f,0.0086008925f,0.028651662f,0.05676712f,0.038951095f,0.035215054f,0.023515426f,0.09426005f,0.0f,0.0f,0.0f,0.019520408f,0.09774056f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.12273409f,0.03317896f,0.0f,0.0f,0.0f,0.0f,0.0f,0.056036845f,0.0f,0.005601604f,0.09232295f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.10639726f,0.017117623f,0.0f,0.04106111f,0.06582154f,0.0f,0.10570599f,0.10395363f,0.11154216f,0.0f,0.09019618f,0.0f,0.0f,0.0f,0.0f,0.008780864f,0.0f,0.0f,0.01997713f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.016066022f,0.020096382f,0.0f,0.05578338f,0.0f,0.0f,0.0f,0.048834037f,0.031760734f,0.0f,0.0f,0.0f,0.0f,0.0f,0.02459963f,0.11993096f,0.047805987f,0.070974134f,0.10536186f,0.0f,0.0f,0.0f,0.111894555f,0.08973926f,0.071053386f,0.0f,0.071883f,0.005135076f,0.0f,0.0f,0.0f,0.0f,0.023139987f,0.027882056f,0.0f,0.12620124f,0.05076331f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.06944067f,0.0146693f,0.0f,0.0f,0.0f,0.0f,0.00978479f,0.04965295f,0.0f,0.03953311f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.05496083f,0.0f,0.05957852f,0.0f,0.0f,0.05285405f,0.0f,0.0f,0.0f,0.10977843f,0.0f,0.21329264f,0.0f,0.0f,0.0f,0.0f,0.022029493f,0.12511873f,0.0f,0.0f,0.0f,0.0f,0.015391711f,0.1697028f,0.035269253f,0.0f,0.0f,0.0070471396f,0.0f,0.0f,0.0f,0.123943165f,0.10272677f,0.0f,0.0f,0.0f,0.06351426f,0.0f,0.009808158f,0.0f,0.0f,0.0f,0.02582221f,0.0f,0.0745801f,0.0f,0.0f,0.023285847f,0.0f,0.0f,0.017162288f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.09181752f,0.0f,0.007880242f,0.15574618f,0.0f,0.0f,0.0f,0.0f,0.02478441f,0.039601766f,0.0f,0.049401764f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0793202f,0.0f,0.0f,0.071133174f,0.09675928f,0.0f,0.015621623f,0.0f,0.0f,0.0f,0.08347399f,0.009147923f,0.0f,0.0f,0.0f,0.0f,0.14187747f,0.15219086f,0.12055488f,0.07175866f,0.07192898f,0.0f,0.0f,0.04634941f,0.12534219f,0.0f,0.0f,0.0f,0.008665879f,0.0f,0.040081337f,0.0f,0.06729515f,0.0f,0.058273762f,0.0020215393f,0.0f,0.10878004f,0.0f,0.0f,0.09762441f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.03849448f,0.0f,0.03300536f,0.0f,0.0f,0.013231625f,0.018584426f,0.0f,0.0f,0.0f,0.0f,0.08124468f,0.0f,0.0f,0.0f,0.060708806f,0.0f,0.11115098f,0.0f,0.0f,0.0f,0.0803424f,0.0f,0.0f,0.115781434f,0.0f,0.00764197f,0.0f,0.0f,0.121340856f,0.0869794f,0.0f,0.0f,0.0f,0.0f,0.045324527f,0.0f,0.015057986f,0.048937548f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.030864827f,0.0f,0.0029875587f,0.03196471f,0.0f,0.0f,0.054534424f,0.0f,0.0f,0.07863297f,0.019617077f,0.0f,0.026181744f,0.0f,0.0f,0.021914937f,0.0f,0.063720085f,0.16607703f,0.006567324f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0017898319f,0.0f,0.0f,0.04841979f,0.0044351136f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.15329078f,0.0f,0.0f,0.0f,0.030429695f,0.0f,0.0f,0.11840538f,0.0f,0.0f,0.107368395f,0.014730752f,0.0f,0.0f,0.0f,0.030180579f,0.039591953f,0.03209204f,0.048180148f,0.0676186f,0.0f,0.0f,0.03614289f,0.030257396f,1.5626202E-4f,0.018185573f,0.0f,0.0f,0.0f,0.0f,0.055325508f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.051894154f,0.0f,0.12869093f,0.0f,0.122797504f,0.083724305f,0.12840301f,0.0f,0.0f,0.0f,0.05686797f,0.0f,0.0f,0.11015593f,0.018203156f,0.020094728f,0.0f,0.0f,0.0f,0.0f,0.058597155f,0.0f,0.0f,0.0f,0.0f,0.12797132f,0.0f,0.006606713f,0.0f,0.0f,0.0f,0.028628595f,0.0f,0.0f,0.0f,0.0f,0.0f,0.050383586f,0.07132696f,0.0f,0.092209496f,0.0f,0.0f,0.0f,0.0f,0.14604862f,0.01660271f,0.057229653f,0.13104743f,0.0f,0.0f,0.06136706f,0.0f,0.0f,0.0f,0.0011910667f,0.0f,0.0f,0.15921566f,0.0f,0.0f,0.0f,0.0f,0.007558685f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0f,0.007532647f,0.05110198f,0.00667856f,0.0f,0.0f,0.0f,0.0f,0.0f,0.0016389234f,0.0f,0.0f,0.0f,0.0f,0.018518167f,0.0f,0.08580791f,0.0f,0.06099585f,0.0f,0.0366169f,0.0f,0.0f,0.0f,0.0f,0.0f,0.073218584f,0.0f,0.0f,0.0f,0.008933658f,0.0f,0.0f,0.0f,0.0f,0.0f,0.116691895f
// )
// ObjectBox.getBox<Food>()?.let {
// val query: Query<Food>? = it.query(
// Food_.foodVector
// .nearestNeighbors(vector2, 5)
// )?.build()
// try {
// val list = query?.findIdsWithScores()
// val resultList = mutableListOf<String>()
// list?.forEach { idWithScore ->
// val food = it.get(idWithScore.id)
// resultList.add("${food.name}|${food.id}|${idWithScore.score}")
// }
// Timber.tag(TAG).d("比较结果:${resultList.joinToString(separator = ",")}")
// } finally {
// // 先关闭Query,释放Cursor
// query?.close()
// }
// }
// }
// }
override fun onResume() {
super.onResume()
cameraUtils.bind()
cameraUtils.setPreviewController(previewView)
binding.root.postDelayed({
SensorScaleUtils.addWeightListener(weightIntCallback)
}, 1 * 1500)
}
private var weightIntCallback: WeightCallback = {
runOnUiThread {
try {
weightCallback?.invoke(it)
recognizeWeight(weight = it)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
private var weightCallback: ((weight: Int) -> Unit)? = null
@@ -238,13 +216,13 @@ abstract class GoodsListActivity : BaseActivity() {
binding.tvTotalWeight.text = "重量:${weight}"
}
if (weight < 200) {
if (weight < 10) {
if (weight < 20) {
// val clazzName = DialogManager.getDialogList()
// .map { it.javaClass.simpleName }
// .joinToString(",")
// Timber.tag(TAG).d(clazzName)
if (DialogManager.getDialogList().size == 1 &&
DialogManager.contains("GoodsRecognizeDialog") &&
// 无其他弹窗正在显示时(过滤幽灵条目),自动关闭识别弹窗
if (DialogManager.getDialogList().none { it.isShowing && it != goodsRecognizeDialog } &&
goodsRecognizeDialog?.isShowing == true
) {
goodsRecognizeDialog?.dismiss()
@@ -281,8 +259,9 @@ abstract class GoodsListActivity : BaseActivity() {
searchFood(uri)
} catch (e: Exception) {
e.printStackTrace()
Loading.dismiss()
}
}.start()
}
}
private val delayHandler by lazy {
@@ -298,7 +277,7 @@ abstract class GoodsListActivity : BaseActivity() {
}
}
fun recognizeFood() {
open fun recognizeFood() {
if (isShowRecognizeDialog.not()) {
if (goodsRecognizeDialog == null) {
isShowRecognizeDialog = true
@@ -423,19 +402,22 @@ abstract class GoodsListActivity : BaseActivity() {
// readBitmap(it)
// }
// }
ImageUtil.uriToBitmap(this, uri)?.let { bitmap ->
val newBmp = BitmapCropper.cropCenter(
original = bitmap,
targetWidth = 1000, targetHeight = 1300,
offsetX = 30, offsetY = 100
)
readBitmap(newBmp)
if (newBmp.isRecycled.not()) {
newBmp.recycle()
}
if (bitmap.isRecycled.not()) {
bitmap.recycle()
}
val bitmap = ImageUtil.uriToBitmap(this, uri)
if (bitmap == null) {
Loading.dismiss()
return
}
val newBmp = BitmapCropper.cropCenter(
original = bitmap,
targetWidth = 1000, targetHeight = 1300,
offsetX = 30, offsetY = 100
)
readBitmap(newBmp)
if (newBmp.isRecycled.not()) {
newBmp.recycle()
}
if (bitmap.isRecycled.not()) {
bitmap.recycle()
}
}
@@ -478,7 +460,7 @@ abstract class GoodsListActivity : BaseActivity() {
goodsFile = file,
goodsName = name
) {
GoodsSearchDialog(this).show()
onGoodsSearchRequested()
}.apply {
if (isFinishing.not() && isDestroyed.not()) {
//isShowRecognizeDialog = false
@@ -490,6 +472,11 @@ abstract class GoodsListActivity : BaseActivity() {
var goodsRecognizeDialog: GoodsRecognizeDialog? = null
/** 子类可重写以替换物品检索弹窗 */
protected open fun onGoodsSearchRequested() {
GoodsSearchDialog(this).show()
}
private fun replaceIncludeContent() {
binding.flContainer.removeAllViews()
//isReceiptPage
@@ -497,15 +484,18 @@ abstract class GoodsListActivity : BaseActivity() {
val tempBinding =
ListItemReceiptGoodsBinding.inflate(layoutInflater, binding.flContainer, false)
binding.flContainer.addView(tempBinding.root)
} else {
} else if (this is SelfProcurementActivity) {
val tempBinding =
ListItemSelfProcurementBinding.inflate(layoutInflater, binding.flContainer, false)
binding.flContainer.addView(tempBinding.root)
} else {
binding.flContainer.gone()
}
}
override fun onPause() {
super.onPause()
SensorScaleUtils.removeWeightListener(weightIntCallback)
unBindCamera()
}
@@ -670,11 +660,12 @@ abstract class GoodsListActivity : BaseActivity() {
val uploadInfo = UploadInfo(
id = id,
supplierId = supplierId,
purCode = purCode,
receiveGoodsInfos = goodsList
)
logInfo("receiptSubmit确认收货提交数据:${uploadInfo.toJsonString()}")
showWaitingDialog2("加载中")
receiptViewModel.confirmReceipt(uploadInfo) { success ->
receiptViewModel.confirmReceipt(uploadInfo) { success, msg ->
hideWaitingDialog()
if (success) {
userViewModel.deleteKey(localGoodsKey)
@@ -683,7 +674,7 @@ abstract class GoodsListActivity : BaseActivity() {
finish()
}, 200)
} else {
toast("确认收货失败")
toast("确认收货失败$msg")
}
}
}
@@ -713,7 +704,7 @@ abstract class GoodsListActivity : BaseActivity() {
}
logInfo("selfProcurementSubmit自采入库提交数据:${submitData.toJsonString()}")
showWaitingDialog2("加载中")
receiptViewModel.addToWarehouse(submitData) { success ->
receiptViewModel.addToWarehouse(submitData) { success, msg ->
hideWaitingDialog()
if (success) {
toast("提交入库成功")
@@ -722,7 +713,7 @@ abstract class GoodsListActivity : BaseActivity() {
finish()
}, 200)
} else {
toast("提交入库失败")
toast("提交入库失败$msg")
}
}
}
@@ -3,19 +3,24 @@ package com.sw.inbound.activity
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.KeyEvent
import androidx.activity.viewModels
import com.sw.inbound.base.BaseActivity
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.sw.inbound.databinding.ActivityHomeBinding
import com.sw.inbound.dialog.EnvSwitchDialog
import com.sw.inbound.dialog.Loading
import com.sw.inbound.model.response.User
import com.sw.inbound.objbox.FoodModule
import com.sw.inbound.objbox.ObjectBox
import com.sw.inbound.sdk.SensorScaleUtils
import com.sw.inbound.utils.DateTimeUtils
import com.sw.inbound.utils.ThreadUtils
import com.sw.inbound.utils.ext.gone
import com.sw.inbound.utils.ext.startActivity
import com.sw.inbound.utils.ext.toast
import com.sw.inbound.utils.ext.visible
import com.sw.inbound.viewmodel.UserViewModel
import dagger.hilt.android.AndroidEntryPoint
@@ -45,6 +50,10 @@ class HomeActivity : BaseActivity() {
}
}
/** 用于检测双击返回键的时间戳 */
private var lastBackPressTime = 0L
private val backPressInterval = 2000L // 2 秒内连续按下返回键视为双击
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityHomeBinding.inflate(layoutInflater)
@@ -68,19 +77,48 @@ class HomeActivity : BaseActivity() {
*/
private fun initViews() {
// 标题点击:跳转菜品采集页面
binding.tvTitle.setOnClickListener {
startActivity<FoodCollectionActivity>()
// binding.tvTitle.setOnClickListener {
// startActivity<FoodCollectionActivity>()
// }
//
// 时间点击:打开环境切换弹窗
binding.tvTime.setOnClickListener { v ->
EnvSwitchDialog(context = this, onEnvChanged = {
Loading.show(this)
lifecycleScope.launch {
// ObjectBox.removeAll()
// FoodModule.init(this@HomeActivity)
v.postDelayed({
Loading.dismiss()
toast("服务重置完成")
}, 500)
}
}).show()
}
// 采购单入库入口
binding.ivOrderPurchase.setOnClickListener {
startActivity<PurchaseOrderActivity>()
// // 采购单入库入口
// binding.ivOrderPurchase.setOnClickListener {
// startActivity<PurchaseOrderActivity>()
// }
//
// // 自采入库入口,传入非采购单标志
// binding.ivOrderSelfProcurement.setOnClickListener {
// startActivity<SelfProcurementActivity> {
// putExtra(GoodsListActivity.IS_RECEIPT_PAGE, false)
// }
// }
// 净菜按钮点击提示
binding.btnCleanDishStore.setOnClickListener {
startActivity<StoreActivity> {
putExtra(StoreActivity.EXTRA_STORE_TYPE, 0)
}
}
// 自采入库入口,传入非采购单标志
binding.ivOrderSelfProcurement.setOnClickListener {
startActivity<SelfProcurementActivity> {
putExtra(GoodsListActivity.IS_RECEIPT_PAGE, false)
// 毛菜按钮点击提示
binding.btnRawDishStore.setOnClickListener {
startActivity<StoreActivity> {
putExtra(StoreActivity.EXTRA_STORE_TYPE, 1)
}
}
}
@@ -128,4 +166,27 @@ class HomeActivity : BaseActivity() {
// 页面不可见时停止刷新,避免内存泄漏
handler.removeCallbacks(timeRunnable)
}
/**
* 处理返回键事件
* 双击返回键退出应用,单击返回键提示再次点击退出
*/
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return if (keyCode == KeyEvent.KEYCODE_BACK) {
val currentTime = System.currentTimeMillis()
if (currentTime - lastBackPressTime < backPressInterval) {
// 双击返回键,退出应用
finish()
true
} else {
// 单击返回键,显示提示
lastBackPressTime = currentTime
this.toast("再按一次返回键退出应用")
true
}
} else {
super.onKeyDown(keyCode, event)
}
}
}
@@ -0,0 +1,190 @@
package com.sw.inbound.activity
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.view.KeyEvent
import android.view.animation.RotateAnimation
import com.sw.inbound.GlobalData
import com.sw.inbound.GlobalKey
import com.sw.inbound.base.BaseActivity
import com.sw.inbound.databinding.ActivityInitBinding
import com.sw.inbound.utils.NetworkUtils
import com.sw.inbound.utils.SPUtil
/**
* 初始化页面 Activity
* 用于应用启动时的初始化流程
* - 显示 60 秒倒计时
* - 每 10 秒检测一次网络连接
* - 网络连接成功则跳转到 HomeActivity
* - 60 秒后仍未连接则显示"连接网络"按钮
*/
class InitActivity : BaseActivity() {
private lateinit var binding: ActivityInitBinding
private val handler = Handler(Looper.getMainLooper())
private var startTime = 0L // 倒计时开始时间
private var lastNetworkCheckTime = 0L // 上次检测网络的时间
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityInitBinding.inflate(layoutInflater)
setContentView(binding.root)
initViews()
}
/**
* 初始化控件事件
*/
private fun initViews() {
// 连接网络按钮点击事件
binding.btnConnectNetwork.setOnClickListener {
openNetworkSettings()
}
// 启动加载动画 - 持续旋转
startLoadingAnimation()
}
/**
* 启动加载动画 - ImageView 持续旋转
*/
private fun startLoadingAnimation() {
val rotateAnimation = RotateAnimation(
0f, 360f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f,
RotateAnimation.RELATIVE_TO_SELF, 0.5f
).apply {
duration = 1200 // 旋转周期 1.2 秒
repeatCount = RotateAnimation.INFINITE // 无限循环
repeatMode = RotateAnimation.RESTART
}
binding.ivLoading.startAnimation(rotateAnimation)
}
override fun onResume() {
super.onResume()
if (NetworkUtils.isNetworkConnected(this)) {
navigateToHome()
return
}
startCountdown()
}
override fun onPause() {
super.onPause()
// 页面不可见时停止倒计时
handler.removeCallbacksAndMessages(null)
}
/**
* 启动 60 秒倒计时
*/
private fun startCountdown() {
startTime = SystemClock.elapsedRealtime()
lastNetworkCheckTime = 0
binding.llLoading.visibility = android.view.View.VISIBLE
binding.llNetworkButton.visibility = android.view.View.GONE
binding.tvCountdown.text = "60秒"
scheduleCountdown()
}
/**
* 定时更新倒计时
* 使用 SystemClock.elapsedRealtime() 确保精确计时
*/
private fun scheduleCountdown() {
handler.postDelayed({
val elapsedTime = SystemClock.elapsedRealtime() - startTime
val remainingTime = 60 - (elapsedTime / 1000).toInt()
val currentNetworkCheckTime = (elapsedTime / 1000).toInt()
// 每 10 秒检测一次网络
if (currentNetworkCheckTime > 0 && currentNetworkCheckTime % 10 == 0 && currentNetworkCheckTime != lastNetworkCheckTime.toInt()) {
lastNetworkCheckTime = currentNetworkCheckTime.toLong()
checkNetworkConnection()
}
// 倒计时未结束,继续更新 UI
if (remainingTime > 0) {
binding.tvCountdown.text = "${remainingTime}"
scheduleCountdown()
} else {
// 倒计时结束,最后检测一次网络
if (NetworkUtils.isNetworkConnected(this)) {
navigateToHome()
} else {
// 网络未连接,显示"连接网络"按钮
showNetworkButton()
}
}
}, 100) // 每 100ms 检查一次,确保精确性
}
/**
* 检测网络连接
*/
private fun checkNetworkConnection() {
if (NetworkUtils.isNetworkConnected(this)) {
// 网络连接成功,停止倒计时并跳转到 HomeActivity
handler.removeCallbacksAndMessages(null)
navigateToHome()
}
}
/**
* 跳转到 HomeActivity
*/
private fun navigateToHome() {
// 优先读取用户手动切换后持久化的 url
val savedUrl = SPUtil.getInstance().get(GlobalKey.KEY_BASE_URL, "")
if (!savedUrl.isNullOrEmpty()) {
GlobalData.appBaseUrl = savedUrl
} else {
// 未保存过则使用默认逻辑:特定设备走测试环境,其余走 UAT
GlobalData.appBaseUrl = GlobalData.PROD_BASE_URL
val list = listOf(
//"5a41a2cf-3cee-3731-911d-a28b10164f7a",
"f0bcabbb-0d58-3b14-8bed-9863b7ec61ef"
)
if (list.contains(GlobalData.deviceId)) {
GlobalData.appBaseUrl = GlobalData.LOCAL_BASE_URL
}
}
val intent = Intent(this, HomeActivity::class.java)
startActivity(intent)
finish()
}
/**
* 显示"连接网络"按钮
*/
private fun showNetworkButton() {
binding.llLoading.visibility = android.view.View.GONE
binding.llNetworkButton.visibility = android.view.View.VISIBLE
}
/**
* 打开系统网络设置页面
*/
private fun openNetworkSettings() {
val intent = Intent(android.provider.Settings.ACTION_WIFI_SETTINGS)
startActivity(intent)
}
/**
* 禁用返回键
*/
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
return if (keyCode == KeyEvent.KEYCODE_BACK) {
// 禁用返回键,不执行任何操作
true
} else {
super.onKeyDown(keyCode, event)
}
}
}
@@ -1,5 +1,6 @@
package com.sw.inbound.activity
import android.annotation.SuppressLint
import android.app.Dialog
import android.content.Context
import android.graphics.Color
@@ -9,11 +10,11 @@ import android.view.ViewGroup
import android.view.WindowManager
import androidx.core.graphics.drawable.toDrawable
import androidx.lifecycle.lifecycleScope
import com.sw.inbound.base.BaseActivity
import androidx.recyclerview.widget.GridLayoutManager
import coil.load
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.sw.inbound.base.BaseActivity
import com.sw.inbound.databinding.ActivityLocalImagePreviewBinding
import com.sw.inbound.databinding.DialogImageFullPreviewBinding
import com.sw.inbound.databinding.ListItemImagePreviewBinding
@@ -23,6 +24,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
class LocalImagePreviewActivity : BaseActivity() {
@@ -57,7 +60,7 @@ class LocalImagePreviewActivity : BaseActivity() {
val nameWithoutExt = it.nameWithoutExtension
// 兼容 IMG_CROP_{时间戳} 和其他命名格式
val displayName = if (nameWithoutExt.startsWith("IMG_CROP_")) {
nameWithoutExt.removePrefix("IMG_CROP_")
df.format(Date(nameWithoutExt.removePrefix("IMG_CROP_").toLong()))
} else {
nameWithoutExt
}
@@ -74,6 +77,9 @@ class LocalImagePreviewActivity : BaseActivity() {
}
}
@SuppressLint("SimpleDateFormat")
private val df = SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
/** 弹出全屏大图预览 Dialog,支持左右切换 */
private fun showFullImagePreview(startPosition: Int) {
var currentIndex = startPosition
@@ -41,6 +41,7 @@ class PurchaseOrderActivity : BaseActivity() {
startActivity<ReceiptActivity> {
putExtra(GoodsListActivity.ID, item.id)
putExtra(GoodsListActivity.SUPPLIER_ID, item.supplierId)
putExtra(GoodsListActivity.PUR_CODE, item.purCode)
putExtra(GoodsListActivity.IS_RECEIPT_PAGE, true)
//putExtra(GoodsListActivity.IS_NEW_RECEIPT, false)
}
@@ -5,6 +5,7 @@ import androidx.recyclerview.widget.LinearLayoutManager
import com.sw.inbound.R
import com.sw.inbound.adapter.ReceiptGoodsAdapter
import com.sw.inbound.model.response.DictType
import com.sw.inbound.model.response.GoodsInfo
import com.sw.inbound.utils.ext.clickWithDebounce
import com.sw.inbound.utils.ext.toJsonString
import com.sw.inbound.utils.ext.toast
@@ -83,12 +84,7 @@ class ReceiptActivity : GoodsListActivity() {
binding.tvPurchaseNo.text = "采购单号:${detail.purCode}"
val list = detail.receiveGoodsInfoList
list.forEach { goodsInfo ->
goodsInfo.recUnitPriceTaxInBak = goodsInfo.recUnitPriceTaxIn
goodsInfo.recUnitPriceTaxIn = 0.0
goodsInfo.recPriceExItemBak = goodsInfo.recPriceExItem
goodsInfo.receiveCountBak = goodsInfo.receiveCount
goodsInfo.unitNameBak = goodsInfo.unitName
goodsInfo.receivedNumBak = goodsInfo.receivedNum
resetGoods(goodsInfo)
}
goodsList.addAll(list)
adapter.notifyDataSetChanged()
@@ -97,6 +93,15 @@ class ReceiptActivity : GoodsListActivity() {
}
}
fun resetGoods(item: GoodsInfo) {
item.recUnitPriceTaxInBak = item.recUnitPriceTaxIn
item.recUnitPriceTaxIn = 0.0
item.recPriceExItemBak = item.recPriceExItem
item.receiveCountBak = item.receiveCount
item.unitNameBak = item.unitName
item.receivedNumBak = item.receivedNum
}
override fun getWarehouseList(): List<DictType> {
return goodsList.filter {
it.warehouseId.isNullOrBlank().not() && it.warehouseName.isNullOrBlank().not()
@@ -0,0 +1,86 @@
package com.sw.inbound.activity
import android.os.Bundle
import androidx.activity.viewModels
import com.sw.inbound.GlobalData
import com.sw.inbound.dialog.InboundGoodsSearchDialog
import com.sw.inbound.model.response.DictType
import com.sw.inbound.utils.ext.gone
import com.sw.inbound.viewmodel.StoreViewModel
import dagger.hilt.android.AndroidEntryPoint
/**
* 入库称重页面
* 继承 GoodsListActivity 以复用秤监听、相机拍照、菜品识别弹窗等核心能力,
* 通过隐藏不需要的 View 精简界面,避免大量重复代码。
*/
@AndroidEntryPoint
class StoreActivity : GoodsListActivity() {
val storeViewModel: StoreViewModel by viewModels()
/** 0=净菜,1=毛菜,由 HomeActivity 通过 Intent 传入 */
var storeType: Int = 0
/** 防止 onResume 重复弹窗 */
private var dialogShown = false
override fun onCreate(savedInstanceState: Bundle?) {
storeType = intent.getIntExtra(EXTRA_STORE_TYPE, 0)
super.onCreate(savedInstanceState)
hideUnusedViews()
}
override fun onResume() {
super.onResume()
preloadInboundOptions()
if (!dialogShown) {
dialogShown = true
showEntryDialog()
}
}
/** 进入页面统一打开检索弹窗,dismiss 时关闭页面 */
private fun showEntryDialog() {
InboundGoodsSearchDialog(this).apply {
setOnDismissListener { finish() }
}.show()
}
/** 预加载供应商和仓库区域列表到 GlobalData,供弹窗直接使用 */
private fun preloadInboundOptions() {
storeViewModel.loadSupplierOptions(callback = { GlobalData.inboundSupplierList = it })
storeViewModel.loadZoneOptions(callback = { GlobalData.inboundZoneList = it })
storeViewModel.loadSpecOptions(callback = { GlobalData.specOptionList = it })
}
/** 隐藏采购单列表、仓库下拉、提交按钮等本页面不需要的控件 */
private fun hideUnusedViews() {
binding.flWarehouseDropdown.gone()
binding.cvDropdownBg.gone()
binding.tvPurchaseNo.gone()
binding.btnConfirmReceipt.gone()
binding.tvConfirmNum.gone()
binding.tvNotConfirmNum.gone()
binding.flContainer.gone()
binding.rvGoodsList.gone()
}
override fun initRecyclerView() {}
override fun initView() {}
/** 入库页面暂不需要识别功能 */
override fun recognizeFood() {}
override fun getWarehouseList(): List<DictType> = emptyList()
/** 使用入库专用检索弹窗替代原有 GoodsSearchDialog */
override fun onGoodsSearchRequested() {
InboundGoodsSearchDialog(this).show()
}
companion object {
const val EXTRA_STORE_TYPE = "storeType"
}
}
@@ -8,12 +8,13 @@ import androidx.core.graphics.toColorInt
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.sw.inbound.databinding.ListItemCollectSearchBinding
import com.sw.inbound.model.response.CollectedFoodInfo
import com.sw.inbound.model.response.SearchGoodsInfo
import com.sw.inbound.utils.ext.dp
import com.sw.inbound.utils.ext.setShapeDrawable
class CollectSearchAdapter(var list: MutableList<SearchGoodsInfo.Record>) :
BaseQuickAdapter<SearchGoodsInfo.Record, CollectSearchAdapter.VH>(list) {
class CollectSearchAdapter(var list: MutableList<CollectedFoodInfo>) :
BaseQuickAdapter<CollectedFoodInfo, CollectSearchAdapter.VH>(list) {
inner class VH(var binding: ListItemCollectSearchBinding) : QuickViewHolder(binding.root)
@@ -23,19 +24,20 @@ class CollectSearchAdapter(var list: MutableList<SearchGoodsInfo.Record>) :
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: SearchGoodsInfo.Record?) {
override fun onBindViewHolder(holder: VH, position: Int, item: CollectedFoodInfo?) {
holder.binding.let {
var goodsName = item!!.goodsName?.split("WP")?.get(0)
val goodsName = item!!.foodName.split("WP")[0]
// if (goodsName?.contains("\n") == true) {
// goodsName = goodsName?.split("\n")?.get(0)
// }
it.tvGoodsName.run {
text = Html.fromHtml(goodsName)
setTextColor(if (item!!.isSelected) "#FF0032C8".toColorInt() else "#FF666666".toColorInt())
setTextColor(if (item.isSelected) "#FF0032C8".toColorInt() else "#FF666666".toColorInt())
//setTextColor("#FF666666".toColorInt())
}
it.tvGoodsNum.text = "已采集${item.foodNum}"
it.root.run {
if (item!!.isSelected) {
if (item.isSelected) {
setShapeDrawable(
solidColor = "#FFEAF0FF",
strokeColor = "#FF0033CC",
@@ -0,0 +1,185 @@
package com.sw.inbound.adapter
import android.app.DatePickerDialog
import android.content.Context
import android.text.Editable
import android.text.InputType
import android.text.SpannableStringBuilder
import android.text.TextWatcher
import android.text.style.ForegroundColorSpan
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.graphics.toColorInt
import com.chad.library.adapter4.BaseQuickAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.sw.inbound.R
import com.sw.inbound.databinding.ListItemFormFieldBinding
import com.sw.inbound.dialog.DropdownPopup
import com.sw.inbound.model.FieldType
import com.sw.inbound.model.FormField
import com.sw.inbound.utils.ext.dp
import com.sw.inbound.utils.ext.gone
import com.sw.inbound.utils.ext.hideKeyboard
import com.sw.inbound.utils.ext.visible
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import kotlin.math.min
import kotlin.text.clear
import kotlin.text.get
import kotlin.text.set
/**
* 动态表单字段 Adapter,配合 FlexboxLayoutManager 实现两列自动换行布局
* 单一 item 布局,通过 visibility 控制 TEXT/NUMBER/FIXED/DROPDOWN/DATE_PICKER 五种类型渲染
*
* @param activityContext Activity context,用于创建 DatePickerDialog
* @param fields 表单字段列表(hidden=false 的字段,由外部过滤后传入)
*/
class FormFieldAdapter(
private val activityContext: Context,
fields: MutableList<FormField>
) : BaseQuickAdapter<FormField, FormFieldAdapter.VH>(fields) {
private val isoFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
private val displayDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
inner class VH(val binding: ListItemFormFieldBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemFormFieldBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: FormField?) {
item ?: return
val b = holder.binding
// 设置标签,必填项追加红色星号
b.tvLabel.text = buildLabelSpannable(item.label, item.required)
// 先全部隐藏,再按类型显示对应控件
b.etInput.gone()
b.flDropdown.gone()
b.flDatePicker.gone()
// 移除旧的 TextWatcher,防止 RecyclerView 复用时错误触发
(b.etInput.tag as? TextWatcher)?.let { b.etInput.removeTextChangedListener(it) }
b.etInput.tag = null
when (item.type) {
FieldType.TEXT -> bindEditText(b, item, InputType.TYPE_CLASS_TEXT)
FieldType.NUMBER -> bindEditText(
b, item,
InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL or InputType.TYPE_NUMBER_FLAG_SIGNED
)
FieldType.FIXED -> bindFixed(b, item)
FieldType.DROPDOWN -> bindDropdown(b, item)
FieldType.DATE_PICKER -> bindDatePicker(b, item)
}
}
/** 绑定可编辑文本框(TEXT / NUMBER */
private fun bindEditText(b: ListItemFormFieldBinding, item: FormField, inputType: Int) {
b.etInput.visible()
b.etInput.isEnabled = true
b.etInput.inputType = inputType
b.etInput.hint = item.hint.ifBlank { item.label }
b.etInput.setBackgroundResource(R.drawable.bg_white_radius10_stroke2)
b.etInput.setText(item.value)
b.etInput.setSelection(b.etInput.text.length)
val watcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
item.value = s?.toString() ?: ""
}
}
b.etInput.addTextChangedListener(watcher)
b.etInput.tag = watcher
}
/** 绑定固定值(不可编辑,灰色背景区分) */
private fun bindFixed(b: ListItemFormFieldBinding, item: FormField) {
b.etInput.visible()
b.etInput.isEnabled = false
b.etInput.inputType = InputType.TYPE_CLASS_TEXT
b.etInput.hint = ""
b.etInput.setBackgroundResource(R.drawable.bg_gray_radius10)
b.etInput.setText(item.value)
}
/** 绑定下拉选择框,点击时创建 DropdownPopup */
private fun bindDropdown(b: ListItemFormFieldBinding, item: FormField) {
b.flDropdown.visible()
b.tvDropdownValue.hint = item.hint.ifBlank { "请选择${item.label}" }
b.tvDropdownValue.text = item.value.ifBlank { null }
b.flDropdown.setOnClickListener { v ->
v.hideKeyboard()
val popHeight = min(item.options.size.coerceAtLeast(1), 4) * 71.dp
DropdownPopup(
context = activityContext,
list = item.options.toMutableList(),
popWidth = v.width,
popHeight = popHeight
) { dictType ->
item.value = dictType.value ?: ""
b.tvDropdownValue.text = item.value
// 将 extraApiKeys 映射的 DictType 字段值写入 extraValues
item.extraValues.clear()
item.extraApiKeys.forEach { (apiKey, dictField) ->
item.extraValues[apiKey] = dictType[dictField]
}
}.also {
it.bgLayout = b.flDropdown
it.showAsDropDown(v)
}
}
}
/** 绑定日期选择器,点击时弹出 DatePickerDialog */
private fun bindDatePicker(b: ListItemFormFieldBinding, item: FormField) {
b.flDatePicker.visible()
b.tvDateValue.hint = item.hint.ifBlank { "请选择日期" }
b.tvDateValue.text = item.value.ifBlank { null }
b.flDatePicker.setOnClickListener { v ->
v.hideKeyboard()
val cal = Calendar.getInstance()
DatePickerDialog(
activityContext,
{ _, year, month, dayOfMonth ->
val selected = Calendar.getInstance().apply {
set(year, month, dayOfMonth, 0, 0, 0)
set(Calendar.MILLISECOND, 0)
}
// value 存显示格式,valueId 存 ISO 格式供提交使用
item.value = displayDateFormat.format(selected.time)
b.tvDateValue.text = item.value
},
cal.get(Calendar.YEAR),
cal.get(Calendar.MONTH),
cal.get(Calendar.DAY_OF_MONTH)
).show()
}
}
/** 构建带红色星号的必填标签 */
private fun buildLabelSpannable(label: String, required: Boolean): SpannableStringBuilder {
return SpannableStringBuilder().apply {
append(label)
if (required) {
val start = length
append(" *")
setSpan(
ForegroundColorSpan("#FFCC0000".toColorInt()),
start, length,
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
}
}
@@ -0,0 +1,56 @@
package com.sw.inbound.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.graphics.toColorInt
import androidx.recyclerview.widget.DiffUtil
import com.chad.library.adapter4.BaseDifferAdapter
import com.chad.library.adapter4.viewholder.QuickViewHolder
import com.sw.inbound.databinding.ListItemGoodsSearchBinding
import com.sw.inbound.model.response.GoodsSearchModel
import com.sw.inbound.utils.ext.dp
import com.sw.inbound.utils.ext.setShapeDrawable
class InboundSearchAdapter :
BaseDifferAdapter<GoodsSearchModel, InboundSearchAdapter.VH>(DIFF_CALLBACK) {
inner class VH(val binding: ListItemGoodsSearchBinding) : QuickViewHolder(binding.root)
override fun onCreateViewHolder(context: Context, parent: ViewGroup, viewType: Int): VH {
val binding = ListItemGoodsSearchBinding.inflate(LayoutInflater.from(context), parent, false)
return VH(binding)
}
override fun onBindViewHolder(holder: VH, position: Int, item: GoodsSearchModel?) {
holder.binding.tvGoodsName.run {
text = item?.materName
setTextColor(
if (item?.isSelected == true) "#FF0032C8".toColorInt()
else "#FF666666".toColorInt()
)
}
holder.binding.root.run {
if (item?.isSelected == true) {
setShapeDrawable(
solidColor = "#FFEAF0FF",
strokeColor = "#FF0033CC",
strokeWidth = 2.dp,
radius = 10.dp
)
} else {
setShapeDrawable(solidColor = "#FFF5F5F5", radius = 10.dp)
}
}
}
companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<GoodsSearchModel>() {
override fun areItemsTheSame(old: GoodsSearchModel, new: GoodsSearchModel) =
old.materId == new.materId
override fun areContentsTheSame(old: GoodsSearchModel, new: GoodsSearchModel) =
old == new
}
}
}
@@ -176,6 +176,7 @@ class ReceiptGoodsAdapter(var receiptActivity: ReceiptActivity, var list: Mutabl
item.recPriceInItem = 0.0
item.recUnitPriceTaxIn = 0.0
item.unitName = item.unitNameBak
item.goodsWeight = null
notifyItemChanged(position)
receiptActivity.run {
userViewModel.putGoodsData(localGoodsKey, list.toJsonString())
@@ -1,7 +1,7 @@
package com.sw.inbound.base
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
@@ -12,7 +12,7 @@ import androidx.core.view.WindowInsetsControllerCompat
* - 自动隐藏状态栏和导航栏,实现全屏沉浸式体验
* - 在屏幕底部上滑时可临时唤出导航栏,松手后自动收回
*/
abstract class BaseActivity : ComponentActivity() {
abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -0,0 +1,83 @@
package com.sw.inbound.di
import com.google.gson.GsonBuilder
import com.sw.inbound.GlobalData
import com.sw.inbound.MyApp
import com.sw.inbound.network.api.InboundApiService
import com.sw.inbound.network.interceptor.InboundInterceptor
import com.sw.inbound.network.interceptor.RequestInterceptor
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Named
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object InboundNetworkModule {
// private const val INBOUND_BASE_URL = "http://192.168.10.101:24801/"
private const val TIME_OUT = 30L
/** 动态域名拦截器:将 Retrofit 请求的 baseUrl 实时替换为 [GlobalData.appBaseUrl] */
class DynamicBaseUrlInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val newUrl = GlobalData.appBaseUrl.toHttpUrlOrNull() ?: return chain.proceed(request)
val url = request.url.newBuilder()
.scheme(newUrl.scheme)
.host(newUrl.host)
.port(newUrl.port)
.build()
return chain.proceed(request.newBuilder().url(url).build())
}
}
@Provides
@Singleton
@Named("inbound")
fun provideInboundHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(TIME_OUT, TimeUnit.SECONDS)
.readTimeout(TIME_OUT, TimeUnit.SECONDS)
.writeTimeout(TIME_OUT, TimeUnit.SECONDS)
.addNetworkInterceptor(HttpLoggingInterceptor(logger = {
Timber.d("inbound okhttp ==> $it")
}).apply {
level = if (MyApp.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
})
.addInterceptor(DynamicBaseUrlInterceptor())
// .addInterceptor(InboundInterceptor())
.addInterceptor(RequestInterceptor())
.build()
}
@Provides
@Singleton
@Named("inbound")
fun provideInboundRetrofit(@Named("inbound") okHttpClient: OkHttpClient): Retrofit {
val gson = GsonBuilder().setLenient().create()
return Retrofit.Builder()
.baseUrl(GlobalData.appBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
}
@Provides
@Singleton
fun provideInboundApiService(@Named("inbound") retrofit: Retrofit): InboundApiService {
return retrofit.create(InboundApiService::class.java)
}
}
@@ -1,5 +1,6 @@
package com.sw.inbound.di
import com.google.gson.GsonBuilder
import com.sw.inbound.MyApp
import com.sw.inbound.network.api.ApiService
import com.sw.inbound.network.interceptor.RequestInterceptor
@@ -51,11 +52,15 @@ object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
val gson = GsonBuilder()
.setLenient()
.create()
return Retrofit.Builder()
// .baseUrl(DEVICE_BASE_URL)
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
}
@@ -14,11 +14,17 @@ import com.sw.inbound.activity.GoodsListActivity
import com.sw.inbound.utils.ext.dp
import com.sw.inbound.utils.ext.hideKeyboard
//测试设备,小屏幕
private val devices = listOf(
"f0bcabbb-0d58-3b14-8bed-9863b7ec61ef",
"3dff871a-c66a-3fd8-93ce-81b57ead80f4"
)
abstract class BaseDialog(
context: Context,
var defWidth: Int = 1500.dp,
// var defHeight: Int = 900.dp
var defHeight: Int = if ("f0bcabbb-0d58-3b14-8bed-9863b7ec61ef" == GlobalData.deviceId) 750.dp else 900.dp
var defHeight: Int = if (devices.contains(GlobalData.deviceId)) 750.dp else 900.dp
) : Dialog(context, R.style.DialogTheme) {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -5,7 +5,9 @@ import android.content.Context
import android.view.LayoutInflater
import android.view.View
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.sw.inbound.R
@@ -13,6 +15,7 @@ import com.sw.inbound.activity.FoodCollectionActivity
import com.sw.inbound.adapter.CollectSearchAdapter
import com.sw.inbound.databinding.DialogCollectSearchBinding
import com.sw.inbound.databinding.LayoutEmptySearchBinding
import com.sw.inbound.model.response.CollectedFoodInfo
import com.sw.inbound.model.response.SearchGoodsInfo
import com.sw.inbound.objbox.ObjectBox
import com.sw.inbound.utils.ext.addOnActionSearchListener
@@ -28,6 +31,32 @@ class CollectSearchDialog(
private lateinit var inflater: LayoutInflater
private var emptyBinding: LayoutEmptySearchBinding? = null
private lateinit var binding: DialogCollectSearchBinding
/**
* 收集 searchFoodState
*/
@SuppressLint("NotifyDataSetChanged")
private fun initObserver() {
activity?.run {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
userViewModel.collectedFoodListState.collect { items ->
binding.rvSearch.layoutManager =
GridLayoutManager(activity, 2, LinearLayoutManager.VERTICAL, false)
list.clear()
list.addAll(items)
searchAdapter.notifyDataSetChanged()
finishRefresh()
binding.root.hideKeyboard()
if (items.isEmpty()) {
loadEmptyView()
}
}
}
}
}
}
override fun getRootView(): View {
inflater = LayoutInflater.from(context)
binding = DialogCollectSearchBinding.inflate(inflater)
@@ -82,23 +111,14 @@ class CollectSearchDialog(
this@CollectSearchDialog.clickIndex = -1
val searchName = binding.etInputGoods.text.toString().trim()
getCollectGoods(searchName)
}
//it.setOnLoadMoreListener {
// searchGoods()
//}
}
getCollectGoods()
//binding.root.clickWithDebounce {
// Thread {
// FoodModule.initDefFoodData(activity!!) {
// activity?.runOnUiThread {
// activity?.toast("添加完成")
// getCollectGoods()
// }
// }
// }.start()
//}
binding.root.postDelayed({
finishRefresh()
}, 1000)
}
}
initObserver()
getCollectGoods()
}
private var pageNo = 1
@@ -118,17 +138,17 @@ class CollectSearchDialog(
private var clickIndex = -1
private val list: MutableList<SearchGoodsInfo.Record> = mutableListOf()
private val list: MutableList<CollectedFoodInfo> = mutableListOf()
private val searchAdapter by lazy {
CollectSearchAdapter(list).apply {
isStateViewEnable = true
setOnItemClickListener { _, _, position ->
activity?.lifecycleScope?.launch {
val goodsName = list[position].goodsName?:""
val count = ObjectBox.countNameField(goodsName)
activity?.toast("已采集数据${count}")
}
}
// setOnItemClickListener { _, _, position ->
// activity?.lifecycleScope?.launch {
// val goodsName = list[position].goodsName?:""
// val count = ObjectBox.countNameField(goodsName)
// activity?.toast("已采集数据${count}条")
// }
// }
addOnItemChildClickListener(R.id.ivDelete) { _, _, position ->
this@CollectSearchDialog.clickIndex = position
list.forEach { item ->
@@ -144,46 +164,24 @@ class CollectSearchDialog(
}
private fun showDeleteDialog(position: Int, deleteAction: () -> Unit = {}) {
val foodName = list[position].foodName
WarnDialog(
context = context,
content = "请确认是否删除物品:${list[position].goodsName?.split("WP")?.get(0)}",
content = "请确认是否删除物品:${foodName.split("WP").first()}",
cancelBlock = {
this@CollectSearchDialog.clickIndex = -1
list[position].isSelected = false
searchAdapter.notifyItemChanged(position)
},
confirmBlock = {
// Thread {
// }.start()
activity?.lifecycleScope?.launch {
deleteGoods(position, deleteAction)
}
activity?.userViewModel?.removeCollectedFood(
foodName = foodName,
nameFilter = binding.etInputGoods.text.toString().trim()
)
deleteAction()
}).show()
}
private suspend fun deleteGoods(position: Int, deleteAction: () -> Unit = {}) {
activity?.runOnUiThread {
Loading.show(activity!!)
}
val name = list[position].goodsName ?: ""
// val filterIdList = box?.all?.filter { it.name == name }?.map { it.id }
// ObjectBox.boxStore.runInTx {
// box?.removeByIds(filterIdList)
// }
ObjectBox.remove(name)
activity?.runOnUiThread {
Loading.dismiss()
//list.remove(list[position])
//searchAdapter.notifyDataSetChanged()
searchAdapter.removeAt(position)
activity?.toast("删除成功")
if (list.isEmpty()) {
loadEmptyView()
}
deleteAction()
}
}
private fun finishRefresh() {
binding.refreshLayout.let {
if (pageNo == 1) {
@@ -197,27 +195,8 @@ class CollectSearchDialog(
// private var box: Box<Food>? = null
@SuppressLint("NotifyDataSetChanged")
private fun getCollectGoods(searchName: String? = null) = activity?.lifecycleScope?.launch {
// if (box == null) {
// box = ObjectBox.boxStore.boxFor(Food::class)
// }
list.clear()
// var queryList = box?.all?.distinctBy { it.name }
// if (searchName.isNullOrBlank().not()) {
// queryList = queryList?.filter { it.name?.contains(searchName!!) == true }
// }
val queryList = ObjectBox.filter(searchName)
queryList?.forEach {
list.add((SearchGoodsInfo.Record(goodsName = it.name)))
}
binding.rvSearch.layoutManager =
GridLayoutManager(activity, 2, LinearLayoutManager.VERTICAL, false)
searchAdapter.notifyDataSetChanged()
if (list.isEmpty()) {
loadEmptyView()
}
finishRefresh()
binding.root.hideKeyboard()
private fun getCollectGoods(name: String? = null) {
activity?.userViewModel?.loadCollectedFoodList(nameFilter = name)
}
// fun test() {
@@ -0,0 +1,70 @@
package com.sw.inbound.dialog
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import com.sw.inbound.GlobalData
import com.sw.inbound.GlobalKey
import com.sw.inbound.databinding.DialogEnvSwitchBinding
import com.sw.inbound.utils.SPUtil
import com.sw.inbound.utils.ext.dp
import com.sw.inbound.utils.ext.toast
/**
* 环境切换弹窗
*
* 提供 TEST / UAT / PROD 三套环境的切换功能,
* 初始化时自动根据 [GlobalData.appBaseUrl] 选中当前环境,
* 确认后更新 [GlobalData.appBaseUrl] 并通过 [onEnvChanged] 回调通知外部。
*
* @param context 上下文
* @param onEnvChanged 确认切换后的回调,参数为新的 baseUrl
*/
class EnvSwitchDialog(
context: Context,
private val onEnvChanged: (newBaseUrl: String) -> Unit = {}
) : BaseDialog(
context,
defWidth = 600.dp,
defHeight = WindowManager.LayoutParams.WRAP_CONTENT
) {
private lateinit var binding: DialogEnvSwitchBinding
override fun getRootView(): View {
binding = DialogEnvSwitchBinding.inflate(LayoutInflater.from(context))
return binding.root
}
override fun initView() {
// 根据当前 appBaseUrl 预选对应 RadioButton,不匹配则不选
when (GlobalData.appBaseUrl) {
GlobalData.LOCAL_BASE_URL -> binding.rbTest.isChecked = true
GlobalData.TEST_BASE_URL -> binding.rbUat.isChecked = true
GlobalData.PROD_BASE_URL -> binding.rbProd.isChecked = true
}
// 取消按钮
binding.btnCancel.setOnClickListener { dismiss() }
// 确认按钮
binding.btnConfirm.setOnClickListener {
// 未选中任何选项时提示用户
if (binding.rgEnv.checkedRadioButtonId == -1) {
context.toast("请选择环境")
return@setOnClickListener
}
val newUrl = when (binding.rgEnv.checkedRadioButtonId) {
binding.rbTest.id -> GlobalData.LOCAL_BASE_URL
binding.rbUat.id -> GlobalData.TEST_BASE_URL
binding.rbProd.id -> GlobalData.PROD_BASE_URL
else -> return@setOnClickListener
}
GlobalData.appBaseUrl = newUrl
SPUtil.getInstance().put(GlobalKey.KEY_BASE_URL, newUrl)
onEnvChanged(newUrl)
dismiss()
}
}
}
@@ -6,8 +6,10 @@ import android.view.LayoutInflater
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import com.sw.inbound.activity.GoodsListActivity
import com.sw.inbound.activity.StoreActivity
import com.sw.inbound.adapter.RecognizeAdapter
import com.sw.inbound.databinding.DialogGoodsRecognizeBinding
import com.sw.inbound.model.response.GoodsSearchModel
import com.sw.inbound.model.response.SearchGoodsInfo
import com.sw.inbound.utils.ext.clickWithDebounce
import com.sw.inbound.utils.ext.gone
@@ -57,6 +59,17 @@ class GoodsRecognizeDialog(
}
private fun showGoodsStoreDialog(position: Int) {
if (activity is StoreActivity) {
// 入库称重页面:识别结果无溯源码,直接用物品名称构造 GoodsSearchModel 打开入库录入弹窗
val goods = list[position]
InboundGoodsStoreDialog(
context = context,
goods = GoodsSearchModel(materName = goods.goodsName ?: "")
) {
dismiss()
}.show()
return
}
GoodsStoreDialog(
context = context,
funcType = 0,
@@ -357,27 +357,28 @@ class GoodsStoreDialog(
context.toast("入库金额不能为0")
return
}
if (funcType == 1) {
activity?.let { Loading.show(it) }
cameraUtils.takePhoto { uri ->
//imageUri = uri
collectGoodsVector(uri) {
activity?.uploadImage(goods.id!!, goods.goodsName!!, imageFile)
}
binding.root.postDelayed({
Loading.dismiss()
confirm(unitName, count, price, totalPrice)
}, 500)
// if (funcType == 1) {
//无论是否识别到物品,都上传图片增加样本数据
activity?.let { Loading.show(it) }
cameraUtils.takePhoto { uri ->
//imageUri = uri
collectGoodsVector(uri) {
activity?.uploadImage(goods.id!!, goods.goodsName!!, imageFile)
}
cameraUtils.addFailCallback {
binding.root.postDelayed({
Loading.dismiss()
confirm(unitName, count, price, totalPrice)
}, 500)
}
} else {
confirm(unitName, count, price, totalPrice)
binding.root.postDelayed({
Loading.dismiss()
confirm(unitName, count, price, totalPrice)
}, 500)
}
cameraUtils.addFailCallback {
binding.root.postDelayed({
Loading.dismiss()
confirm(unitName, count, price, totalPrice)
}, 500)
}
// } else {
// confirm(unitName, count, price, totalPrice)
// }
}
private fun confirm(unitName: String, count: Double, price: Double, totalPrice: Double?) {
@@ -423,7 +424,7 @@ class GoodsStoreDialog(
override fun show() {
super.show()
if (funcType == 1) {
// if (funcType == 1) {
takePhotoBinding?.let {
cameraUtils.setPreviewController(previewView)
cameraUtils.bind()
@@ -433,7 +434,7 @@ class GoodsStoreDialog(
}, 1000)
}
}
}
// }
binding.root.postDelayed({
SensorScaleUtils.startScale()
}, 2600)
@@ -454,7 +455,7 @@ class GoodsStoreDialog(
private var imagePreviewBinding: LayoutImagePreviewBinding? = null
private fun addChildToContainer() {
val inflater = LayoutInflater.from(context)
if (funcType == 1) {
// if (funcType == 1) {
//预览及拍照
takePhotoBinding = LayoutTakePhotoBinding.inflate(inflater, binding.flContainer, true)
activity?.unBindCamera()
@@ -465,7 +466,7 @@ class GoodsStoreDialog(
takePhotoBinding?.let {
previewView = it.previewView
it.flCameraPreview.visible()
it.cvImageBg.gone()
// it.cvImageBg.gone()
// it.btnStartPreview.setOnClickListener { _ ->
// it.cvImageBg.gone()
// it.flCameraPreview.visible()
@@ -474,19 +475,19 @@ class GoodsStoreDialog(
// cameraUtils.takePhoto(takePhotoCallback)
// }
}
return
}
//只显示原始图片
imagePreviewBinding = LayoutImagePreviewBinding.inflate(inflater, binding.flContainer)
imagePreviewBinding?.imageView?.load(goods.relativeUrl) {
transformations(RoundedCornersTransformation(radius = 10.dp.toFloat()))
}
binding.flContainer.let {
it.setBackgroundResource(R.drawable.bg_white_radius10_stroke2)
it.updateLayoutParams<LinearLayout.LayoutParams> {
width = 488.dp
height = 356.dp
bottomMargin = 35.dp
if(funcType != 1){
//只显示原始图片
imagePreviewBinding = LayoutImagePreviewBinding.inflate(inflater, binding.flContainer)
imagePreviewBinding?.imageView?.load(goods.relativeUrl) {
transformations(RoundedCornersTransformation(radius = 10.dp.toFloat()))
}
binding.flContainer.let {
it.setBackgroundResource(R.drawable.bg_white_radius10_stroke2)
it.updateLayoutParams<LinearLayout.LayoutParams> {
width = 488.dp
height = 356.dp
bottomMargin = 35.dp
}
}
}
}
@@ -0,0 +1,123 @@
package com.sw.inbound.dialog
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.sw.inbound.activity.StoreActivity
import com.sw.inbound.adapter.InboundSearchAdapter
import com.sw.inbound.databinding.DialogInboundGoodsSearchBinding
import com.sw.inbound.databinding.LayoutEmptySearchBinding
import com.sw.inbound.model.response.GoodsSearchModel
import com.sw.inbound.utils.ext.addOnActionSearchListener
import com.sw.inbound.utils.ext.hideKeyboard
import com.sw.inbound.utils.ext.toast
/** 入库物品检索弹窗,使用溯源码查询食材,供 StoreActivity 使用 */
class InboundGoodsSearchDialog(
context: Context
) : BaseDialog(context) {
private lateinit var inflater: LayoutInflater
private var emptyBinding: LayoutEmptySearchBinding? = null
private lateinit var binding: DialogInboundGoodsSearchBinding
override fun getRootView(): View {
inflater = LayoutInflater.from(context)
binding = DialogInboundGoodsSearchBinding.inflate(inflater)
return binding.root
}
private var storeActivity: StoreActivity? = null
override fun initView() {
storeActivity = getReceiptActivity() as? StoreActivity
binding.tvDialogTitle.text = if (storeActivity?.storeType == 1) "毛菜检索" else "净菜检索"
binding.ivClose.setOnClickListener { dismiss() }
binding.ivGoodsSearch.setOnClickListener { searchGoods() }
binding.etInputGoods.addOnActionSearchListener { searchGoods() }
binding.btnAddGoods.setOnClickListener {
InboundGoodsStoreDialog(context = context, goods = null) {
dismiss()
}.show()
}
binding.btnConfirm.setOnClickListener {
if (list.isEmpty()) {
context.toast("请搜索物品")
return@setOnClickListener
}
if (clickIndex == -1) {
context.toast("请选择物品")
return@setOnClickListener
}
InboundGoodsStoreDialog(
context = context,
goods = list[clickIndex]
) {
storeActivity?.goodsRecognizeDialog?.dismiss()
dismiss()
}.show()
}
binding.rvSearch.let {
it.layoutManager = GridLayoutManager(context, 2, LinearLayoutManager.VERTICAL, false)
it.adapter = searchAdapter
}
binding.refreshLayout.let {
it.setEnableRefresh(false)
it.setEnableLoadMore(false)
}
loadEmptyView()
}
private fun searchGoods() {
val traceCode = binding.etInputGoods.text.toString().trim()
if (traceCode.isBlank()) {
context.toast("请输入溯源码")
return
}
storeActivity?.storeViewModel?.getIngredientByTrace(traceCode) { items ->
binding.etInputGoods.hideKeyboard()
val models = items.map { GoodsSearchModel.from(it) }
if (models.isEmpty()) {
loadEmptyView()
searchAdapter.submitList(emptyList())
return@getIngredientByTrace
}
list.clear()
list.addAll(models)
binding.rvSearch.layoutManager =
GridLayoutManager(context, 2, GridLayoutManager.VERTICAL, false)
searchAdapter.submitList(list.toList())
}
}
private fun loadEmptyView() {
binding.rvSearch.layoutManager =
LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
if (emptyBinding == null) {
emptyBinding = LayoutEmptySearchBinding.inflate(inflater, binding.rvSearch, false)
}
emptyBinding?.run {
tvContent.text = "请检查溯源码或稍后重试"
root.setOnClickListener { root.hideKeyboard() }
searchAdapter.stateView = root
}
}
private var clickIndex = -1
private val list: MutableList<GoodsSearchModel> = mutableListOf()
private val searchAdapter by lazy {
InboundSearchAdapter().apply {
isStateViewEnable = true
setOnItemClickListener { _, _, position ->
clickIndex = position
if (list[position].isSelected) return@setOnItemClickListener
list.forEach { it.isSelected = false }
list[position].isSelected = true
notifyDataSetChanged()
}
}
}
}
@@ -0,0 +1,393 @@
package com.sw.inbound.dialog
import android.content.Context
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.view.LayoutInflater
import android.view.View
import androidx.camera.view.PreviewView
import androidx.core.graphics.toColorInt
import androidx.recyclerview.widget.GridLayoutManager
import com.sw.inbound.GlobalData
import com.sw.inbound.activity.StoreActivity
import com.sw.inbound.adapter.FormFieldAdapter
import com.sw.inbound.databinding.DialogInboundGoodsStoreBinding
import com.sw.inbound.databinding.LayoutTakePhotoBinding
import com.sw.inbound.model.FieldType
import com.sw.inbound.model.FormField
import com.sw.inbound.model.response.DictType
import com.sw.inbound.model.response.GoodsSearchModel
import com.sw.inbound.sdk.SensorScaleUtils
import com.sw.inbound.utils.CameraUtils
import com.sw.inbound.utils.ext.appendText
import com.sw.inbound.utils.ext.buildSpannableString
import com.sw.inbound.utils.ext.toast
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.abs
/** 入库输入弹窗,使用 RecyclerView加载 动态表单,根据 storeType 展示净菜/毛菜对应字段 */
class InboundGoodsStoreDialog(
context: Context,
private val goods: GoodsSearchModel?,
private val confirmBlock: () -> Unit = {}
) : BaseDialog(context) {
private lateinit var binding: DialogInboundGoodsStoreBinding
private lateinit var inflater: LayoutInflater
private var storeActivity: StoreActivity? = null
/** 0=净菜,1=毛菜 */
private var storeType: Int = 0
private var realWeight: Int = 0
private val isoFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
/** 当前渲染的表单字段列表 */
private lateinit var fields: MutableList<FormField>
private var previewView: PreviewView? = null
private var takePhotoBinding: LayoutTakePhotoBinding? = null
private val cameraUtils: CameraUtils by lazy { CameraUtils(storeActivity!!) }
override fun getRootView(): View {
inflater = LayoutInflater.from(context)
binding = DialogInboundGoodsStoreBinding.inflate(inflater)
return binding.root
}
override fun initView() {
storeActivity = getReceiptActivity() as? StoreActivity
storeType = storeActivity?.storeType ?: 0
buildFormFields()
setupRecyclerView()
setupButtons()
addChildToContainer()
addWeightListener()
}
private fun buildFormFields() {
fields = if (storeType == 1) buildRawFields() else buildCleanFields()
}
/**
* 毛菜字段列表
* 接口字段:traceCode, supplierType(额外注入), materialName, supplierId, spec, quantity,
* weight(额外注入), inboundTime, zoneId, operator
* 注:来源类型无对应接口字段,apiKey="" 不提交
*/
private fun buildRawFields(): MutableList<FormField> {
val supplierOptions = GlobalData.inboundSupplierList.mapNotNull { opt ->
val id = opt.id?.toString() ?: return@mapNotNull null
DictType(id = id, value = opt.name, type = opt.type?.toString() ?: "")
}
val zoneOptions = GlobalData.inboundZoneList.mapNotNull { zone ->
val id = zone.id?.toString() ?: return@mapNotNull null
DictType(id = id, value = zone.zoneName)
}
return mutableListOf(
FormField(
"溯源码",
FieldType.TEXT,
apiKey = "traceCode",
value = goods?.traceCode ?: "",
required = true,
hint = "请输入溯源码"
),
FormField(
"食材名称",
FieldType.TEXT,
apiKey = "materialName",
required = true,
value = goods?.materName ?: "",
hint = "请输入食材名称"
),
FormField(
"供应商", FieldType.DROPDOWN,
extraApiKeys = mapOf("supplierId" to "id", "supplierType" to "type"),
required = true,
hint = "请选择供应商", options = supplierOptions
),
FormField(
"入库规格",
FieldType.TEXT,
apiKey = "spec",
required = true,
hint = "请输入入库规格"
),
FormField(
"入库数量",
FieldType.NUMBER,
apiKey = "quantity",
required = true,
hint = "请输入入库数量"
),
FormField(
"入库时间",
FieldType.DATE_PICKER,
apiKey = "inboundTime",
required = true,
hint = "请选择入库时间"
),
FormField(
"存放区域", FieldType.DROPDOWN,
extraApiKeys = mapOf("zoneId" to "id", "storageArea" to "value"),
required = true,
hint = "请选择存放区域",
options = zoneOptions
),
FormField(
"操作人",
FieldType.TEXT,
apiKey = "operator",
value = GlobalData.user?.name ?: "",
required = true,
hint = "请输入操作人"
)
)
}
/**
* 净菜字段列表
* 接口字段:traceCode, ingredientName, cleanType, spec, quantity,
* weight(额外注入), inboundTime(额外注入当前时间), zoneId, operator,
* expiryDate, storageTemp
*/
private fun buildCleanFields(): MutableList<FormField> {
val zoneOptions = GlobalData.inboundZoneList.mapNotNull { zone ->
val id = zone.id?.toString() ?: return@mapNotNull null
DictType(id = id, value = zone.zoneName)
}
val specOptions = GlobalData.specOptionList.mapNotNull { spec ->
if (spec.dictCode.isNullOrEmpty() || spec.dictLabel.isNullOrEmpty()) {
return@mapNotNull null
}
DictType(id = spec.dictCode, value = spec.dictLabel)
}
return mutableListOf(
FormField(
"溯源码",
FieldType.TEXT,
apiKey = "traceCode",
value = goods?.traceCode ?: "",
required = true,
hint = "请输入溯源码"
),
FormField(
"食材名称",
type = if (goods?.materName.isNullOrEmpty()) FieldType.TEXT else FieldType.FIXED,
apiKey = "ingredientName",
required = true,
value = goods?.materName ?: ""
),
FormField(
"净菜类型",
type = if (goods?.materName.isNullOrEmpty()) FieldType.TEXT else FieldType.FIXED,
apiKey = "cleanType",
value = goods?.vegTypes ?: ""
),
FormField(
"入库规格",
FieldType.DROPDOWN,
extraApiKeys = mapOf("spec" to "id", "spec_dictText" to "value"),
required = true,
hint = "请选择入库规格",
options = specOptions
),
FormField(
"入库数量",
FieldType.NUMBER,
apiKey = "quantity",
required = true,
hint = "请输入入库数量"
),
FormField(
"操作时间",
FieldType.DATE_PICKER,
apiKey = "inboundTime",
hint = "请选择操作时间"
),
FormField(
"保质期至",
FieldType.DATE_PICKER,
apiKey = "expiryDate",
hint = "请选择保质期"
),
FormField(
"存放区域", FieldType.DROPDOWN,
extraApiKeys = mapOf("zoneId" to "id", "storageArea" to "value"),
required = true,
hint = "请选择存放区域",
options = zoneOptions
),
FormField(
"存放温度(℃)",
FieldType.NUMBER,
apiKey = "storageTemp"
),
FormField(
"操作人",
FieldType.TEXT,
apiKey = "operator",
value = GlobalData.user?.name ?: "",
required = true,
hint = "请输入操作人"
)
)
}
private fun setupRecyclerView() {
binding.rvFormFields.run {
layoutManager = GridLayoutManager(context, 2)
adapter = FormFieldAdapter(storeActivity ?: context, fields)
}
}
private fun setupButtons() {
binding.btnCancel.setOnClickListener {
dismiss()
}
binding.btnConfirm.setOnClickListener { confirmInputData() }
binding.tvTitle.run {
text = if (storeType == 1) "毛菜入库" else "净菜入库"
setOnClickListener { SensorScaleUtils.startScale(autoScale = true) }
}
binding.btnClearZero.setOnClickListener { SensorScaleUtils.tare() }
}
private fun addWeightListener() {
storeActivity?.readWeightInfo { weight ->
if (abs(this.realWeight - weight) < 10) return@readWeightInfo
this.realWeight = weight
binding.tvTotalWeight.text = buildWeightSpannable(weight)
}
}
private fun buildWeightSpannable(num: Int): SpannableStringBuilder {
return buildSpannableString {
appendText(
num.toString(),
ForegroundColorSpan("#FF141428".toColorInt()),
StyleSpan(Typeface.BOLD)
)
appendText(
"",
ForegroundColorSpan("#FF000033".toColorInt()),
StyleSpan(Typeface.BOLD)
)
}
}
/** 校验必填字段,通过后将 fields 转为 Map 提交 */
private fun confirmInputData() {
val invalid = fields.firstOrNull { it.required && it.value.isBlank() }
if (invalid != null) {
context.toast(invalid.hint.ifBlank { "${invalid.label}不能为空" })
return
}
if (realWeight <= 0) {
context.toast("物品重量需大于0")
return
}
if (storeType == 1) submitRawInbound() else submitCleanInbound()
}
/**
* 将 fields 转为接口 Map
* - extraApiKeys 非空时从 extraValues 读取多字段值
* - apiKey 非空时按原逻辑提交单字段值
* - quantity → IntstorageTemp → Double,其余 → String
*/
private fun buildParamMap(): MutableMap<String, Any> {
val map = mutableMapOf<String, Any>()
for (field in fields) {
if (field.extraApiKeys.isNotEmpty()) {
field.extraValues.forEach { (apiKey, value) ->
map[apiKey] = value
}
continue
}
if (field.apiKey.isBlank()) continue
val raw = field.value
if (raw.isBlank()) continue
val converted: Any = when (field.apiKey) {
"quantity" -> raw.toIntOrNull() ?: continue
"storageTemp" -> raw.toDoubleOrNull() ?: continue
else -> raw
}
map[field.apiKey] = converted
}
return map
}
private fun submitRawInbound() {
val param = buildParamMap()
// weight 来自秤,不在表单中,额外注入
param["weight"] = realWeight / 1000.0
param["materId"] = goods?.materId ?: ""
Loading.show(storeActivity ?: return)
storeActivity?.storeViewModel?.submitRawInbound(param) { success, msg ->
Loading.dismiss()
if (success) {
context.toast("入库成功")
confirmBlock()
dismiss()
} else {
context.toast(msg ?: "入库失败")
}
}
}
private fun submitCleanInbound() {
val param = buildParamMap()
// weight 来自秤,不在表单中,额外注入
param["weight"] = realWeight / 1000.0
// inboundTime 净菜取当前时间,不在表单中
val inboundTime = isoFormat.format(Date())
param["inboundTime"] = inboundTime
param["materId"] = goods?.materId ?: ""
val expiryDate = param["expiryDate"] as? String
param["expiryDate"] = expiryDate + " " + inboundTime.split(" ").getOrNull(1)
Loading.show(storeActivity ?: return)
storeActivity?.storeViewModel?.submitCleanInbound(param) { success, msg ->
Loading.dismiss()
if (success) {
context.toast("入库成功")
confirmBlock()
dismiss()
} else {
context.toast(msg ?: "入库失败")
}
}
}
private fun addChildToContainer() {
takePhotoBinding = LayoutTakePhotoBinding.inflate(inflater, binding.flContainer, true)
storeActivity?.unBindCamera()
setOnDismissListener { cameraUtils.unbind() }
cameraUtils.initCamera()
takePhotoBinding?.let {
previewView = it.previewView
it.flCameraPreview.visibility = View.VISIBLE
it.cvImageBg.visibility = View.GONE
}
}
override fun show() {
super.show()
takePhotoBinding?.let {
cameraUtils.setPreviewController(previewView)
cameraUtils.bind()
it.llCameraFlag.postDelayed({ it.llCameraFlag.visibility = View.GONE }, 1000)
}
binding.root.postDelayed({ SensorScaleUtils.startScale() }, 2600)
}
}
@@ -0,0 +1,43 @@
package com.sw.inbound.model
import com.sw.inbound.model.response.DictType
/** 表单字段类型枚举 */
enum class FieldType {
/** 普通文本输入 */
TEXT,
/** 数字输入(支持小数和负数) */
NUMBER,
/** 下拉选择 */
DROPDOWN,
/** 日期选择弹窗 */
DATE_PICKER,
/** 固定值,不可编辑 */
FIXED
}
/**
* 动态表单字段数据模型
* @param label 字段标签名称
* @param type 字段类型,决定渲染哪种输入控件
* @param apiKey 非下拉字段对应的接口参数名,空字符串表示不提交
* @param extraApiKeys 下拉字段的多接口参数映射,key=接口参数名,value=DictType 字段名(id/value/type
* @param required 是否必填,提交时校验
* @param hidden 是否隐藏,hidden=true 时不渲染该字段
* @param hint 校验失败提示文案,同时作为输入框 hint
* @param options 下拉框选项列表(DROPDOWN 类型使用)
* @param value 当前显示值/输入值;DATE_PICKER 存 yyyy-MM-dd 显示格式
* @param extraValues 下拉选中后按 extraApiKeys 映射存储的提交值,key=接口参数名
*/
data class FormField(
val label: String,
val type: FieldType,
val apiKey: String = "",
val extraApiKeys: Map<String, String> = emptyMap(),
val required: Boolean = false,
val hidden: Boolean = false,
val hint: String = "",
val options: List<DictType> = emptyList(),
var value: String = "",
val extraValues: MutableMap<String, String> = mutableMapOf()
)
@@ -0,0 +1,26 @@
package com.sw.inbound.model.request
import com.google.gson.annotations.SerializedName
/** 净菜入库提交参数 */
data class CleanInboundParam(
@SerializedName("traceCode") val traceCode: String,
@SerializedName("ingredientName") val ingredientName: String,
/** 净菜类型,如"切丁"、"切片",参考 IngredientByTrace.vegTypes */
@SerializedName("cleanType") val cleanType: String,
/** 入库规格,如"袋装/500g"、"盒装/300g" */
@SerializedName("spec") val spec: String,
@SerializedName("quantity") val quantity: Int,
/** 入库重量(kg */
@SerializedName("weight") val weight: Double,
/** 入库时间,格式:yyyy-MM-dd'T'HH:mm:ss */
@SerializedName("inboundTime") val inboundTime: String,
@SerializedName("zoneId") val zoneId: Long,
@SerializedName("operator") val operator: String? = null,
/** 保质期至,格式同 inboundTime */
@SerializedName("expiryDate") val expiryDate: String? = null,
@SerializedName("storageTemp") val storageTemp: Double? = null,
@SerializedName("cameraCode") val cameraCode: String? = null,
@SerializedName("videoUrl") val videoUrl: String? = null,
@SerializedName("remark") val remark: String? = null
)
@@ -0,0 +1,26 @@
package com.sw.inbound.model.request
import com.google.gson.annotations.SerializedName
/** 毛菜入库提交参数 */
data class RawInboundParam(
@SerializedName("traceCode") val traceCode: String,
/** 供应商类型:1-内部,2-外部 */
@SerializedName("supplierType") val supplierType: Short,
@SerializedName("materialName") val materialName: String,
@SerializedName("supplierId") val supplierId: Long,
/** 入库规格,如"25kg/件"、"散装(kg)" */
@SerializedName("spec") val spec: String,
@SerializedName("quantity") val quantity: Int,
/** 入库重量(kg */
@SerializedName("weight") val weight: Double,
/** 入库时间,格式:yyyy-MM-dd'T'HH:mm:ss */
@SerializedName("inboundTime") val inboundTime: String,
@SerializedName("zoneId") val zoneId: Long,
@SerializedName("operator") val operator: String,
@SerializedName("storageArea") val storageArea: String? = null,
@SerializedName("reportNo") val reportNo: String? = null,
@SerializedName("cameraCode") val cameraCode: String? = null,
@SerializedName("videoUrl") val videoUrl: String? = null,
@SerializedName("remark") val remark: String? = null
)
@@ -1,7 +1,6 @@
package com.sw.inbound.model.request
import android.os.Parcelable
import com.sw.inbound.model.response.BaseBean
import com.sw.inbound.model.response.GoodsInfo
import kotlinx.parcelize.Parcelize
import java.math.BigDecimal
@@ -19,6 +18,10 @@ data class UploadInfo(
* 供应商id
*/
var supplierId: String? = null,
/**
* 采购单号
*/
var purCode: String? = null,
/**
*
* 物品信息 【全部收货使用】
@@ -90,6 +93,6 @@ private data class UploadGoodsInfo(
// 已调整
var isAdjusted: Boolean = false
) : Parcelable, BaseBean() {
) : Parcelable {
}
@@ -1,5 +0,0 @@
package com.sw.inbound.model.response
open class BaseBean() {
}
@@ -0,0 +1,7 @@
package com.sw.inbound.model.response
data class CollectedFoodInfo(
var foodName: String,
var foodNum: Int,
var isSelected: Boolean = false
)
@@ -8,6 +8,17 @@ import kotlinx.parcelize.Parcelize
*/
@Parcelize
data class DictType(
val id: String?=null,
val value: String?=null
) : Parcelable
val id: String? = null,
val value: String? = null,
val type: String = ""
) : Parcelable {
/**
* 按字段名取值,供 extraApiKeys 映射直接使用
*/
operator fun get(fieldName: String): String = when (fieldName) {
"id" -> id ?: ""
"value" -> value ?: ""
else -> type
}
}
@@ -117,7 +117,7 @@ data class GoodsInfo(
var buyToInventoryValue:String?="",
//--------------------------------------------------------
) : Parcelable, BaseBean() {
) : Parcelable {
val goodNameStr: String
get() {
@@ -0,0 +1,25 @@
package com.sw.inbound.model.response
/** 溯源码查食材结果,用于 InboundGoodsSearchDialog 列表展示 */
data class GoodsSearchModel(
val materId: Long = 0L,
val materName: String = "",
/** 净菜类型,逗号分隔,如"切丁,切片" */
val vegTypes: String = "",
val traceCode: String = "",
var isSelected: Boolean = false
) {
/** 将 vegTypes 拆分为列表,供净菜类型下拉使用 */
fun vegTypeList(): List<String> =
if (vegTypes.isBlank()) emptyList()
else vegTypes.split(",").map { it.trim() }.filter { it.isNotBlank() }
companion object {
fun from(item: IngredientByTrace): GoodsSearchModel = GoodsSearchModel(
materId = item.materId ?: 0L,
materName = item.materName ?: "",
vegTypes = item.vegTypes ?: "",
traceCode = item.traceCode ?: ""
)
}
}
@@ -0,0 +1,15 @@
package com.sw.inbound.model.response
import com.google.gson.annotations.SerializedName
/**
* 入库秤设备端接口统一响应包装
* 与旧 ApiResponse 区别:code 为 Int200 表示成功
*/
data class InboundApiResponse<T>(
@SerializedName("code") val code: String? = null,
@SerializedName("msg") val msg: String? = null,
@SerializedName("data") val data: T? = null
) {
fun isSuccess(): Boolean = code == "00000"
}
@@ -0,0 +1,12 @@
package com.sw.inbound.model.response
import com.google.gson.annotations.SerializedName
/** 按溯源码查食材的响应条目 */
data class IngredientByTrace(
@SerializedName("traceCode") val traceCode: String? = null,
@SerializedName("materId") val materId: Long? = null,
@SerializedName("materName") val materName: String? = null,
/** 净菜类型,逗号分隔,如"切丁,切片" */
@SerializedName("vegTypes") val vegTypes: String? = null
)
@@ -31,6 +31,6 @@ data class PurchaseInfo(
var receiveGoodsInfoList: List<GoodsInfo> = emptyList<GoodsInfo>(),
// 自用
var warehouseName: String = "选择仓库"
) : BaseBean() {
) {
}
@@ -0,0 +1,9 @@
package com.sw.inbound.model.response
/**
* 字典下拉,dictLabel 用于界面展示,dictCode 用于提交入参
*/
data class SpecOption(
var dictLabel:String?,
var dictCode:String?,
)
@@ -0,0 +1,11 @@
package com.sw.inbound.model.response
import com.google.gson.annotations.SerializedName
/** 供应商下拉选项 */
data class SupplierOption(
@SerializedName("id") val id: Long? = null,
@SerializedName("name") val name: String? = null,
/** 1-内部供应商,2-外部供应商 */
@SerializedName("type") val type: Int? = null
)
@@ -0,0 +1,11 @@
package com.sw.inbound.model.response
import com.google.gson.annotations.SerializedName
/** 仓库区域下拉选项 */
data class ZoneOption(
@SerializedName("id") val id: Long? = null,
@SerializedName("zoneNo") val zoneNo: String? = null,
@SerializedName("zoneName") val zoneName: String? = null,
@SerializedName("warehouseId") val warehouseId: Long? = null
)
@@ -39,31 +39,30 @@ interface ApiService {
/**
* device获取token
*/
@GET("sys/getEquipmentToken")
suspend fun getDeviceToken(
@Query("qrcodeId") qrcodeId: String,
@Query("appVersion") appVersion: String = GlobalData.appVersion
): ApiResponse<String>
// @GET("sys/getEquipmentToken")
// suspend fun getDeviceToken(
// @Query("qrcodeId") qrcodeId: String,
// @Query("appVersion") appVersion: String = GlobalData.appVersion
// ): ApiResponse<String>
/**
*获取配置信息
*/
@GET("equipment/stEquipment/queryByEquipmentCode")
suspend fun getDeviceInfo(
@Query("equipmentCode") equipmentCode: String,
@Query("appVersion") appVersion: String = GlobalData.appVersion,
@Header("X-Access-Token") token: String
): ApiResponse<EquipmentInfo>
// @GET("equipment/stEquipment/queryByEquipmentCode")
// suspend fun getDeviceInfo(
// @Query("equipmentCode") equipmentCode: String,
// @Query("appVersion") appVersion: String = GlobalData.appVersion,
// @Header("X-Access-Token") token: String
// ): ApiResponse<EquipmentInfo>
/**
* 登录
*/
@POST//("sys/loginPad")
suspend fun login(
@Url url: String = "${GlobalData.appBaseUrl}/sys/loginPad",
@Body body: LoginParam,
): ApiResponse<User>
// @POST//("sys/loginPad")
// suspend fun login(
// @Url url: String = "${GlobalData.appBaseUrl}/sys/loginPad",
// @Body body: LoginParam,
// ): ApiResponse<User>
/**
* 采购单入库-列表
@@ -94,11 +93,11 @@ interface ApiService {
/**
* 部分收货
*/
@POST//("pad/partialReceiptGoods")
suspend fun partialReceipt(
@Url url: String = "${GlobalData.appBaseUrl}${apiPrefix}/pad/partialReceiptGoods",
@Body uploadInfo: UploadInfo
): ApiResponse<Boolean>
// @POST//("pad/partialReceiptGoods")
// suspend fun partialReceipt(
// @Url url: String = "${GlobalData.appBaseUrl}${apiPrefix}/pad/partialReceiptGoods",
// @Body uploadInfo: UploadInfo
// ): ApiResponse<Boolean>
/**
* 确认收货
@@ -195,11 +194,11 @@ interface ApiService {
/**
* 添加物品采购单位
*/
@POST//("pad/goodsAddUnit")
suspend fun goodsAddUnit(
@Url url: String = "${GlobalData.appBaseUrl}${apiPrefix}/pad/goodsAddUnit",
@Body purchaseUnitParam: GoodsPurchaseUnitParam,
): ApiResponse<Boolean>
// @POST//("pad/goodsAddUnit")
// suspend fun goodsAddUnit(
// @Url url: String = "${GlobalData.appBaseUrl}${apiPrefix}/pad/goodsAddUnit",
// @Body purchaseUnitParam: GoodsPurchaseUnitParam,
// ): ApiResponse<Boolean>
/**
* 自采入库
@@ -0,0 +1,65 @@
package com.sw.inbound.network.api
import com.sw.inbound.GlobalData
import com.sw.inbound.model.response.SpecOption
import com.sw.inbound.model.response.IngredientByTrace
import com.sw.inbound.model.response.InboundApiResponse
import com.sw.inbound.model.response.SupplierOption
import com.sw.inbound.model.response.ZoneOption
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import retrofit2.http.Url
/**
* 入库秤设备端接口
* Base URL: http://192.168.10.101:24801/
* 鉴权:authorization header 由 InboundInterceptor 统一注入
*/
interface InboundApiService {
/** 按溯源码查食材,用于毛菜/净菜入库时联动选择食材 */
@GET
suspend fun getIngredientByTrace(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/inbound/ingredient-by-trace",
@Query("traceCode") traceCode: String
): InboundApiResponse<List<IngredientByTrace>>
/** 仓库区域下拉列表 */
@GET
suspend fun getZoneOptions(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/inbound/zone-options",
@Query("warehouseId") warehouseId: Long? = null,
@Query("keyword") keyword: String? = null
): InboundApiResponse<List<ZoneOption>>
/** 供应商下拉列表,type: 1-内部,2-外部,不传返回全部 */
@GET
suspend fun getSupplierOptions(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/inbound/supplier-options",
@Query("type") type: Int? = null,
@Query("keyword") keyword: String? = null
): InboundApiResponse<List<SupplierOption>>
/** 规格下拉列表,dictType=nut_spec_type */
@GET
suspend fun getSpecOptions(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/dict-items",
@Query("dictType") dictType: String = "nut_spec_type"
): InboundApiResponse<List<SpecOption>?>
/** 毛菜入库提交,入参为 Map 以便动态组装字段 */
@POST
suspend fun submitRawInbound(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/inbound/raw-inbound",
@Body param: Map<String, @JvmSuppressWildcards Any>
): InboundApiResponse<Unit>
/** 净菜入库提交,入参为 Map 以便动态组装字段 */
@POST
suspend fun submitCleanInbound(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/inbound/clean-inbound",
@Body param: Map<String, @JvmSuppressWildcards Any>
): InboundApiResponse<Unit>
}
@@ -0,0 +1,19 @@
package com.sw.inbound.network.interceptor
import okhttp3.Interceptor
import okhttp3.Response
/**
* 入库秤设备端接口拦截器
* 统一注入 authorization 固定鉴权值及 Content-Type
*/
class InboundInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().newBuilder()
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("authorization", "57ee87183f2a4fa59683ec9ef41c8f5d")
.build()
return chain.proceed(request)
}
}
@@ -36,10 +36,6 @@ object FoodModule {
private lateinit var module_mobile: Module
// private lateinit var box: Box<Food>
private lateinit var embeddingsList: List<List<Float>>
private lateinit var labelsList: IntArray
private lateinit var classInfo: FoodClassInfo
private val NO_MEAN_RGB = floatArrayOf(0.0f, 0.0f, 0.0f)
private val NO_STD_RGB = floatArrayOf(1.0f, 1.0f, 1.0f)
@@ -47,6 +43,7 @@ object FoodModule {
private const val MODEL_INPUT_WIDTH = 300
private const val MODEL_INPUT_HEIGHT = 300
const val DEFAULT_FOOD_INDEX = -1
const val DEFAULT_QUERY_COUNT = 50
const val BAG_RATE = 0.05
@@ -94,7 +91,7 @@ object FoodModule {
return null
}
// fun queryFood(uri: Uri, queryCount: Int = 15): List<String>? {
// fun queryFood(uri: Uri, queryCount: Int = DEFAULT_QUERY_COUNT): List<String>? {
// return uri2FloatArray(uri)?.let {
// queryFood(it, queryCount)
// }
@@ -103,7 +100,7 @@ object FoodModule {
// /**
// * 返回识别物品名称列表
// */
// fun queryFood(bitmap: Bitmap, queryCount: Int = 15): List<String> {
// fun queryFood(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List<String> {
// val floatArray = bitmap2FloatArray(bitmap)
// return queryFood(floatArray, queryCount)
// }
@@ -111,14 +108,14 @@ object FoodModule {
// /**
// * 返回识别物品IdNameScore对象列表
// */
// fun queryFoodNameScore(bitmap: Bitmap, queryCount: Int = 15): List<IdNameScore> {
// fun queryFoodNameScore(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List<IdNameScore> {
// val floatArray = bitmap2FloatArray(bitmap)
// return queryFoodNameScore(floatArray, queryCount)
// }
suspend fun queryFoodNameScore(
floatArray: FloatArray?,
queryCount: Int = 15
queryCount: Int = DEFAULT_QUERY_COUNT
): List<IdNameScore> {
if (floatArray == null) return emptyList()
// val query: Query<Food> = box.query(Food_.foodVector.nearestNeighbors(floatArray, queryCount)).build()
@@ -134,16 +131,18 @@ object FoodModule {
logInfo("queryFood向量:${floatArray.slice(0 until 50).toJsonString()}")
val idScoreList = ObjectBox.query(floatArray, queryCount)
// 批量加载所有实体,一次DB操作替代循环单条查询
val foodMap = ObjectBox.getByIds(idScoreList.map { it.id }).associateBy { it.id }
val nameScoreList = mutableListOf<IdNameScore>()
idScoreList.forEach {
val name = ObjectBox.get(it.id)?.name ?: ""
val name = foodMap[it.id]?.name ?: ""
nameScoreList.add(IdNameScore(id = it.id, name = name, score = it.score))
}
logInfo("queryFood数据:${nameScoreList.toJsonString()}")
return nameScoreList
}
suspend fun getFoodScoreList(bitmap: Bitmap, queryCount: Int = 15): List<IdNameScore> {
suspend fun getFoodScoreList(bitmap: Bitmap, queryCount: Int = DEFAULT_QUERY_COUNT): List<IdNameScore> {
val floatArray = bitmap2FloatArray(bitmap, false) ?: return emptyList()
logInfo("getFoodScoreList向量:${floatArray.toJsonString()}")
val nameScoreList = queryFoodNameScore(floatArray, queryCount)
@@ -156,34 +155,35 @@ object FoodModule {
.groupBy { it.name }
.map { (_, value) -> value.minByOrNull { it.score }!! }
.toMutableList()
val map = mutableMapOf<String, Int>()
nameScoreList.forEach {
val key = it.name
val count = map[key] ?: 0
map[key] = count + 1
}
val orderList = map.entries.sortedByDescending { it.value }.map { it.key }.toMutableList()
val firstFood = nameScoreList[0].name
orderList.remove(firstFood)
orderList.add(0, firstFood)
val sortedScoreList = maxScoreList.sortedWith(compareBy {
orderList.indexOf(it.name)
})
// val map = mutableMapOf<String, Int>()
// nameScoreList.forEach {
// val key = it.name
// val count = map[key] ?: 0
// map[key] = count + 1
// }
// val orderList = map.entries.sortedByDescending { it.value }.map { it.key }.toMutableList()
// val firstFood = nameScoreList[0].name
// orderList.remove(firstFood)
// orderList.add(0, firstFood)
//
// val sortedScoreList = maxScoreList.sortedWith(compareBy {
// orderList.indexOf(it.name)
// })
val sortedScoreList = maxScoreList.sortedBy { it.score }
logInfo("getFoodScoreList数据:${sortedScoreList.toJsonString()}")
return sortedScoreList
}
suspend fun queryFood(floatArray: FloatArray, queryCount: Int = 15): List<String> {
val map = mutableMapOf<String, Int>()
val nameScoreList = queryFoodNameScore(floatArray, queryCount)
nameScoreList.filter { it.score < 0.05 }.forEach {
val count = map[it.name] ?: 0
map[it.name] = count + 1
}
val list = map.entries.sortedByDescending { it.value }.map { it.key }
return list
}
// suspend fun queryFood(floatArray: FloatArray, queryCount: Int = DEFAULT_QUERY_COUNT): List<String> {
// val map = mutableMapOf<String, Int>()
// val nameScoreList = queryFoodNameScore(floatArray, queryCount)
// nameScoreList.filter { it.score < 0.05 }.forEach {
// val count = map[it.name] ?: 0
// map[it.name] = count + 1
// }
// val list = map.entries.sortedByDescending { it.value }.map { it.key }
// return list
// }
data class IdNameScore(
val id: Long,
@@ -201,10 +201,10 @@ object FoodModule {
val labelsJson = AssetsTool.readAssetsFile(context, "data/labels.json")
val classInfoJson = AssetsTool.readAssetsFile(context, "data/class_info.json")
embeddingsList =
val embeddingsList: List<List<Float>> =
Gson().fromJson(embeddingsJson, object : TypeToken<List<List<Float>>>() {}.type)
labelsList = Gson().fromJson(labelsJson, IntArray::class.java)
classInfo =
val labelsList: IntArray = Gson().fromJson(labelsJson, IntArray::class.java)
val classInfo: FoodClassInfo =
Gson().fromJson(classInfoJson, FoodClassInfo::class.java)
val foodMap = classInfo.idx_to_class
@@ -151,6 +151,9 @@ object ObjectBox {
e.printStackTrace()
LogSaveUtil.saveLogFile("safeDbOp出现其它异常:${e.message}")
null
} finally {
// 线程资源清理
boxStore?.closeThreadResources()
}
}
}
@@ -172,6 +175,12 @@ object ObjectBox {
getBox<Food>()?.get(id)
}
// 批量按ID加载,一次DB事务替代循环单条查询
suspend fun getByIds(ids: List<Long>): List<Food> = safeDbOp {
if (ids.isEmpty()) return@safeDbOp emptyList<Food>()
getBox<Food>()?.get(ids.toLongArray()) ?: emptyList<Food>()
} ?: emptyList()
suspend fun put(entity: Food) = safeDbOp {
getBox<Food>()?.put(entity)
}
@@ -200,11 +209,40 @@ object ObjectBox {
} ?: emptyList()
}
/**
* 按名称分组统计采集数量
* 使用属性查询只加载 name 字段,不加载 foodVector,节省大量内存
* @param nameFilter 名称过滤关键词,为空时查询全部
* @return 按名称分组后的 (名称, 数量) 列表,按名称排序
*/
suspend fun queryFoodNameCounts(nameFilter: String? = null) = safeDbOp {
val box = getBox<Food>() ?: return@safeDbOp emptyList<Pair<String, Int>>()
val query = if (!nameFilter.isNullOrBlank()) {
box.query(Food_.name.contains(nameFilter)).build()
} else {
box.query().build()
}
try {
// 只查 name 字段,完全不加载 foodVector
query.property(Food_.name).findStrings()
.groupingBy { it }
.eachCount()
.map { (name, count) -> Pair(name, count) }
.sortedBy { it.first }
} finally {
query.close()
}
} ?: emptyList()
// 使用 QueryBuilder 在数据库层删除,避免全量加载到内存
suspend fun remove(name: String) = safeDbOp {
getBox<Food>()?.query(Food_.name.equal(name))?.build()?.remove()
}
suspend fun removeAll() = safeDbOp {
getBox<Food>()?.removeAll()
}
private val dbMutex = Mutex() // 协程并发锁,保证写入操作原子性
private const val DB_DIR_NAME = "objectbox" // ObjectBox 默认数据库目录
private const val BACKUP_DIR_NAME = "objectbox_backup" // 备份目录
@@ -3,7 +3,7 @@ package com.sw.inbound.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.sw.inbound.activity.HomeActivity
import com.sw.inbound.activity.InitActivity
import timber.log.Timber
/**
@@ -14,7 +14,7 @@ class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (Intent.ACTION_BOOT_COMPLETED == intent.action) {
Timber.d("设备启动完成,开始执行自启动逻辑")
val intent = Intent(context, HomeActivity::class.java)
val intent = Intent(context, InitActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
@@ -2,6 +2,7 @@ package com.sw.inbound.repository
import com.google.gson.JsonParseException
import com.sw.inbound.model.response.ApiResponse
import com.sw.inbound.utils.LogSaveUtil
import retrofit2.HttpException
import timber.log.Timber
import java.io.IOException
@@ -15,7 +16,7 @@ abstract class BaseRepository {
apiCall()
} catch (e: Exception) {
Timber.e("safeApiCall Exception: ${e.stackTraceToString()}")
LogSaveUtil.saveLogFile("safeApiCall Exception: ${e.stackTraceToString()}")
when (e) {
is HttpException -> {
// 对于HTTP异常,尝试从响应体中获取错误信息
@@ -0,0 +1,79 @@
package com.sw.inbound.repository
import com.google.gson.JsonParseException
import com.sw.inbound.model.response.IngredientByTrace
import com.sw.inbound.model.response.InboundApiResponse
import com.sw.inbound.model.response.SpecOption
import com.sw.inbound.model.response.SupplierOption
import com.sw.inbound.model.response.ZoneOption
import com.sw.inbound.network.api.InboundApiService
import com.sw.inbound.utils.LogSaveUtil
import retrofit2.HttpException
import timber.log.Timber
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
import javax.inject.Inject
/**
* 入库秤设备端接口数据层
* 对应接口文档 Base URL: http://192.168.10.101:24801/nutrition/neglect/inbound
*/
class InboundRepository @Inject constructor(
private val apiService: InboundApiService
) {
/** 按溯源码查食材 */
suspend fun getIngredientByTrace(traceCode: String): InboundApiResponse<List<IngredientByTrace>> {
return safeCall { apiService.getIngredientByTrace(traceCode = traceCode) }
}
/** 仓库区域下拉列表 */
suspend fun getZoneOptions(
warehouseId: Long? = null,
keyword: String? = null
): InboundApiResponse<List<ZoneOption>> {
return safeCall { apiService.getZoneOptions(warehouseId = warehouseId, keyword = keyword) }
}
/** 供应商下拉列表 */
suspend fun getSupplierOptions(
type: Int? = null,
keyword: String? = null
): InboundApiResponse<List<SupplierOption>> {
return safeCall { apiService.getSupplierOptions(type = type, keyword = keyword) }
}
/** 规格下拉列表 */
suspend fun getSpecOptions(): InboundApiResponse<List<SpecOption>?> {
return safeCall { apiService.getSpecOptions() }
}
/** 毛菜入库提交 */
suspend fun submitRawInbound(param: Map<String, Any>): InboundApiResponse<Unit> {
return safeCall { apiService.submitRawInbound(param = param) }
}
/** 净菜入库提交 */
suspend fun submitCleanInbound(param: Map<String, Any>): InboundApiResponse<Unit> {
return safeCall { apiService.submitCleanInbound(param = param) }
}
private suspend fun <T> safeCall(call: suspend () -> InboundApiResponse<T>): InboundApiResponse<T> {
return try {
call()
} catch (e: Exception) {
Timber.e("InboundRepository safeCall Exception: ${e.stackTraceToString()}")
LogSaveUtil.saveLogFile("InboundRepository safeCall Exception: ${e.stackTraceToString()}")
val msg = when (e) {
is HttpException -> "HTTP错误: ${e.code()} - ${e.message()}"
is SocketTimeoutException -> "请求超时: ${e.message}"
is ConnectException -> "连接失败: ${e.message}"
is JsonParseException -> "JSON解析错误: ${e.message}"
is IOException -> "网络IO错误: ${e.message}"
else -> "未知错误: ${e.message}"
}
InboundApiResponse(code = "-1", msg = msg)
}
}
}
@@ -38,29 +38,29 @@ class RemoteRepository @Inject constructor(
/**
* 生成token
*/
suspend fun getDeviceToken(qrcodeId: String): ApiResponse<String> {
return safeApiCall {
apiService.getDeviceToken(
qrcodeId
)
}
}
// suspend fun getDeviceToken(qrcodeId: String): ApiResponse<String> {
// return safeApiCall {
// apiService.getDeviceToken(
// qrcodeId
// )
// }
// }
/**
* 获取设备信息
*/
suspend fun getDeviceInfo(equipmentCode: String, token: String): ApiResponse<EquipmentInfo> {
return safeApiCall {
apiService.getDeviceInfo(
equipmentCode = equipmentCode,
token = token
)
}
}
// suspend fun getDeviceInfo(equipmentCode: String, token: String): ApiResponse<EquipmentInfo> {
// return safeApiCall {
// apiService.getDeviceInfo(
// equipmentCode = equipmentCode,
// token = token
// )
// }
// }
suspend fun login(loginParam: LoginParam): ApiResponse<User> {
return safeApiCall { apiService.login(body = loginParam) }
}
// suspend fun login(loginParam: LoginParam): ApiResponse<User> {
// return safeApiCall { apiService.login(body = loginParam) }
// }
suspend fun getReceiveList(pageNum: Int, pageSize: Int): ApiResponse<List<SupplierInfo>?> {
return safeApiCall {
@@ -153,11 +153,11 @@ class RemoteRepository @Inject constructor(
/**
* 部分收货
*/
suspend fun partialReceipt(uploadInfo: UploadInfo): ApiResponse<Boolean> {
return safeApiCall {
apiService.partialReceipt(uploadInfo = uploadInfo)
}
}
// suspend fun partialReceipt(uploadInfo: UploadInfo): ApiResponse<Boolean> {
// return safeApiCall {
// apiService.partialReceipt(uploadInfo = uploadInfo)
// }
// }
/**
* 全部收货
@@ -198,11 +198,11 @@ class RemoteRepository @Inject constructor(
}
}
suspend fun selfPurchaseGoodsAddUnit(addParam: GoodsPurchaseUnitParam): ApiResponse<Boolean> {
return safeApiCall {
apiService.goodsAddUnit(purchaseUnitParam = addParam)
}
}
// suspend fun selfPurchaseGoodsAddUnit(addParam: GoodsPurchaseUnitParam): ApiResponse<Boolean> {
// return safeApiCall {
// apiService.goodsAddUnit(purchaseUnitParam = addParam)
// }
// }
/**
* 自采购-入库
@@ -61,7 +61,15 @@ object SensorScaleUtils {
private var callbackList: MutableList<WeightCallback> = mutableListOf()
fun addWeightListener(callback: WeightCallback) {
callbackList.add(callback)
if (callbackList.contains(callback).not()) {
callbackList.add(callback)
}
}
fun removeWeightListener(callback: WeightCallback) {
if (callbackList.contains(callback)) {
callbackList.remove(callback)
}
}
/**
@@ -28,8 +28,8 @@ class CameraUtils(private var activity: ComponentActivity) {
}
}
private var failCallback:(()-> Unit)?=null
fun addFailCallback(callback:()->Unit) {
private var failCallback: (() -> Unit)? = null
fun addFailCallback(callback: () -> Unit) {
failCallback = callback
}
@@ -76,11 +76,19 @@ class CameraUtils(private var activity: ComponentActivity) {
}
fun bind() {
cameraController?.bindToLifecycle(activity)
try {
cameraController?.bindToLifecycle(activity)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun unbind() {
cameraController?.unbind()
try {
cameraController?.unbind()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
@@ -0,0 +1,196 @@
package com.sw.inbound.utils
import android.content.Context
import timber.log.Timber
import java.io.File
/**
* 模型资源管理器
* 负责从接口下载模型资源包(Zip),并解压到应用缓存目录
* 替代原有的 assets 中写死的 data 文件夹和 .pt 文件
*/
object ModelResourceManager {
/**
* 资源下载和解压的回调接口
*/
interface ResourceCallback {
/**
* 下载进度回调
* @param progress 进度百分比 (0-100)
* @param message 进度信息
*/
fun onDownloadProgress(progress: Int, message: String)
/**
* 解压进度回调
* @param progress 进度百分比 (0-100)
* @param message 进度信息
*/
fun onExtractProgress(progress: Int, message: String)
/**
* 完成回调
* @param resourceDir 资源所在目录
*/
fun onSuccess(resourceDir: File)
/**
* 失败回调
* @param errorMessage 错误信息
*/
fun onFailure(errorMessage: String)
}
/**
* 下载并解压模型资源
* @param context 应用上下文
* @param downloadUrl 资源包下载链接
* @param callback 进度和结果回调
*/
fun downloadAndExtractResources(
context: Context,
downloadUrl: String,
callback: ResourceCallback
) {
try {
// 获取缓存目录
val cacheDir = context.cacheDir
val resourcesDir = File(cacheDir, "model_resources")
val zipFile = File(cacheDir, "model_resources.zip")
Timber.d("资源缓存目录: ${resourcesDir.absolutePath}")
Timber.d("Zip 文件路径: ${zipFile.absolutePath}")
// Step 1: 下载 Zip 文件
Timber.d("开始下载资源包: $downloadUrl")
ZipDownloadUtils.downloadZip(
downloadUrl = downloadUrl,
targetFile = zipFile,
listener = object : ZipDownloadUtils.DownloadProgressListener {
override fun onProgress(currentSize: Long, totalSize: Long, progress: Int) {
val message = "下载中... ${formatFileSize(currentSize)}/${formatFileSize(totalSize)}"
callback.onDownloadProgress(progress, message)
}
override fun onSuccess(file: File) {
Timber.d("资源包下载完成")
callback.onDownloadProgress(100, "下载完成")
// Step 2: 解压 Zip 文件
extractResources(resourcesDir, zipFile, callback)
}
override fun onFailure(exception: Exception) {
val errorMsg = "资源包下载失败: ${exception.message}"
Timber.e(exception, errorMsg)
callback.onFailure(errorMsg)
}
}
)
} catch (e: Exception) {
val errorMsg = "资源下载和解压过程异常: ${e.message}"
Timber.e(e, errorMsg)
callback.onFailure(errorMsg)
}
}
/**
* 解压资源包
*/
private fun extractResources(
resourcesDir: File,
zipFile: File,
callback: ResourceCallback
) {
try {
Timber.d("开始解压资源包")
ZipExtractUtils.extractZip(
zipFile = zipFile,
targetDir = resourcesDir,
listener = object : ZipExtractUtils.ExtractProgressListener {
override fun onProgress(
currentFile: String,
currentIndex: Int,
totalFiles: Int,
progress: Int
) {
val message = "解压中... ($currentIndex/$totalFiles) $currentFile"
callback.onExtractProgress(progress, message)
}
override fun onSuccess(targetDir: File) {
Timber.d("资源包解压完成: ${targetDir.absolutePath}")
callback.onExtractProgress(100, "解压完成")
// 清理 Zip 文件
zipFile.delete()
Timber.d("临时 Zip 文件已删除")
callback.onSuccess(targetDir)
}
override fun onFailure(exception: Exception) {
val errorMsg = "资源包解压失败: ${exception.message}"
Timber.e(exception, errorMsg)
callback.onFailure(errorMsg)
}
}
)
} catch (e: Exception) {
val errorMsg = "解压过程异常: ${e.message}"
Timber.e(e, errorMsg)
callback.onFailure(errorMsg)
}
}
/**
* 获取已缓存的资源目录
* @param context 应用上下文
* @return 资源目录,如果不存在则返回 null
*/
fun getCachedResourcesDir(context: Context): File? {
val resourcesDir = File(context.cacheDir, "model_resources")
return if (resourcesDir.exists() && resourcesDir.isDirectory) {
resourcesDir
} else {
null
}
}
/**
* 清理缓存的资源
* @param context 应用上下文
* @return 是否清理成功
*/
fun clearCachedResources(context: Context): Boolean {
return try {
val resourcesDir = File(context.cacheDir, "model_resources")
val zipFile = File(context.cacheDir, "model_resources.zip")
val dirDeleted = resourcesDir.deleteRecursively()
val zipDeleted = zipFile.delete()
Timber.d("清理缓存资源: 目录=$dirDeleted, Zip=$zipDeleted")
dirDeleted || zipDeleted
} catch (e: Exception) {
Timber.e(e, "清理缓存资源失败")
false
}
}
/**
* 格式化文件大小为可读字符串
*/
private fun formatFileSize(bytes: Long): String {
return when {
bytes <= 0 -> "0 B"
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> String.format("%.2f KB", bytes / 1024.0)
bytes < 1024 * 1024 * 1024 -> String.format("%.2f MB", bytes / (1024.0 * 1024))
else -> String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024))
}
}
}
@@ -0,0 +1,24 @@
package com.sw.inbound.utils
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
/**
* 网络连接检测工具类
* 提供检测设备网络连接状态的功能
*/
object NetworkUtils {
/**
* 检测设备是否连接到网络
* @param context 应用上下文
* @return true 表示已连接网络,false 表示未连接
*/
fun isNetworkConnected(context: Context): Boolean {
val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
@@ -0,0 +1,122 @@
package com.sw.inbound.utils
import okhttp3.OkHttpClient
import okhttp3.Request
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
/**
* Zip 文件下载工具类
* 支持大文件下载、进度回调、断点续传等功能
*/
object ZipDownloadUtils {
/**
* 下载进度回调接口
*/
interface DownloadProgressListener {
/**
* 下载进度回调
* @param currentSize 已下载字节数
* @param totalSize 总字节数
* @param progress 进度百分比 (0-100)
*/
fun onProgress(currentSize: Long, totalSize: Long, progress: Int)
/**
* 下载完成回调
* @param file 下载完成的文件
*/
fun onSuccess(file: File)
/**
* 下载失败回调
* @param exception 异常信息
*/
fun onFailure(exception: Exception)
}
/**
* 下载 Zip 文件
* @param downloadUrl 下载链接
* @param targetFile 目标文件路径
* @param listener 进度监听器
* @param okHttpClient OkHttp 客户端(可选,默认创建新实例)
*/
fun downloadZip(
downloadUrl: String,
targetFile: File,
listener: DownloadProgressListener? = null,
okHttpClient: OkHttpClient? = null
) {
try {
// 确保目标文件的父目录存在
targetFile.parentFile?.mkdirs()
val client = okHttpClient ?: OkHttpClient()
val request = Request.Builder()
.url(downloadUrl)
.build()
val response = client.newCall(request).execute()
if (!response.isSuccessful) {
throw IOException("下载失败,HTTP 状态码: ${response.code}")
}
val responseBody = response.body
?: throw IOException("响应体为空")
val totalSize = responseBody.contentLength()
Timber.d("开始下载 Zip 文件,总大小: ${formatFileSize(totalSize)}")
var currentSize = 0L
val buffer = ByteArray(8192) // 8KB 缓冲区
var bytesRead: Int
FileOutputStream(targetFile).use { fileOutput ->
responseBody.byteStream().use { inputStream ->
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
fileOutput.write(buffer, 0, bytesRead)
currentSize += bytesRead
// 计算进度百分比
val progress = if (totalSize > 0) {
((currentSize * 100) / totalSize).toInt()
} else {
0
}
listener?.onProgress(currentSize, totalSize, progress)
Timber.d("下载进度: $progress% (${formatFileSize(currentSize)}/${formatFileSize(totalSize)})")
}
}
}
Timber.d("Zip 文件下载完成: ${targetFile.absolutePath}")
listener?.onSuccess(targetFile)
} catch (e: Exception) {
Timber.e(e, "下载 Zip 文件失败")
targetFile.delete() // 删除不完整的文件
listener?.onFailure(e)
}
}
/**
* 格式化文件大小为可读字符串
* @param bytes 字节数
* @return 格式化后的字符串(如 "10.5 MB"
*/
private fun formatFileSize(bytes: Long): String {
return when {
bytes <= 0 -> "0 B"
bytes < 1024 -> "$bytes B"
bytes < 1024 * 1024 -> String.format("%.2f KB", bytes / 1024.0)
bytes < 1024 * 1024 * 1024 -> String.format("%.2f MB", bytes / (1024.0 * 1024))
else -> String.format("%.2f GB", bytes / (1024.0 * 1024 * 1024))
}
}
}
@@ -0,0 +1,131 @@
package com.sw.inbound.utils
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
/**
* Zip 文件解压工具类
* 支持解压进度回调、自动创建目录等功能
*/
object ZipExtractUtils {
/**
* 解压进度回调接口
*/
interface ExtractProgressListener {
/**
* 解压进度回调
* @param currentFile 当前正在解压的文件名
* @param currentIndex 当前解压文件索引
* @param totalFiles 总文件数
* @param progress 进度百分比 (0-100)
*/
fun onProgress(currentFile: String, currentIndex: Int, totalFiles: Int, progress: Int)
/**
* 解压完成回调
* @param targetDir 解压目标目录
*/
fun onSuccess(targetDir: File)
/**
* 解压失败回调
* @param exception 异常信息
*/
fun onFailure(exception: Exception)
}
/**
* 解压 Zip 文件
* @param zipFile 待解压的 Zip 文件
* @param targetDir 解压目标目录
* @param listener 进度监听器
*/
fun extractZip(
zipFile: File,
targetDir: File,
listener: ExtractProgressListener? = null
) {
try {
if (!zipFile.exists() || !zipFile.isFile) {
throw IllegalArgumentException("Zip 文件不存在或不是文件: ${zipFile.absolutePath}")
}
// 确保目标目录存在
targetDir.mkdirs()
Timber.d("开始解压 Zip 文件: ${zipFile.absolutePath}")
Timber.d("解压目标目录: ${targetDir.absolutePath}")
// 第一次遍历:计算总文件数
val totalFiles = countZipEntries(zipFile)
Timber.d("Zip 文件总条目数: $totalFiles")
var currentIndex = 0
ZipInputStream(zipFile.inputStream()).use { zipInputStream ->
var entry: ZipEntry? = zipInputStream.nextEntry
while (entry != null) {
currentIndex++
val entryName = entry.name
val progress = ((currentIndex * 100) / totalFiles).coerceIn(0, 100)
Timber.d("解压进度: $progress% ($currentIndex/$totalFiles) - $entryName")
listener?.onProgress(entryName, currentIndex, totalFiles, progress)
if (entry.isDirectory) {
// 创建目录
val dir = File(targetDir, entryName)
dir.mkdirs()
} else {
// 解压文件
val file = File(targetDir, entryName)
file.parentFile?.mkdirs()
FileOutputStream(file).use { fileOutput ->
val buffer = ByteArray(8192) // 8KB 缓冲区
var bytesRead: Int
while (zipInputStream.read(buffer).also { bytesRead = it } != -1) {
fileOutput.write(buffer, 0, bytesRead)
}
}
}
zipInputStream.closeEntry()
entry = zipInputStream.nextEntry
}
}
Timber.d("Zip 文件解压完成: ${targetDir.absolutePath}")
listener?.onSuccess(targetDir)
} catch (e: Exception) {
Timber.e(e, "解压 Zip 文件失败")
listener?.onFailure(e)
}
}
/**
* 计算 Zip 文件中的条目总数
* @param zipFile Zip 文件
* @return 条目总数
*/
private fun countZipEntries(zipFile: File): Int {
var count = 0
try {
ZipInputStream(zipFile.inputStream()).use { zipInputStream ->
while (zipInputStream.nextEntry != null) {
count++
zipInputStream.closeEntry()
}
}
} catch (e: Exception) {
Timber.e(e, "计算 Zip 条目数失败")
}
return count.coerceAtLeast(1) // 至少返回 1,避免除以 0
}
}
@@ -0,0 +1,188 @@
package com.sw.inbound.utils
/**
* Zip 文件下载和解压工具使用示例
*
* ============================================
* 方案一:使用高层管理器(推荐)
* ============================================
*
* 在 Activity 或 ViewModel 中使用:
*
* ```kotlin
* // 下载并解压模型资源
* ModelResourceManager.downloadAndExtractResources(
* context = this,
* downloadUrl = "https://example.com/model_resources.zip",
* callback = object : ModelResourceManager.ResourceCallback {
* override fun onDownloadProgress(progress: Int, message: String) {
* // 更新下载进度 UI
* runOnUiThread {
* tvProgress.text = "$progress% - $message"
* }
* }
*
* override fun onExtractProgress(progress: Int, message: String) {
* // 更新解压进度 UI
* runOnUiThread {
* tvProgress.text = "$progress% - $message"
* }
* }
*
* override fun onSuccess(resourceDir: File) {
* // 资源解压完成,可以开始使用
* Timber.d("资源已准备好: ${resourceDir.absolutePath}")
* // 例如:加载 data 文件夹和 .pt 文件
* val dataDir = File(resourceDir, "data")
* val modelFile = File(resourceDir, "model.pt")
* }
*
* override fun onFailure(errorMessage: String) {
* // 处理错误
* runOnUiThread {
* ToastUtils.show("资源下载失败: $errorMessage")
* }
* }
* }
* )
*
* // 获取已缓存的资源目录
* val cachedDir = ModelResourceManager.getCachedResourcesDir(this)
* if (cachedDir != null) {
* // 资源已缓存,直接使用
* val dataDir = File(cachedDir, "data")
* val modelFile = File(cachedDir, "model.pt")
* }
*
* // 清理缓存的资源
* ModelResourceManager.clearCachedResources(this)
* ```
*
* ============================================
* 方案二:分别使用下载和解压工具
* ============================================
*
* 如果需要更细粒度的控制,可以分别使用:
*
* ```kotlin
* // 1. 下载 Zip 文件
* val zipFile = File(context.cacheDir, "model_resources.zip")
* ZipDownloadUtils.downloadZip(
* downloadUrl = "https://example.com/model_resources.zip",
* targetFile = zipFile,
* listener = object : ZipDownloadUtils.DownloadProgressListener {
* override fun onProgress(currentSize: Long, totalSize: Long, progress: Int) {
* Timber.d("下载进度: $progress%")
* }
*
* override fun onSuccess(file: File) {
* Timber.d("下载完成: ${file.absolutePath}")
* // 继续解压
* }
*
* override fun onFailure(exception: Exception) {
* Timber.e(exception, "下载失败")
* }
* }
* )
*
* // 2. 解压 Zip 文件
* val targetDir = File(context.cacheDir, "model_resources")
* ZipExtractUtils.extractZip(
* zipFile = zipFile,
* targetDir = targetDir,
* listener = object : ZipExtractUtils.ExtractProgressListener {
* override fun onProgress(
* currentFile: String,
* currentIndex: Int,
* totalFiles: Int,
* progress: Int
* ) {
* Timber.d("解压进度: $progress% - $currentFile")
* }
*
* override fun onSuccess(targetDir: File) {
* Timber.d("解压完成: ${targetDir.absolutePath}")
* }
*
* override fun onFailure(exception: Exception) {
* Timber.e(exception, "解压失败")
* }
* }
* )
* ```
*
* ============================================
* 关键特性
* ============================================
*
* 1. 下载特性:
* - 支持大文件下载(120MB+ 无压力)
* - 实时进度回调
* - 自动错误处理和清理
* - 使用 8KB 缓冲区,内存占用低
*
* 2. 解压特性:
* - 支持大型 Zip 文件
* - 自动创建目录结构
* - 实时进度回调
* - 使用 Java 内置 ZipInputStream,无额外依赖
*
* 3. 资源管理:
* - 自动缓存到应用缓存目录
* - 支持检查缓存是否存在
* - 支持清理缓存
* - 完整的日志记录
*
* ============================================
* 建议的集成方式
* ============================================
*
* 1. 在 Application 初始化时检查缓存:
* ```kotlin
* class MyApp : Application() {
* override fun onCreate() {
* super.onCreate()
* // 检查模型资源是否已缓存
* val cachedDir = ModelResourceManager.getCachedResourcesDir(this)
* if (cachedDir == null) {
* // 需要下载资源,可以在首次启动时提示用户
* }
* }
* }
* ```
*
* 2. 在需要使用资源的 Activity/Fragment 中:
* ```kotlin
* val cachedDir = ModelResourceManager.getCachedResourcesDir(context)
* if (cachedDir != null) {
* // 资源已准备好,直接使用
* useModelResources(cachedDir)
* } else {
* // 资源不存在,触发下载
* downloadResources()
* }
* ```
*
* 3. 在后台线程中执行下载和解压:
* ```kotlin
* Thread {
* ModelResourceManager.downloadAndExtractResources(
* context = this,
* downloadUrl = resourceUrl,
* callback = callback
* )
* }.start()
* ```
*
* ============================================
* 注意事项
* ============================================
*
* 1. 下载和解压操作会阻塞线程,建议在后台线程执行
* 2. 确保应用有网络权限和文件读写权限
* 3. 缓存目录会占用应用缓存空间,大文件需要足够的存储空间
* 4. 下载失败时会自动删除不完整的文件
* 5. 所有操作都有详细的日志记录,可通过 Timber 查看
*/
object ZipToolsUsageExample
@@ -14,6 +14,7 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.TypedValue
import android.view.TouchDelegate
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
@@ -270,3 +271,24 @@ fun String.copyText(context: Context, showToast: Boolean = true) {
Toast.makeText(context, "文本已复制到剪贴板", Toast.LENGTH_SHORT).show()
}
}
/**
* 扩大 View 的点击区域
* @param horizontal 水平方向扩大距离(dp)→ 左右各扩大一半
* @param vertical 垂直方向扩大距离(dp)→ 上下各扩大一半
*/
fun View.expandClickArea(horizontal: Int, vertical: Int) {
val density = resources.displayMetrics.density
val hPx = (horizontal * density).toInt()
val vPx = (vertical * density).toInt()
post {
val rect = android.graphics.Rect()
getHitRect(rect)
// 扩大区域:负值 = 向外扩大
rect.inset(-hPx, -vPx)
//rect.bottom += downPx // 只往下扩大
(parent as View).touchDelegate = TouchDelegate(rect, this)
}
}
@@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.gson.reflect.TypeToken
import com.sw.inbound.GlobalData
import com.sw.inbound.MyApp
import com.sw.inbound.model.response.ApiResponse
import com.sw.inbound.model.response.DictType
import com.sw.inbound.model.response.EquipmentInfo
@@ -16,6 +17,7 @@ import com.sw.inbound.repository.RemoteRepository
import com.sw.inbound.utils.SPUtil
//import com.sw.inbound.utils.ToastUtils
import com.sw.inbound.utils.ext.toType
import com.sw.inbound.utils.ext.toast
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -102,7 +104,8 @@ abstract class BaseViewModel(
return true
}
Timber.d("msg = ${response.msg}, code = ${response.code}")
//ToastUtils.showToast("${response.msg}(${response.code})")
// ToastUtils.showToast("${response.msg}(${response.code})")
MyApp.instance?.toast("${response.msg}(${response.code})")
return false
}
@@ -209,17 +212,25 @@ abstract class BaseViewModel(
callback: (List<SearchGoodsInfo.Record>) -> Unit
) {
launch {
if (nameList.isNullOrEmpty() && scoreList.isNullOrEmpty()) return@launch
val paramList = if (nameList.isNullOrEmpty()) scoreList!!.map { it.name } else nameList
val response = repository.recognizeGoodsList(paramList)
val list = if (parseResponse(response)) response.data ?: listOf() else listOf()
if (scoreList.isNullOrEmpty().not()) {
list.forEach { record ->
val item = scoreList.firstOrNull { it.name == record.goodsName }
record.score = ((1 - (item?.score ?: 0.0)) * 10000).roundToInt()
try {
if (nameList.isNullOrEmpty() && scoreList.isNullOrEmpty()) {
callback(listOf())
return@launch
}
val paramList = if (nameList.isNullOrEmpty()) scoreList!!.map { it.name } else nameList
val response = repository.recognizeGoodsList(paramList)
val list = if (parseResponse(response)) response.data ?: listOf() else listOf()
if (scoreList.isNullOrEmpty().not()) {
list.forEach { record ->
val item = scoreList.firstOrNull { it.name == record.goodsName }
record.score = ((1 - (item?.score ?: 0.0)) * 10000).roundToInt()
}
}
callback(list)
} catch (e: Exception) {
Timber.e(e)
callback(listOf())
}
callback(list)
}
}
@@ -27,29 +27,29 @@ class DeviceViewModel @Inject constructor(
/**
* 获取token
*/
fun getDeviceToken(deviceId: String = GlobalData.deviceId) {
launchWithLoading {
val response = repository.getDeviceToken(deviceId)
if (parseResponse(response)) {
val response1 = repository.getDeviceInfo(deviceId, response.result!!)
if (parseResponse(response1)) {
val equipmentInfo = response1.result
if (equipmentInfo == null) return@launchWithLoading
try {
parseEquipmentInfo(equipmentInfo)
SPUtil.getInstance()
.put(GlobalKey.KEY_EQUIPMENT_INFO, GsonUtils.toJson(equipmentInfo))
_deviceInfoResult.value = true
} catch (e: Exception) {
Timber.e(e)
}
} else {
//ToastUtils.showToast("获取设备信息失败")
}
}
}
}
// fun getDeviceToken(deviceId: String = GlobalData.deviceId) {
// launchWithLoading {
// val response = repository.getDeviceToken(deviceId)
// if (parseResponse(response)) {
// val response1 = repository.getDeviceInfo(deviceId, response.result!!)
//
// if (parseResponse(response1)) {
// val equipmentInfo = response1.result
// if (equipmentInfo == null) return@launchWithLoading
// try {
// parseEquipmentInfo(equipmentInfo)
// SPUtil.getInstance()
// .put(GlobalKey.KEY_EQUIPMENT_INFO, GsonUtils.toJson(equipmentInfo))
// _deviceInfoResult.value = true
// } catch (e: Exception) {
// Timber.e(e)
// }
// } else {
// //ToastUtils.showToast("获取设备信息失败")
// }
// }
// }
// }
/**
* 检查缓存数据
@@ -323,17 +323,17 @@ class ReceiptViewModel @Inject constructor(
// }
// }
fun confirmReceipt(uploadInfo: UploadInfo, callback: (Boolean) -> Unit) {
fun confirmReceipt(uploadInfo: UploadInfo, callback: (Boolean, String) -> Unit) {
launchWithLoading {
val resp = repository.confirmReceipt(uploadInfo)
callback(resp.data == true)
callback(resp.isSuccess(), resp.msg?:"")
}
}
fun addToWarehouse(list: List<PurchaseWarehouseParam>, callback: (Boolean) -> Unit) {
fun addToWarehouse(list: List<PurchaseWarehouseParam>, callback: (Boolean, String) -> Unit) {
launchWithLoading {
val resp = repository.selfPurchaseWarehousing(list)
callback(resp.data == true)
callback(resp.isSuccess(), resp.msg?:"")
}
}
@@ -409,11 +409,7 @@ class ReceiptViewModel @Inject constructor(
return true
}
val resp = repository.uploadMultipleImages(fileList = fileList, params = params)
// if (!parseResponse(resp)) {
// callback(false)
// return@launchWithLoading
// }
return resp.code == "00000"
return parseResponse(resp)
}
}
@@ -0,0 +1,115 @@
package com.sw.inbound.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sw.inbound.model.response.IngredientByTrace
import com.sw.inbound.model.response.SpecOption
import com.sw.inbound.model.response.SupplierOption
import com.sw.inbound.model.response.ZoneOption
import com.sw.inbound.repository.InboundRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class StoreViewModel @Inject constructor(
private val repository: InboundRepository
) : ViewModel() {
private val _zoneOptions = MutableStateFlow<List<ZoneOption>>(emptyList())
val zoneOptions: StateFlow<List<ZoneOption>> = _zoneOptions
private val _supplierOptions = MutableStateFlow<List<SupplierOption>>(emptyList())
val supplierOptions: StateFlow<List<SupplierOption>> = _supplierOptions
private val _specOptions = MutableStateFlow<List<SpecOption>>(emptyList())
val specOptions: StateFlow<List<SpecOption>> = _specOptions
/** 按溯源码查食材,结果通过 callback 返回 */
fun getIngredientByTrace(
traceCode: String,
callback: (List<IngredientByTrace>) -> Unit
) {
viewModelScope.launch {
val response = repository.getIngredientByTrace(traceCode)
if (response.isSuccess()) {
callback(response.data ?: emptyList())
} else {
Timber.e("getIngredientByTrace failed: ${response.msg}")
callback(emptyList())
}
}
}
/** 加载仓库区域列表,结果同时更新 StateFlow 并通过 callback 返回 */
fun loadZoneOptions(
warehouseId: Long? = null,
keyword: String? = null,
callback: ((List<ZoneOption>) -> Unit)? = null
) {
viewModelScope.launch {
val response = repository.getZoneOptions(warehouseId, keyword)
if (response.isSuccess()) {
val list = response.data ?: emptyList()
_zoneOptions.value = list
callback?.invoke(list)
} else {
Timber.e("loadZoneOptions failed: ${response.msg}")
callback?.invoke(emptyList())
}
}
}
/** 加载供应商列表,结果同时更新 StateFlow 并通过 callback 返回 */
fun loadSupplierOptions(
type: Int? = null,
keyword: String? = null,
callback: ((List<SupplierOption>) -> Unit)? = null
) {
viewModelScope.launch {
val response = repository.getSupplierOptions(type, keyword)
if (response.isSuccess()) {
val list = response.data ?: emptyList()
_supplierOptions.value = list
callback?.invoke(list)
} else {
Timber.e("loadSupplierOptions failed: ${response.msg}")
callback?.invoke(emptyList())
}
}
}
/** 加载规格列表,结果同时更新 StateFlow 并通过 callback 返回 */
fun loadSpecOptions(callback: ((List<SpecOption>) -> Unit)? = null) {
viewModelScope.launch {
val response = repository.getSpecOptions()
if (response.isSuccess()) {
val list = response.data ?: emptyList()
_specOptions.value = list
callback?.invoke(list)
} else {
Timber.e("loadSpecOptions failed: ${response.msg}")
callback?.invoke(emptyList())
}
}
}
/** 毛菜入库提交 */
fun submitRawInbound(param: Map<String, Any>, callback: (Boolean, String?) -> Unit) {
viewModelScope.launch {
val response = repository.submitRawInbound(param)
callback(response.isSuccess(), response.msg)
}
}
/** 净菜入库提交 */
fun submitCleanInbound(param: Map<String, Any>, callback: (Boolean, String?) -> Unit) {
viewModelScope.launch {
val response = repository.submitCleanInbound(param)
callback(response.isSuccess(), response.msg)
}
}
}
@@ -1,17 +1,23 @@
package com.sw.inbound.viewmodel
import androidx.lifecycle.viewModelScope
import com.sw.inbound.GlobalData
import com.sw.inbound.GlobalKey
import com.sw.inbound.model.request.LoginParam
import com.sw.inbound.model.response.CollectedFoodInfo
import com.sw.inbound.model.response.User
import com.sw.inbound.objbox.ObjectBox
import com.sw.inbound.repository.RemoteRepository
import com.sw.inbound.utils.GsonUtils
import com.sw.inbound.utils.SPUtil
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
import kotlin.collections.remove
@HiltViewModel
class UserViewModel @Inject constructor(
@@ -33,16 +39,16 @@ class UserViewModel @Inject constructor(
//}
}
fun login(userName: String, password: String) = launchWithLoading {
val loginParam = LoginParam(userName = userName, password = password)
val response = repo.login(loginParam)
if (parseResponse(response)) {
SPUtil.getInstance().put(GlobalKey.KEY_USER_NAME, userName)
_user.value = response.result
saveToken(response.result)
response.result?.token
}
}
// fun login(userName: String, password: String) = launchWithLoading {
// val loginParam = LoginParam(userName = userName, password = password)
// val response = repo.login(loginParam)
// if (parseResponse(response)) {
// SPUtil.getInstance().put(GlobalKey.KEY_USER_NAME, userName)
// _user.value = response.result
// saveToken(response.result)
// response.result?.token
// }
// }
fun getUserInfo() {
val userInfo = SPUtil.getInstance().get(GlobalKey.KEY_USER_INFO, "")
@@ -64,6 +70,39 @@ class UserViewModel @Inject constructor(
SPUtil.getInstance().remove(GlobalKey.KEY_TOKEN)
}
/**
* 已采集食材列表 UI 状态流
* 元素为按名称分组后的(名称,数量)数据
*/
private val _collectedFoodListState = MutableStateFlow<List<CollectedFoodInfo>>(emptyList())
val collectedFoodListState: StateFlow<List<CollectedFoodInfo>> = _collectedFoodListState.asStateFlow()
/**
* 加载已采集食材列表,按名称分组统计数量
* 使用属性查询只读 name 字段,不加载 foodVector,节省内存
* @param nameFilter 名称过滤关键词,为空时加载全部
*/
fun loadCollectedFoodList(nameFilter: String? = null) {
viewModelScope.launch {
val result = ObjectBox.queryFoodNameCounts(nameFilter)
_collectedFoodListState.value = result.map { (name, count) ->
CollectedFoodInfo(foodName = name, foodNum = count)
}
}
}
/**
* 删除指定名称的所有采集记录,删除完成后刷新列表
* @param foodName 食材名称
* @param nameFilter 当前搜索关键词,用于删除后刷新列表
*/
fun removeCollectedFood(foodName: String, nameFilter: String? = null) {
viewModelScope.launch {
ObjectBox.remove(foodName)
loadCollectedFoodList(nameFilter)
}
}
override fun handleSensorScale(weight: Double) {
}
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 从上到下:浅蓝(占比多)→ 深蓝,angle=270 表示从上到下 -->
<gradient
android:angle="270"
android:startColor="#88BBFF"
android:centerColor="#1A6EFF"
android:centerX="0.65"
android:endColor="#0032C8"
android:type="linear" />
<corners android:radius="16dp" />
</shape>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 从上到下:浅橙(占比多)→ 深橙,angle=270 表示从上到下 -->
<gradient
android:angle="270"
android:startColor="#FFD08A"
android:centerColor="#FF9632"
android:centerX="0.65"
android:endColor="#CC6400"
android:type="linear" />
<corners android:radius="16dp" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#4ECDC4" />
<corners android:radius="12dp" />
<stroke
android:width="2dp"
android:color="#2BA39F" />
</shape>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="80dp"
android:height="80dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- 网络断开的图标 -->
<path
android:fillColor="#FF6B6B"
android:pathData="M1,9l2,2c4.97,-4.97 13.03,-4.97 18,0l2,-2C16.93,2.93 7.08,2.93 1,9zM5,13l2,2c2.76,-2.76 7.24,-2.76 10,0l2,-2c-4.42,-4.42 -11.58,-4.42 -16,0zM9,17h6v2h-6z" />
</vector>
+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="240dp"
android:height="240dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<!-- 由小到大排列的圆点,构成旋转加载指示器 -->
<path
android:fillColor="#262626"
android:pathData="M876.864,782.592c3.264,0 6.272,-3.2 6.272,-6.656c0,-3.456 -3.008,-6.592 -6.272,-6.592c-3.264,0 -6.272,3.2 -6.272,6.592c0,3.456 3.008,6.656 6.272,6.656z" />
<path
android:fillColor="#262626"
android:pathData="M736.32,935.936c2.304,2.432 5.568,3.84 8.768,3.84a12.16,12.16 0,0 0,8.832 -3.84a13.76,13.76 0,0 0,0 -18.56a12.224,12.224 0,0 0,-8.832 -3.84a12.16,12.16 0,0 0,-8.768 3.84a13.696,13.696 0,0 0,0 18.56z" />
<path
android:fillColor="#262626"
android:pathData="M552.32,1018.24c3.456,3.648 8.32,5.76 13.184,5.76a18.368,18.368 0,0 0,13.184 -5.76a20.608,20.608 0,0 0,0 -27.968a18.368,18.368 0,0 0,-13.184 -5.824a18.368,18.368 0,0 0,-13.184 5.76a20.608,20.608 0,0 0,0 28.032z" />
<path
android:fillColor="#262626"
android:pathData="M353.984,1012.48c4.608,4.8 11.072,7.68 17.6,7.68a24.448,24.448 0,0 0,17.536 -7.68a27.456,27.456 0,0 0,0 -37.248a24.448,24.448 0,0 0,-17.536 -7.68a24.448,24.448 0,0 0,-17.6 7.68a27.52,27.52 0,0 0,0 37.184z" />
<path
android:fillColor="#262626"
android:pathData="M178.304,920.64c5.76,6.08 13.824,9.6 21.952,9.6a30.592,30.592 0,0 0,22.016 -9.6a34.368,34.368 0,0 0,0 -46.592a30.592,30.592 0,0 0,-22.016 -9.6a30.592,30.592 0,0 0,-21.952 9.6a34.368,34.368 0,0 0,0 46.592z" />
<path
android:fillColor="#262626"
android:pathData="M57.152,761.28c6.912,7.36 16.64,11.648 26.368,11.648a36.736,36.736 0,0 0,26.432 -11.584a41.28,41.28 0,0 0,0 -55.936a36.736,36.736 0,0 0,-26.432 -11.584a36.8,36.8 0,0 0,-26.368 11.52a41.28,41.28 0,0 0,0 56z" />
<path
android:fillColor="#262626"
android:pathData="M12.736,564.672a42.88,42.88 0,0 0,30.784 13.44a42.88,42.88 0,0 0,30.784 -13.44a48.128,48.128 0,0 0,0 -65.216a42.88,42.88 0,0 0,-30.72 -13.44a42.88,42.88 0,0 0,-30.848 13.44a48.128,48.128 0,0 0,0 65.216z" />
<path
android:fillColor="#262626"
android:pathData="M52.544,369.28a48.96,48.96 0,0 0,35.2 15.36a48.96,48.96 0,0 0,35.2 -15.36a54.976,54.976 0,0 0,0 -74.56a48.96,48.96 0,0 0,-35.2 -15.424a48.96,48.96 0,0 0,-35.2 15.424a54.976,54.976 0,0 0,0 74.56z" />
<path
android:fillColor="#262626"
android:pathData="M168.32,212.48c10.368,11.008 24.96,17.408 39.68,17.408c14.592,0 29.184,-6.4 39.552,-17.408a61.888,61.888 0,0 0,0 -83.84a55.104,55.104 0,0 0,-39.616 -17.408c-14.656,0 -29.248,6.4 -39.616,17.408a61.888,61.888 0,0 0,0 83.84z" />
<path
android:fillColor="#262626"
android:pathData="M337.344,124.8c11.52,12.16 27.712,19.264 43.968,19.264c16.256,0 32.448,-7.04 43.968,-19.264a68.672,68.672 0,0 0,0 -93.184a61.248,61.248 0,0 0,-43.968 -19.264a61.248,61.248 0,0 0,-43.968 19.264a68.736,68.736 0,0 0,0 93.184z" />
<path
android:fillColor="#262626"
android:pathData="M526.976,123.712c12.672,13.44 30.528,21.248 48.448,21.248s35.712,-7.808 48.384,-21.248a75.584,75.584 0,0 0,0 -102.464A67.392,67.392 0,0 0,575.36 0c-17.92,0 -35.776,7.808 -48.448,21.248a75.584,75.584 0,0 0,0 102.464z" />
<path
android:fillColor="#262626"
android:pathData="M700.8,210.304c13.824,14.592 33.28,23.104 52.736,23.104c19.584,0 39.04,-8.512 52.8,-23.104a82.432,82.432 0,0 0,0 -111.744a73.472,73.472 0,0 0,-52.8 -23.168c-19.52,0 -38.912,8.512 -52.736,23.168a82.432,82.432 0,0 0,0 111.744z" />
<path
android:fillColor="#262626"
android:pathData="M824.832,368.832c14.976,15.872 36.032,25.088 57.216,25.088c21.12,0 42.24,-9.216 57.152,-25.088a89.344,89.344 0,0 0,0 -121.088a79.616,79.616 0,0 0,-57.152 -25.088c-21.184,0 -42.24,9.216 -57.216,25.088a89.344,89.344 0,0 0,0 121.088z" />
<path
android:fillColor="#262626"
android:pathData="M875.264,572.864c16.128,17.088 38.784,27.008 61.632,27.008c22.784,0 45.44,-9.92 61.568,-27.008a96.256,96.256 0,0 0,0 -130.432a85.76,85.76 0,0 0,-61.568 -27.072c-22.848,0 -45.44,9.984 -61.632,27.072a96.192,96.192 0,0 0,0 130.432z" />
</vector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="ring"
android:innerRadiusRatio="3"
android:thicknessRatio="8"
android:useLevel="false">
<gradient
android:type="sweep"
android:startColor="#FF6B6B"
android:centerColor="#4ECDC4"
android:endColor="#FF6B6B"
android:angle="0" />
</shape>
+98
View File
@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@mipmap/bg"
android:orientation="vertical">
<!-- 标题栏区域 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="60dp"
android:paddingEnd="60dp"
android:paddingTop="22dp">
<!-- 返回按钮 -->
<ImageView
android:id="@+id/ivBack"
android:layout_width="60dp"
android:layout_height="60dp"
android:contentDescription="返回"
android:src="@mipmap/ic_back" />
<!-- 标题文字 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="30dp"
android:text="趣味页面"
android:textColor="@color/title"
android:textSize="36sp"
android:textStyle="bold" />
<!-- 占位弹性空间 -->
<Space
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
</LinearLayout>
<!-- 内容区域 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center"
android:padding="60dp">
<!-- 下载按钮 -->
<Button
android:id="@+id/btnDownload"
android:layout_width="400dp"
android:layout_height="80dp"
android:text="1. 下载 Zip 文件"
android:textSize="24sp"
android:textColor="@color/white"
android:backgroundTint="@color/title"
android:layout_marginBottom="30dp" />
<!-- 解压按钮 -->
<Button
android:id="@+id/btnExtract"
android:layout_width="400dp"
android:layout_height="80dp"
android:text="2. 解压 Zip 文件"
android:textSize="24sp"
android:textColor="@color/white"
android:backgroundTint="@color/title"
android:layout_marginBottom="30dp" />
<!-- 读取并保存按钮 -->
<Button
android:id="@+id/btnLoadAndSave"
android:layout_width="400dp"
android:layout_height="80dp"
android:text="3. 读取并保存到数据库"
android:textSize="24sp"
android:textColor="@color/white"
android:backgroundTint="@color/title"
android:layout_marginBottom="30dp" />
<!-- 进度/状态显示 -->
<TextView
android:id="@+id/tvProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="就绪"
android:textColor="@color/black"
android:textSize="20sp"
android:layout_marginTop="30dp" />
</LinearLayout>
</LinearLayout>
@@ -19,16 +19,22 @@
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="40dp"
android:layout_weight="1"
android:layout_marginStart="30dp"
android:padding="10dp"
android:gravity="center_vertical"
android:text="出入库管理"
android:textColor="#141428"
android:textSize="36sp"
android:textStyle="bold" />
<Space
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/tvUserName"
android:layout_width="wrap_content"
+47 -3
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@mipmap/bg"
@@ -37,9 +38,11 @@
android:id="@+id/tvTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingVertical="10dp"
android:textColor="@color/black"
android:textSize="24sp"
android:visibility="visible" />
android:visibility="visible"
tools:text="2026-03-13 星期五"/>
<!-- 用户信息区(已登录时显示,未登录隐藏) -->
<LinearLayout
@@ -73,13 +76,14 @@
</LinearLayout>
<!-- 功能入口区域,水平居中排列两个入口图标 -->
<!-- 功能入口区域(原始,已隐藏) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
android:orientation="horizontal"
android:visibility="gone">
<!-- 采购单入库入口 -->
<ImageView
@@ -103,4 +107,44 @@
</LinearLayout>
<!-- 功能入口区域(新版,按钮样式) -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
<!-- 净菜入库入口 -->
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnCleanDishStore"
android:layout_width="595dp"
android:layout_height="477dp"
android:background="@drawable/bg_gradient_blue_btn"
android:foreground="?android:attr/selectableItemBackground"
android:text="净菜"
android:textColor="@color/white"
android:textSize="100sp"
android:textStyle="bold"
android:stateListAnimator="@null" />
<Space
android:layout_width="15dp"
android:layout_height="1dp" />
<!-- 毛菜入库入口 -->
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnRawDishStore"
android:layout_width="595dp"
android:layout_height="477dp"
android:background="@drawable/bg_gradient_orange_btn"
android:foreground="?android:attr/selectableItemBackground"
android:text="毛菜"
android:textColor="@color/white"
android:textSize="100sp"
android:textStyle="bold"
android:stateListAnimator="@null" />
</LinearLayout>
</LinearLayout>
+88
View File
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@mipmap/bg"
android:orientation="vertical"
android:gravity="center">
<!-- Loading 动画容器 -->
<LinearLayout
android:id="@+id/llLoading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:visibility="visible">
<!-- 加载动画 -->
<ImageView
android:id="@+id/ivLoading"
android:layout_width="160dp"
android:layout_height="160dp"
android:src="@drawable/loading"
android:contentDescription="加载中" />
<!-- 倒计时文字 -->
<TextView
android:id="@+id/tvCountdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="60秒"
android:textSize="40sp"
android:textColor="@color/black"
android:textStyle="bold"
android:layout_marginTop="50dp" />
<!-- 提示文字 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="网络连接检测中,请稍后……"
android:textSize="22sp"
android:textColor="#555555"
android:layout_marginTop="20dp" />
</LinearLayout>
<!-- 连接网络按钮容器 -->
<LinearLayout
android:id="@+id/llNetworkButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:visibility="gone">
<!-- 网络错误图标 -->
<ImageView
android:layout_width="140dp"
android:layout_height="140dp"
android:src="@drawable/ic_network_error"
android:contentDescription="网络连接失败"
android:layout_marginBottom="30dp" />
<!-- 连接失败提示 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="网络连接失败"
android:textSize="36sp"
android:textColor="@color/black"
android:textStyle="bold"
android:layout_marginBottom="40dp" />
<!-- 连接网络按钮 -->
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnConnectNetwork"
android:layout_width="350dp"
android:layout_height="70dp"
android:text="连接网络"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="@android:color/white"
android:background="@drawable/btn_connect_network" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="600dp"
android:layout_height="wrap_content"
android:background="@drawable/bg_white_radius12"
android:orientation="vertical"
android:paddingHorizontal="40dp"
android:paddingVertical="36dp">
<!-- 标题 -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="环境切换"
android:textColor="#ff141428"
android:textSize="36sp"
android:textStyle="bold" />
<!-- 分割线 -->
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginTop="24dp"
android:background="#FFDCDCF0" />
<!-- 环境选项 -->
<RadioGroup
android:id="@+id/rgEnv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:orientation="vertical">
<RadioButton
android:id="@+id/rbTest"
android:layout_width="match_parent"
android:layout_height="72dp"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:text="本地环境"
android:textColor="#ff141428"
android:textSize="28sp" />
<RadioButton
android:id="@+id/rbUat"
android:layout_width="match_parent"
android:layout_height="72dp"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:text="测试环境"
android:textColor="#ff141428"
android:textSize="28sp" />
<RadioButton
android:id="@+id/rbProd"
android:layout_width="match_parent"
android:layout_height="72dp"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:text="生产环境"
android:textColor="#ff141428"
android:textSize="28sp" />
</RadioGroup>
<!-- 分割线 -->
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginTop="24dp"
android:background="#FFDCDCF0" />
<!-- 底部按钮 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="88dp"
android:layout_marginTop="24dp"
android:gravity="center"
android:orientation="horizontal">
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnCancel"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="20dp"
android:layout_weight="1"
android:background="@drawable/bg_white_radius10_stroke2"
android:text="取消"
android:textColor="#ff141428"
android:textSize="28sp" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnConfirm"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="20dp"
android:layout_weight="1"
android:background="@drawable/bg_blue_radius10"
android:text="确认"
android:textColor="#ffffff"
android:textSize="28sp" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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="1500dp"
android:layout_height="900dp"
android:background="@drawable/bg_white_radius12"
tools:ignore="HardcodedText">
<ImageView
android:id="@+id/ivClose"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_marginTop="30dp"
android:layout_marginEnd="30dp"
android:padding="10dp"
android:src="@mipmap/ic_close"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/tvDialogTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="物品检索"
android:textColor="#ff141428"
android:textSize="36sp"
android:textStyle="bold"
app:layout_constraintBottom_toTopOf="@+id/flSearchBlock"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<FrameLayout
android:id="@+id/flSearchBlock"
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_marginHorizontal="150dp"
android:layout_marginTop="180dp"
android:background="@drawable/bg_white_radius10_stroke2"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<EditText
android:id="@+id/etInputGoods"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#00000000"
android:gravity="center"
android:hint="输入溯源码"
android:imeOptions="actionSearch"
android:inputType="text"
android:maxLines="1"
android:paddingStart="10dp"
android:paddingEnd="80dp"
android:textColor="#FF666666"
android:textColorHint="#FFC7C8DC"
android:textSize="30sp" />
<ImageView
android:id="@+id/ivGoodsSearch"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="16dp"
android:padding="14dp"
android:src="@mipmap/ic_goods_search"
tools:ignore="ContentDescription" />
</FrameLayout>
<com.scwang.smart.refresh.layout.SmartRefreshLayout
android:id="@+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginHorizontal="130dp"
android:layout_marginTop="40dp"
android:layout_marginBottom="58dp"
app:layout_constraintBottom_toTopOf="@id/dividerLine"
app:layout_constraintTop_toBottomOf="@id/flSearchBlock">
<com.scwang.smart.refresh.header.ClassicsHeader
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvSearch"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:overScrollMode="never"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
app:spanCount="2"
tools:itemCount="8"
tools:listitem="@layout/list_item_goods_search" />
<com.scwang.smart.refresh.footer.ClassicsFooter
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.scwang.smart.refresh.layout.SmartRefreshLayout>
<View
android:id="@+id/dividerLine"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginHorizontal="40dp"
android:layout_marginBottom="160dp"
android:background="#FFDCDCF0"
app:layout_constraintBottom_toBottomOf="parent" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnAddGoods"
android:layout_width="240dp"
android:layout_height="80dp"
android:background="@drawable/bg_white_stroke_blue_ripple"
android:text="新增入库"
android:textColor="#FF0033CC"
android:textSize="30sp"
android:textStyle="bold"
android:visibility="visible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/btnConfirm"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/dividerLine" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnConfirm"
android:layout_width="240dp"
android:layout_height="80dp"
android:background="@drawable/bg_blue_ripple"
android:text="确定"
android:textColor="@color/white"
android:textSize="30sp"
android:textStyle="bold"
android:layout_marginStart="100dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/btnAddGoods"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/dividerLine" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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="1500dp"
android:layout_height="900dp"
android:background="@drawable/bg_white_radius12"
android:gravity="center_horizontal"
android:orientation="vertical"
tools:ignore="HardcodedText">
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="14"
android:gravity="center"
android:text="物品入库"
android:textColor="#ff141428"
android:textSize="36sp"
android:textStyle="bold" />
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginHorizontal="40dp"
android:background="#FFDCDCF0" />
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="70">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline"
android:layout_width="1dp"
android:layout_height="match_parent"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.70" />
<!-- 左侧:FlexboxLayoutManager 驱动的动态表单,两列自动换行 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvFormFields"
android:layout_width="0dp"
android:layout_height="match_parent"
android:clipToPadding="false"
android:descendantFocusability="afterDescendants"
android:paddingHorizontal="10dp"
android:paddingVertical="10dp"
app:layout_constraintEnd_toStartOf="@id/guideline"
app:layout_constraintStart_toStartOf="parent"
tools:listitem="@layout/list_item_form_field" />
<!-- 右侧:相机预览 + 秤重显示 -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:gravity="center_vertical"
android:orientation="vertical"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginHorizontal="20dp"
app:layout_constraintStart_toEndOf="@id/guideline">
<!-- 相机预览容器,由代码动态 inflate LayoutTakePhotoBinding -->
<FrameLayout
android:id="@+id/flContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="15dp" />
<!-- 秤重显示条 -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="70dp"
android:layout_gravity="center_horizontal"
android:background="@drawable/bg_gray_radius10">
<TextView
android:layout_width="120dp"
android:layout_height="match_parent"
android:layout_gravity="start"
android:background="@drawable/bg_white_radius10_stroke2"
android:gravity="center"
android:text="累计"
android:textColor="#FF141428"
android:textSize="24sp" />
<TextView
android:id="@+id/tvTotalWeight"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_marginHorizontal="120dp"
android:gravity="center"
android:textColor="#FF141428"
android:textSize="24sp"
tools:text="6300 克" />
<TextView
android:id="@+id/btnClearZero"
android:layout_width="120dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:background="@drawable/bg_white_radius10_stroke2"
android:gravity="center"
android:text="清零"
android:textColor="#FF141428"
android:textSize="24sp" />
</FrameLayout>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<View
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginHorizontal="40dp"
android:background="#FFDCDCF0" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="16"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="50dp">
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnCancel"
android:layout_width="240dp"
android:layout_height="80dp"
android:background="@drawable/bg_white_stroke_blue_ripple"
android:text="取消"
android:textColor="#FF0033CC"
android:textSize="30sp"
android:textStyle="bold" />
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnConfirm"
android:layout_width="240dp"
android:layout_height="80dp"
android:layout_marginStart="30dp"
android:background="@drawable/bg_blue_ripple"
android:text="确定"
android:textColor="@color/white"
android:textSize="30sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
@@ -7,8 +7,10 @@
android:gravity="center"
android:orientation="vertical">
<!-- android:layout_width="488dp"-->
<!-- android:layout_height="325dp"-->
<FrameLayout
android:layout_width="488dp"
android:layout_width="match_parent"
android:layout_height="325dp"
android:background="@drawable/bg_white_radius10_stroke2_dash">
@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="82dp"
android:layout_height="100dp"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_marginVertical="10dp"
android:layout_marginHorizontal="20dp"
android:orientation="horizontal"
@@ -11,23 +12,41 @@
<TextView
android:id="@+id/tvGoodsName"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
tools:text="高杆白菜"
android:textColor="#ff666666"
android:textColor="#ff333333"
android:textSize="30sp"
app:layout_constraintVertical_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/tvGoodsNum"
android:layout_marginStart="30dp"
android:maxLines="1"
android:layout_marginHorizontal="10dp"
android:ellipsize="end"
android:textStyle="bold" />
<TextView
android:id="@+id/tvGoodsNum"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="已采集100"
app:layout_constraintStart_toStartOf="@id/tvGoodsName"
app:layout_constraintTop_toBottomOf="@+id/tvGoodsName"
app:layout_constraintBottom_toBottomOf="parent"
android:textColor="#ff666666"
android:layout_marginTop="5dp"
android:textSize="20sp"
android:maxLines="1"
android:ellipsize="end" />
<ImageView
android:id="@+id/ivDelete"
android:layout_width="80dp"
android:layout_height="80dp"
android:padding="25dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:src="@mipmap/ic_close"/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingVertical="10dp">
<!-- 字段标签,必填项红色星号由 Adapter 通过 SpannableString 追加 -->
<TextView
android:id="@+id/tvLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginBottom="6dp"
android:textColor="#FF141428"
android:textSize="20sp"
android:textStyle="bold"
tools:text="食材名称" />
<!-- 输入区域容器,三种控件叠放,按 FieldType 切换 visibility -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_marginHorizontal="12dp">
<!-- TEXT / NUMBER / FIXED 类型:EditText -->
<EditText
android:id="@+id/etInput"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_white_radius10_stroke2"
android:focusableInTouchMode="true"
android:maxLines="1"
android:paddingHorizontal="20dp"
android:textColor="#FF141428"
android:textColorHint="#FF96A0AA"
android:textSize="22sp"
android:textStyle="bold"
android:visibility="gone"
tools:ignore="Autofill,TextFields" />
<!-- DROPDOWN 类型:点击触发 DropdownPopup -->
<FrameLayout
android:id="@+id/flDropdown"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_white_radius10_stroke2"
android:visibility="gone">
<TextView
android:id="@+id/tvDropdownValue"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginEnd="44dp"
android:layout_marginStart="20dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
android:textColor="#FF141428"
android:textColorHint="#FF96A0AA"
android:textSize="22sp"
android:textStyle="bold" />
<ImageView
android:layout_width="32dp"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="16dp"
android:src="@mipmap/ic_triangle_down"
tools:ignore="ContentDescription" />
</FrameLayout>
<!-- DATE_PICKER 类型:点击触发 DatePickerDialog -->
<FrameLayout
android:id="@+id/flDatePicker"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_white_radius10_stroke2"
android:visibility="gone">
<TextView
android:id="@+id/tvDateValue"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginEnd="44dp"
android:layout_marginStart="20dp"
android:gravity="center_vertical"
android:textColor="#FF141428"
android:textColorHint="#FF96A0AA"
android:textSize="22sp"
android:textStyle="bold" />
<ImageView
android:layout_width="32dp"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:layout_marginEnd="16dp"
android:src="@mipmap/ic_triangle_down"
tools:ignore="ContentDescription" />
</FrameLayout>
</FrameLayout>
</LinearLayout>
@@ -5,7 +5,7 @@
android:layout_height="82dp"
android:layout_marginVertical="10dp"
android:layout_marginHorizontal="20dp"
tools:background="@drawable/bg_white_stroke_blue_ripple">
android:background="@drawable/bg_gray_radius10">
<TextView
android:id="@+id/tvGoodsName"
+1 -1
View File
@@ -1,3 +1,3 @@
<resources>
<string name="app_name">入库客户端</string>
<string name="app_name">入库</string>
</resources>
+2 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Inbound" parent="android:Theme.Material.Light.NoActionBar">
<style name="Theme.Inbound" parent="Theme.MaterialComponents.Light.NoActionBar">
<!-- <item name="android:windowBackground">@drawable/bg_splash</item>-->
</style>
@@ -41,4 +41,5 @@
<item name="windowNoTitle">true</item>
<item name="windowActionBar">false</item>
</style>
</resources>
+3 -3
View File
@@ -28,9 +28,9 @@ buildscript {
mavenCentral()
}
}
plugins {
alias(libs.plugins.kotlin.compose) apply false
}
//plugins {
// alias(libs.plugins.kotlin.compose) apply false
//}
allprojects {
repositories {
+487
View File
@@ -0,0 +1,487 @@
# 入库系统接口文档
> 生成时间:2026-05-26
> 项目:Inbound20260114
---
## 一、通用说明
### 1.1 服务器环境
| 环境 | 地址 |
|------|------|
| 本地环境 | `http://192.168.1.201:14801` |
| 测试环境 | `https://dev.yixiong-tech.com:8083` |
| 生产环境(PROD | `https://api.dm.yixiong-tech.com:8443` |
> 代码中使用 `GlobalData.appBaseUrl` 动态切换,启动时由设备配置决定当前所用环境。
---
### 1.2 通用请求头
以下请求头由拦截器(`RequestInterceptor`)统一注入,**无需在每个接口中单独传递**:
| Header 字段 | 说明 | 示例值 |
|---|---|---|
| `Content-Type` | 请求体格式 | `application/json` |
| `Accept` | 响应体格式 | `application/json` |
| `deviceId` | 设备唯一标识符(UUID | `bcf396ed-78f6-3864-...` |
| `X-DEVICE-CODE` | 设备编码(同 deviceId | `bcf396ed-78f6-3864-...` |
| `x-access-token` | 用户登录 Token(登录后获取) | `{token}` |
| `authorization` | 固定鉴权值 | `57ee87183f2a4fa59683ec9ef41c8f5d` |
> **特殊说明**`getDeviceInfo` 接口额外通过 `@Header("X-Access-Token")` 传入设备 Token(设备初始化阶段使用,区别于用户登录 Token)。
---
### 1.3 通用响应格式
所有接口返回结构统一如下:
```json
{
"code": "00000",
"msg": "success",
"data": {}
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | String | 业务状态码,`00000` 表示成功 |
| `msg` | String | 提示信息 |
| `data` | T / null | 业务数据,各接口不同 |
---
### 1.4 接口路径前缀
业务接口通用前缀(简称 `{prefix}`):
```
/terminal/neglect/inbound
```
---
## 二、接口列表
---
### 2.1 获取存储方式列表
| 项目 | 内容 |
|------|------|
| 请求方式 | `GET` |
| 接口路径 | `{baseUrl}{prefix}/pad/getDictItemByF` |
**Query 参数:**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `dictCode` | String | 否 | 字典编码,固定值 `ccfs`(存储方式) |
**响应 data`List<StorageType>`):**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | String | 存储方式 ID |
| `value` | String | 存储方式值 |
| `text` | String | 展示文本 |
| `title` | String | 标题 |
| `label` | String | 标签 |
---
### 2.2 获取物品类型列表
| 项目 | 内容 |
|------|------|
| 请求方式 | `GET` |
| 接口路径 | `{baseUrl}{prefix}/pad/notLimitList` |
**Query 参数:**
**响应 data`GoodsType`):**
| 字段 | 类型 | 说明 |
|------|------|------|
| `allType` | List\<AllType\> | 物品类型列表 |
**AllType 结构:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | String | 类型 ID |
| `typeName` | String | 类型名称 |
| `typeCode` | String | 类型编码 |
| `ftype` | String | 父类型 |
| `superName` | String | 父类型名称 |
| `status` | Boolean | 是否启用 |
| `eaId` | Int | 企业 ID |
| `kidTypes` | List\<String\> | 子类型列表 |
| `createTime` | String | 创建时间 |
| `updateTime` | String | 更新时间 |
---
### 2.3 获取字典数据列表
| 项目 | 内容 |
|------|------|
| 请求方式 | `GET` |
| 接口路径 | `{baseUrl}{prefix}/pad/dataList` |
**Query 参数:**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `type` | String | 是 | 字典类型,如仓库列表、单位类型等 |
**响应 data`List<DictType>`):**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | String | 字典项 ID |
| `value` | String | 字典项名称/值 |
---
### 2.4 采购单入库列表
| 项目 | 内容 |
|------|------|
| 请求方式 | `POST` |
| 接口路径 | `{baseUrl}{prefix}/pad/receivePage` |
| Content-Type | `application/json` |
**请求体(JSON`Map<String, String>`):**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `pageNo` | String | 是 | 页码,从 1 开始 |
| `pageSize` | String | 是 | 每页条数 |
| *(其他筛选条件)* | String | 否 | 如供应商ID、日期范围等 |
**响应 data`List<SupplierInfo>`):**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | String | 收货单 ID |
| `purCode` | String | 采购单号 |
| `receiveCode` | String | 收货单号 |
| `supplierId` | String | 供应商 ID |
| `supplierName` | String | 供应商名称 |
| `purchaseDate` | String | 采购日期 |
| `receiveDate` | String | 收货时间 |
| `receiveStatus` | Int | 收货状态:1-收货关闭 / 2-收货完成 / 3-待收货 / 4-部分收货 |
| `receiveUser` | String | 操作人 |
| `goodCount` | Int | 物品项数 |
| `receiveCountAll` | Double | 总收货数量 |
| `receivePriceEx` | Double | 收货金额(不含税) |
| `receivePriceIn` | Double | 收货金额(含税) |
| `taxRateAll` | Double | 税率合计 |
| `giftAll` | Double | 赠品合计 |
| `warehouseId` | String | 仓库 ID |
| `eaId` | Int | 企业 ID |
| `createTime` | String | 创建时间 |
| `updateTime` | String | 更新时间 |
---
### 2.5 采购单入库详情
| 项目 | 内容 |
|------|------|
| 请求方式 | `GET` |
| 接口路径 | `{baseUrl}{prefix}/pad/receiveDetail` |
**Query 参数:**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `id` | String | 是 | 收货单 ID |
**响应 data`PurchaseInfo`):**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | String | 收货单 ID |
| `supplierId` | String | 供应商 ID |
| `supplierName` | String | 供应商名称 |
| `purCode` | String | 采购单号 |
| `receiveCode` | String | 收货单号 |
| `receiveGoodsInfoList` | List\<GoodsInfo\> | 物品明细列表(见 **GoodsInfo 结构** |
**GoodsInfo 结构:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | String | 明细 ID |
| `goodId` | String | 物品 ID |
| `goodName` | String | 物品名称 |
| `unitName` | String | 采购单位名称 |
| `purchaseUnitId` | String | 采购单位 ID |
| `goodsPurId` | String | 物品采购单位关系 ID |
| `receiveCount` | Double | 应收数量 |
| `receivedNum` | Double | 实收数量 |
| `receiveCountAll` | Double | 总收货数量 |
| `recUnitPriceTaxIn` | Double | 含税单价 |
| `newRecUnitPriceTaxIn` | Double | 调整后单价(部分收货使用) |
| `recPriceExItem` | Double | 不含税金额 |
| `recPriceInItem` | Double | 含税金额 |
| `warehouseId` | String | 仓库 ID |
| `warehouseName` | String | 仓库名称 |
| `kcUnitId` | String | 库存单位 ID |
| `kcUnitName` | String | 库存单位名称 |
| `purchaseValue` | Int | 采购库存转换值 |
| `consumeValue` | String | 库存与克的转换值 |
| `goodsWeight` | BigDecimal | 物品重量(千克) |
| `goodsCount` | Double | 物品数量 |
| `goodsUnitPrice` | Double | 物品单价 |
| `goodsPrice` | Double | 物品总价 |
| `buyToInventoryValue` | String | 采购转库存换算值 |
---
### 2.6 上传单张图片
| 项目 | 内容 |
|------|------|
| 请求方式 | `POST` |
| 接口路径 | `{baseUrl}/terminal/neglect/upload` |
| Content-Type | `multipart/form-data` |
**Form-Data 参数:**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `file` | FileMultipartBody.Part | 是 | 图片文件 |
**响应 data**
```
String // 图片访问路径(相对 URL)
```
---
### 2.7 上传多张图片
| 项目 | 内容 |
|------|------|
| 请求方式 | `POST` |
| 接口路径 | `{baseUrl}/terminal/neglect/inbound/pad/inboundWeighingScale/add` |
| Content-Type | `multipart/form-data` |
**Form-Data 参数:**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `goodsId` | StringRequestBody | 是 | 物品 ID |
| `goodsName` | StringRequestBody | 是 | 物品名称 |
| `goodsPics` | List\<MultipartBody.Part\> | 是 | 多张图片文件列表,字段名固定为 `goodsPics` |
**响应 data**
```
String // 操作结果
```
---
### 2.8 搜索物品列表
| 项目 | 内容 |
|------|------|
| 请求方式 | `POST` |
| 接口路径 | `{baseUrl}{prefix}/pad/goodsInfoList` |
| Content-Type | `application/json` |
**请求体(JSON`Map<String, String>`):**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `goodsName` | String | 否 | 物品名称关键词 |
| `pageNo` | String | 是 | 页码 |
| `pageSize` | String | 是 | 每页条数 |
| *(其他筛选条件)* | String | 否 | 按需传入 |
**响应 data`SearchGoodsInfo`,分页结构):**
| 字段 | 类型 | 说明 |
|------|------|------|
| `current` | Int | 当前页码 |
| `total` | Int | 总记录数 |
| `size` | Int | 每页条数 |
| `pages` | Int | 总页数 |
| `records` | List\<Record\> | 物品记录列表(见 **Record 结构** |
**Record 结构:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | String | 物品 ID |
| `goodsCode` | String | 物品编码 |
| `goodsName` | String | 物品名称 |
| `zjmCode` | String | 助记码 |
| `specification` | String | 规格 |
| `typeId` | Int | 物品类型 ID |
| `typeName` | String | 物品类型名称 |
| `kcUnitId` | Int | 库存单位 ID |
| `kcUnitName` | String | 库存单位名称 |
| `consumeValue` | String | 库存与克的转换值 |
| `purchaseValue` | String | 采购库存转换值 |
| `unit` | String | 单位 |
| `relativeUrl` | String | 物品图片相对路径 |
| `unitVoList` | List\<UnitVo\> | 可选采购单位列表(见 **UnitVo 结构** |
**UnitVo 结构:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `businessUnitId` | String | 单位关系 ID |
| `purchaseUnitName` | String | 采购单位名称 |
| `buyToInventoryValue` | String | 采购转库存换算值 |
| `consumeValue` | String | 消耗转换值 |
| `purchaseValue` | Double | 购买单位对应库存数量 |
| `isDefault` | Int | 是否默认单位(1-是) |
| `value` | String | 单位展示值 |
---
### 2.9 图像识别物品列表查询
| 项目 | 内容 |
|------|------|
| 请求方式 | `POST` |
| 接口路径 | `{baseUrl}{prefix}/pad/goodsList` |
| Content-Type | `application/json` |
> 用于图片识别后,根据识别到的物品名称列表批量查询物品信息。
**请求体(`RecGoodsName`):**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `nameList` | List\<String\> | 是 | 识别到的物品名称列表 |
**响应 data**
```
List<SearchGoodsInfo.Record> // 物品记录列表(字段同 2.8 的 Record 结构)
```
---
### 2.10 自采添加新物品
| 项目 | 内容 |
|------|------|
| 请求方式 | `POST` |
| 接口路径 | `{baseUrl}{prefix}/pad/goodsAdd` |
| Content-Type | `application/json` |
**请求体(`GoodsAddParam`):**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `goodName` | String | 是 | 物品名称 |
| `goodCode` | String | 否 | 物品编号(服务端自动生成) |
| `goodType` | String | 是 | 物品类型 ID |
| `storageType` | String | 是 | 存储方式 ID |
| `netRate` | Float | 是 | 净材率(0~1 之间) |
| `unitId` | String | 是 | 库存单位 ID |
| `purchaseUnit` | String | 是 | 采购单位 ID |
| `purchaseValue` | Float | 是 | 采购单位对应库存数量(转换值) |
| `purchasePrice` | BigDecimal | 是 | 采购单价 |
| `relativeUrl` | String | 否 | 物品图片相对路径 |
| `consumeValue` | String | 是 | 库存单位对应消耗值(库存与克的转换值) |
**响应 data**
```
String // 新增物品 ID 或操作结果
```
---
### 2.11 确认收货(全部收货)
| 项目 | 内容 |
|------|------|
| 请求方式 | `POST` |
| 接口路径 | `{baseUrl}{prefix}/pad/saveAndReceiveAndGoWare` |
| Content-Type | `application/json` |
**请求体(`UploadInfo`):**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `id` | String | 是 | 收货单 ID |
| `supplierId` | String | 否 | 供应商 ID |
| `purCode` | String | 否 | 采购单号 |
| `receiveGoodsInfos` | List\<GoodsInfo\> | 是 | 全部收货物品列表(字段同 GoodsInfo) |
| `receiveGoodsInfoList` | List\<GoodsInfo\> | 否 | 部分收货列表(全量收货时传空列表) |
**响应 data**
```
Boolean // true 表示操作成功
```
---
### 2.12 自采入库
| 项目 | 内容 |
|------|------|
| 请求方式 | `POST` |
| 接口路径 | `{baseUrl}{prefix}/pad/selfPurchasedGoods` |
| Content-Type | `application/json` |
**请求体(`List<PurchaseWarehouseParam>`):**
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| `goodsId` | String | 是 | 物品 ID |
| `kcUnitId` | String | 否 | 库存单位 ID |
| `goodsCount` | Double | 是 | 入库数量 |
| `goodsUnitPrice` | Double | 是 | 入库单价 |
| `goodsPrice` | Double | 是 | 入库金额 |
| `goodPurId` | String | 是 | 物品采购单位关系 ID(`businessUnitId` |
| `warehouseId` | String | 是 | 仓库 ID |
| `buyToInventoryValue` | String | 否 | 采购转库存换算值 |
| `goodsWeight` | BigDecimal | 是 | 物品重量(千克) |
| `consumeValue` | String | 否 | 库存单位对应消耗值 |
> 注:`goodsName`、`unitName`、`unitList`、`selectUnitType` 等字段为本地 UI 展示用,**不上传至服务端**。
**响应 data**
```
Boolean // true 表示入库成功
```
---
## 三、接口速查表
| # | 接口名称 | 方法 | 路径 |
|---|----------|------|------|
| 2.1 | 获取存储方式列表 | GET | `{prefix}/pad/getDictItemByF` |
| 2.2 | 获取物品类型列表 | GET | `{prefix}/pad/notLimitList` |
| 2.3 | 获取字典数据列表 | GET | `{prefix}/pad/dataList` |
| 2.4 | 采购单入库列表 | POST | `{prefix}/pad/receivePage` |
| 2.5 | 采购单入库详情 | GET | `{prefix}/pad/receiveDetail` |
| 2.6 | 上传单张图片 | POST | `/terminal/neglect/upload` |
| 2.7 | 上传多张图片 | POST | `/terminal/neglect/inbound/pad/inboundWeighingScale/add` |
| 2.8 | 搜索物品列表 | POST | `{prefix}/pad/goodsInfoList` |
| 2.9 | 图像识别物品查询 | POST | `{prefix}/pad/goodsList` |
| 2.10 | 自采添加新物品 | POST | `{prefix}/pad/goodsAdd` |
| 2.11 | 确认收货(全部) | POST | `{prefix}/pad/saveAndReceiveAndGoWare` |
| 2.12 | 自采入库 | POST | `{prefix}/pad/selfPurchasedGoods` |
> `{prefix}` = `/terminal/neglect/inbound`
+289
View File
@@ -0,0 +1,289 @@
# 入库秤设备端接口文档
**Base URL**`http://192.168.10.101:24801/nutrition/neglect/inbound`
**鉴权**:无需 Sa-TokenNacos 白名单 `/nutrition/neglect/**`
| Header 字段 | 说明 | 示例值 |
|---|---|---|
| `authorization` | 固定鉴权值 | `57ee87183f2a4fa59683ec9ef41c8f5d` |
**Content-Type**`application/json`
---
## 通用响应结构
```json
{
"code": 200,
"msg": "success",
"data": { ... }
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| code | int | 200 成功,其他为业务错误码 |
| msg | string | 提示信息 |
| data | any | 响应数据,无数据时为 `null` |
---
## 接口列表
### 1. 按溯源码查食材
**GET** `/nutrition/neglect/inbound/ingredient-by-trace`
按溯源码反查命中的食材列表,返回食材 id、名称、净菜类型,用于毛菜/净菜入库时联动选择食材。
**Query 参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| traceCode | string | ✅ | 溯源码 |
**响应示例**
```json
{
"code": 200,
"msg": "success",
"data": [
{
"traceCode": "TC20240501001",
"materId": 1001,
"materName": "西红柿",
"vegTypes": "切丁,切片"
}
]
}
```
**响应字段说明**
| 字段 | 类型 | 说明 |
|------|------|------|
| traceCode | string | 溯源码 |
| materId | long | 食材 id(关联 nut_mater_base.id |
| materName | string | 食材名称 |
| vegTypes | string | 净菜类型,逗号分隔,如 `切丁,切片` |
---
### 2. 仓库区域下拉列表
**GET** `/nutrition/neglect/inbound/zone-options`
获取仓库区域选项,用于入库时选择存放区域。
**Query 参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| warehouseId | long | ❌ | 仓库 id,不传则返回所有区域 |
| keyword | string | ❌ | 关键字模糊搜索(区域编号/名称) |
**响应示例**
```json
{
"code": 200,
"msg": "success",
"data": [
{
"id": 101,
"zoneNo": "WH-A-01",
"zoneName": "A仓-蔬菜区-01",
"warehouseId": 10
}
]
}
```
**响应字段说明**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | long | 区域主键 id |
| zoneNo | string | 区域编号,如 `WH-A-01` |
| zoneName | string | 区域名称,如 `A仓-蔬菜区-01` |
| warehouseId | long | 关联仓库 id |
---
### 3. 供应商下拉列表
**GET** `/nutrition/neglect/inbound/supplier-options`
获取供应商选项,内部与外部供应商合并返回。
**Query 参数**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| type | int | ❌ | 供应商类型:`1`-内部,`2`-外部,不传则返回全部 |
| keyword | string | ❌ | 关键字模糊搜索(供应商名称) |
**响应示例**
```json
{
"code": 200,
"msg": "success",
"data": [
{
"id": 201,
"name": "绿源农场",
"type": 1
},
{
"id": 202,
"name": "鲜达食品有限公司",
"type": 2
}
]
}
```
**响应字段说明**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | long | 供应商 id |
| name | string | 供应商名称 |
| type | int | `1`-内部供应商,`2`-外部供应商 |
---
### 4. 毛菜入库提交
**POST** `/nutrition/neglect/inbound/raw-inbound`
入库秤设备提交毛菜入库记录,`opType` 由后端固定为 `1`(设备自动),无需传入。
**Request Body**
```json
{
"traceCode": "TC20240501001",
"supplierType": 1,
"materialName": "西红柿",
"supplierId": 201,
"spec": "25kg/件",
"quantity": 2,
"weight": 49.8,
"zoneId": 101,
"operator": "张三",
"storageArea": "A仓-蔬菜区-01",
"reportNo": "RPT-2024-001",
"cameraCode": "CAM-001",
"videoUrl": "http://example.com/video/001.mp4",
"remark": ""
}
```
**字段说明**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| traceCode | string | ✅ | 溯源码 |
| supplierType | short | ✅ | 供应商类型:`1`-内部,`2`-外部 |
| materialName | string | ✅ | 食材名称 |
| supplierId | long | ✅ | 供应商 id |
| spec | string | ✅ | 入库规格,如 `25kg/件``散装(kg)` |
| quantity | int | ✅ | 入库数量 |
| weight | decimal | ✅ | 入库重量(kg |
| zoneId | long | ✅ | 存放区域 id |
| operator | string | ✅ | 操作人姓名 |
| storageArea | string | ❌ | 存放区域名称(冗余字段,可与 zoneId 对应名称一致) |
| reportNo | string | ❌ | 卫生检测报告编号 |
| cameraCode | string | ❌ | 摄像头编号 |
| videoUrl | string | ❌ | 监控视频地址 |
| remark | string | ❌ | 备注 |
**响应示例**
```json
{
"code": 200,
"msg": "保存成功",
"data": null
}
```
---
### 5. 净菜入库提交
**POST** `/nutrition/neglect/inbound/clean-inbound`
入库秤设备提交净菜入库记录,`opType` 由后端固定为 `1`(设备自动),无需传入。
**Request Body**
```json
{
"traceCode": "TC20240501002",
"ingredientName": "西红柿",
"cleanType": "切丁",
"spec": "袋装/500g",
"quantity": 10,
"weight": 5.0,
"inboundTime": "2024-05-01T08:30:00",
"zoneId": 102,
"operator": "李四",
"expiryDate": "2024-05-03T00:00:00",
"storageTemp": 4.5,
"cameraCode": "CAM-002",
"videoUrl": "http://example.com/video/002.mp4",
"remark": ""
}
```
**字段说明**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| traceCode | string | ✅ | 溯源码 |
| ingredientName | string | ✅ | 食材名称 |
| cleanType | string | ✅ | 净菜类型,如 `切丁``切片`(参考接口1返回的 `vegTypes` |
| spec | string | ✅ | 入库规格,如 `袋装/500g``盒装/300g` |
| quantity | int | ✅ | 入库数量(包/盒/份) |
| weight | decimal | ✅ | 入库重量(kg |
| inboundTime | datetime | ✅ | 入库时间,格式:`yyyy-MM-dd'T'HH:mm:ss` |
| zoneId | long | ✅ | 仓库区域 id |
| operator | string | ❌ | 操作人姓名 |
| expiryDate | datetime | ❌ | 保质期至,格式同 `inboundTime` |
| storageTemp | decimal | ❌ | 存放温度(℃) |
| cameraCode | string | ❌ | 摄像头编号 |
| videoUrl | string | ❌ | 监控视频地址 |
| remark | string | ❌ | 备注 |
**响应示例**
```json
{
"code": 200,
"msg": "保存成功",
"data": null
}
```
---
## 典型业务流程
```
设备扫溯源码
GET /ingredient-by-trace?traceCode=xxx → 获取食材列表
GET /zone-options → 获取区域列表
GET /supplier-options → 获取供应商列表(毛菜需要)
用户确认信息 / 秤自动称重
POST /raw-inbound (毛菜)
POST /clean-inbound(净菜)
```