fix: 修复图片上传字段、航班查询接口及图片鉴权加载问题

- 国际进港舱单列表页航班查询接口改为 /flt/searchFlightList,支持多航班校验
- 修复国内进港移库编辑/交接页图片上传缺少 pic、picNumber 字段
- 国际进港舱单详情页对接交接图片展示
- 图片缩略图和大图预览加载带 Authorization header 解决 403
- CLAUDE.md 新增图片上传与展示规范

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-02 16:25:24 +08:00
parent 52171c94df
commit cf8a7f38fb
15 changed files with 246 additions and 72 deletions

View File

@@ -119,13 +119,21 @@
"mcp__apifox__read_project_oas_ruugy8",
"mcp__apifox__read_project_oas_ref_resources_ldmedm",
"mcp__apifox__read_project_oas_ldmedm",
"mcp__apifox__refresh_project_oas_ldmedm"
"mcp__apifox__refresh_project_oas_ldmedm",
"Skill(update-config)",
"mcp__apidoc__get_project_overview",
"mcp__apidoc__search_endpoints",
"mcp__apidoc__list_endpoints",
"mcp__apidoc__get_endpoint_detail"
],
"deny": [],
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"空港集团 - API 文档",
"apifox"
"apifox",
"aerologic-app",
"apidoc"
]
}

View File

@@ -10,6 +10,13 @@
"env": {
"APIFOX_ACCESS_TOKEN": "APS-S2aVVwqasbdByzPLgSqryRC8BB0ZFqhQ"
}
},
"apidoc": {
"type": "http",
"url": "http://localhost:3001/mcp/c6a17835-6389-446c-8334-004b998835e5",
"headers": {
"Authorization": "Bearer afk_Snhv1JVACdbd91_NS699bb-2MN237Jww"
}
}
}
}

View File

@@ -958,6 +958,93 @@ companion object {
---
## 图片上传与展示规范
### 图片上传三字段规范
上传图片后提交表单时,**必须同时传 `pic``originalPic``picNumber` 三个字段**,缺一不可。
**`UploadUtil.upload()` 返回值**:
- `data?.newName` — 缩略图/压缩图文件名
- `data?.zipFileName` — 原图文件名
**提交时字段映射**(参考事故签证 `AccidentVisaDetailsViewModel`:
```kotlin
// FileBean 字段含义:
// - FileBean.url = newName缩略图文件名
// - FileBean.originalPic = zipFileName原图文件名
// 上传新图片
val data = UploadUtil.upload(fileBean.path).data
fileBean.url = data?.newName ?: ""
fileBean.originalPic = data?.zipFileName ?: ""
// 提交时设置三个字段
bean.picNumber = list.size.toString()
bean.pic = list.joinToString(",") { MediaUtil.removeUrl(it.url) } // 缩略图
bean.originalPic = list.joinToString(",") { MediaUtil.removeUrl(it.originalPic) } // 原图
```
**常见错误**:
- ❌ 只传 `images``originalPic` 单个字段 — 接口不认或数据不完整
- ❌ 只取 `newName` 不取 `zipFileName` — 丢失原图路径
- ❌ 用 `fileBean.path.startsWith("http")` 判断已上传 — 应该用 `fileBean.url.isNotEmpty()`
### 编辑页加载已有图片
从详情接口获取图片后,需要同时解析 `pic`(缩略图)和 `originalPic`(原图),构建完整的 `FileBean`
```kotlin
val picList = bean.pic.split(",").filter { it.isNotEmpty() }
val originalPicList = bean.originalPic.split(",").filter { it.isNotEmpty() }
val images = picList.mapIndexed { index, picUrl ->
val originalFile = originalPicList.getOrElse(index) { picUrl }
FileBean(
path = MediaUtil.fillUrl(picUrl), // 完整URL用于显示
url = picUrl, // 相对路径,提交时用
originalPic = MediaUtil.fillUrl(originalFile) // 原图完整URL
)
}.toMutableList()
```
### 图片加载必须带 Authorization Header
`/file/getImg/` 接口需要鉴权Glide 默认不带 token直接用 `loadImage` BindingAdapter 会 **403 Forbidden**
**正确做法** — 在 ViewHolder 中使用 `GlideUrl` + `LazyHeaders`
```kotlin
// 缩略图加载ViewHolder 中)
val glideUrl = GlideUrl(
bean.path,
LazyHeaders.Builder()
.addHeader("Authorization", SharedPreferenceUtil.getString(Constant.Share.token))
.build()
)
Glide.with(itemView.context).load(glideUrl).into(binding.ivThumbnail)
```
**同时必须去掉 XML 布局中的 `loadImage` 属性**,否则 BindingAdapter 会触发不带 token 的请求覆盖手动加载:
```xml
<!-- ❌ 错误:会触发不带 token 的 Glide 请求 -->
<ImageView loadImage="@{bean.path}" />
<!-- ✅ 正确:只保留 id由 ViewHolder 手动加载 -->
<ImageView android:id="@+id/iv_thumbnail" />
```
**大图预览同理**`PreviewImageViewHolder` 也需要用 `GlideUrl` 带 token 加载网络图片。
**参考文件**:
- 缩略图加载: `module_gjj/.../GjjManifestPicViewHolder.kt`
- 大图预览: `module_base/.../PreviewImageViewHolder.kt`
- 图片上传提交: `app/.../AccidentVisaDetailsViewModel.kt`
- 带 token 的 Glide 加载: `module_mit/.../PictureAdapter.kt`
---
## 开发原则
- 资源引用必须存在 — 创建布局前确认 drawable/color/string 资源真实存在或主动创建

View File

@@ -56,7 +56,10 @@ data class GjjManifest(
var subCode: String = "", // 子代码
var unNumber: String = "", // 危险品编号
var activeId: Long = 0, // 活动ID
var locationTally: String = "" // 理货库位号
var locationTally: String = "", // 理货库位号
var pic: String = "", // 交接图片缩略图路径
var originalPic: String = "", // 交接图片原图地址
var picNumber: String = "" // 交接图片数量
) : Serializable {
// 分单列表
var haWbList: List<GjjHaWb>? = null

View File

@@ -1367,6 +1367,12 @@ interface Api {
@POST("flt/queryFlight")
suspend fun queryFlightByDateAndNo(@Body data: RequestBody): BaseResultBean<FlightBean>
/**
* 根据航班日期、航班号、地区类型、进出港查询航班(返回列表)
*/
@POST("flt/searchFlightList")
suspend fun searchFlightList(@Body data: RequestBody): BaseResultBean<List<FlightBean>>
/**
* 获取航班目的站、经停站
*/

View File

@@ -1,9 +1,14 @@
package com.lukouguoji.module_base.ui.page.preview
import android.view.View
import com.bumptech.glide.Glide
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.LazyHeaders
import com.lukouguoji.module_base.base.BaseViewHolder
import com.lukouguoji.module_base.bean.FileBean
import com.lukouguoji.module_base.common.Constant
import com.lukouguoji.module_base.databinding.ItemPreviewImageBinding
import com.lukouguoji.module_base.db.perference.SharedPreferenceUtil
/**
* @author孟凡华
@@ -14,8 +19,26 @@ class PreviewImageViewHolder(view: View) :
BaseViewHolder<FileBean, ItemPreviewImageBinding>(view) {
override fun onBind(item: Any?, position: Int) {
binding.bean = getItemBean(item)
val bean = getItemBean(item) ?: return
binding.bean = bean
// 加载图片
val path = bean.path
if (path.isNotEmpty()) {
if (path.startsWith("http")) {
// 网络图片带 Authorization header
val glideUrl = GlideUrl(
path,
LazyHeaders.Builder()
.addHeader("Authorization", SharedPreferenceUtil.getString(Constant.Share.token))
.build()
)
Glide.with(itemView.context).load(glideUrl).into(binding.photoView)
} else {
// 本地图片直接加载
Glide.with(itemView.context).load(path).into(binding.photoView)
}
}
}
}

View File

@@ -13,7 +13,7 @@
android:layout_height="match_parent">
<com.luck.picture.lib.photoview.PhotoView
loadImage="@{bean.path}"
android:id="@+id/photo_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />

View File

@@ -13,6 +13,7 @@ import com.lukouguoji.module_base.base.CommonAdapter
import com.lukouguoji.module_base.ktx.addOnItemClickListener
import com.lukouguoji.module_base.router.ARouterConstants
@Deprecated("旧的实现")
@Route(path = ARouterConstants.ACTIVITY_URL_GJJ_MANIFEST)
class GjjManifestListActivity :
BaseBindingActivity<ActivityGjjManifestBinding, GjjManifestListViewModel>() {

View File

@@ -3,14 +3,20 @@ package com.lukouguoji.gjj.activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.recyclerview.widget.LinearLayoutManager
import com.alibaba.android.arouter.facade.annotation.Route
import com.lukouguoji.gjj.R
import com.lukouguoji.gjj.databinding.ActivityIntImpManifestDetailsBinding
import com.lukouguoji.gjj.holder.GjjManifestPicViewHolder
import com.lukouguoji.gjj.viewModel.IntImpManifestDetailsViewModel
import com.lukouguoji.module_base.base.BaseBindingActivity
import com.lukouguoji.module_base.base.CommonAdapter
import com.lukouguoji.module_base.bean.FileBean
import com.lukouguoji.module_base.bean.GjjManifest
import com.lukouguoji.module_base.common.Constant
import com.lukouguoji.module_base.router.ARouterConstants
import com.lukouguoji.module_base.util.MediaUtil
import com.lukouguoji.module_base.ktx.noNull
/**
* 国际进港舱单详情
@@ -26,6 +32,29 @@ class IntImpManifestDetailsActivity :
setBackArrow("进港舱单详情")
binding.viewModel = viewModel
viewModel.initOnCreated(intent)
// 交接图片
val picAdapter = CommonAdapter(
this,
R.layout.item_gjj_manifest_pic,
GjjManifestPicViewHolder::class.java
)
binding.rvPic.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
binding.rvPic.adapter = picAdapter
viewModel.dataBean.observe(this) { bean ->
val picList = bean.pic.noNull().split(",").filter { it.isNotEmpty() }
val originalPicList = bean.originalPic.noNull().split(",").filter { it.isNotEmpty() }
val list = picList.mapIndexed { index, picUrl ->
val originalFile = originalPicList.getOrElse(index) { picUrl }
FileBean(
path = MediaUtil.fillUrl(picUrl),
url = picUrl,
originalPic = MediaUtil.fillUrl(originalFile)
)
}
picAdapter.refresh(list)
}
}
companion object {

View File

@@ -1,9 +1,14 @@
package com.lukouguoji.gjj.holder
import android.view.View
import com.bumptech.glide.Glide
import com.bumptech.glide.load.model.GlideUrl
import com.bumptech.glide.load.model.LazyHeaders
import com.lukouguoji.gjj.databinding.ItemGjjManifestPicBinding
import com.lukouguoji.module_base.base.BaseViewHolder
import com.lukouguoji.module_base.bean.FileBean
import com.lukouguoji.module_base.common.Constant
import com.lukouguoji.module_base.db.perference.SharedPreferenceUtil
import com.lukouguoji.module_base.ktx.commonAdapter
import com.lukouguoji.module_base.ui.page.preview.PreviewActivity
@@ -14,6 +19,17 @@ class GjjManifestPicViewHolder(view: View) :
val bean = getItemBean(item)!!
binding.bean = bean
// 带 Authorization header 加载图片
if (bean.path.isNotEmpty()) {
val glideUrl = GlideUrl(
bean.path,
LazyHeaders.Builder()
.addHeader("Authorization", SharedPreferenceUtil.getString(Constant.Share.token))
.build()
)
Glide.with(itemView.context).load(glideUrl).into(binding.ivThumbnail)
}
binding.ivThumbnail.setOnClickListener {
val items = getRecyclerView()?.commonAdapter()?.items
?.filterIsInstance<FileBean>() ?: listOf(bean)

View File

@@ -64,17 +64,26 @@ class IntImpManifestViewModel : BasePageViewModel() {
lastQueriedFlight = key
launchCollect({
NetApply.api.getGjFlightBean(
NetApply.api.searchFlightList(
mapOf(
"fdate" to fdate,
"fno" to fno,
"ieFlag" to "I",
"status" to "1",
).toRequestBody()
)
}) {
onSuccess = {
if (it.verifySuccess() && it.data != null) {
val flight = it.data!!
if (it.verifySuccess() && !it.data.isNullOrEmpty()) {
val dataList = it.data!!
if (dataList.size > 1) {
showToast("存在多个航班记录,请核实")
fid = ""
fdep = ""
fdest.value = ""
sendAddressList.value = emptyList()
sendAddress.value = ""
} else {
val flight = dataList[0]
fid = flight.fid.noNull()
fdep = flight.fdep.noNull()
fdest.value = flight.fdest.noNull()
@@ -88,6 +97,7 @@ class IntImpManifestViewModel : BasePageViewModel() {
}
sendAddressList.value = list
sendAddress.value = flight.fdep.noNull()
}
} else {
fid = ""
fdep = ""

View File

@@ -263,25 +263,14 @@
android:textColor="@color/text_gray"
android:textSize="14sp" />
<LinearLayout
android:id="@+id/ll_images"
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_pic"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginLeft="14dp"
android:layout_weight="1"
android:gravity="center_vertical"
android:minHeight="80dp"
android:orientation="horizontal">
<!-- 交接图片区域预留,后续对接图片数据 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="暂无图片"
android:textColor="@color/text_gray"
android:textSize="12sp" />
</LinearLayout>
android:orientation="horizontal" />
</LinearLayout>

View File

@@ -11,7 +11,6 @@
<ImageView
android:id="@+id/iv_thumbnail"
loadImage="@{bean.path}"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_marginEnd="8dp"

View File

@@ -18,6 +18,7 @@ import com.lukouguoji.module_base.ktx.launchLoadingCollect
import com.lukouguoji.module_base.ktx.showToast
import com.lukouguoji.module_base.ktx.toRequestBody
import com.lukouguoji.module_base.ktx.verifyNullOrEmpty
import com.lukouguoji.module_base.util.MediaUtil
import com.lukouguoji.module_base.util.UploadUtil
import dev.utils.app.info.KeyValue
import kotlinx.coroutines.launch
@@ -71,9 +72,18 @@ class GnjYiKuEditViewModel : BaseViewModel(), IOnItemClickListener {
val bean = it.data ?: GnjYiKuBean()
dataBean.value = bean
// 处理图片列表
val images = bean.getImageList().map { url ->
FileBean(path = url)
// 处理图片列表pic=缩略图(newName)originalPic=原图(zipFileName)
val picList = bean.pic.split(",").filter { it.isNotEmpty() }
val originalPicList = bean.originalPic.split(",").filter { it.isNotEmpty() }
val images = picList.mapIndexed { index, picUrl ->
val fb = FileBean(
path = MediaUtil.fillUrl(picUrl),
url = picUrl
)
if (index < originalPicList.size) {
fb.originalPic = MediaUtil.fillUrl(originalPicList[index])
}
fb
}.toMutableList()
// 如果是编辑模式添加一个空的FileBean用于添加新图片
@@ -108,38 +118,24 @@ class GnjYiKuEditViewModel : BaseViewModel(), IOnItemClickListener {
launchLoadingCollect({
// 1. 上传图片
val uploadedUrls = mutableListOf<String>()
images.forEach { fileBean ->
// 判断是否为已上传的图片在线URL
if (fileBean.path.startsWith("http")) {
uploadedUrls.add(fileBean.path)
if (fileBean.url.isNotEmpty()) {
// 已上传的图片,保持原有的 url 和 originalPic
} else {
// 本地图片需要上传
val result = UploadUtil.upload(fileBean.path)
if (result.verifySuccess()) {
uploadedUrls.add(result.data?.newName ?: "")
}
// 本地图片需要上传
val data = UploadUtil.upload(fileBean.path).data
fileBean.url = data?.newName ?: ""
fileBean.originalPic = data?.zipFileName ?: ""
}
}
// 2. 提交表单数据
val params = mapOf(
"id" to id,
"wbNo" to bean.wbNo,
"pc" to bean.pc,
"weight" to bean.weight,
"spCode" to bean.spCode,
"agentCode" to bean.agentCode,
"goods" to bean.goods,
"flight" to bean.flight,
"route" to bean.route,
"awbType" to bean.awbType,
"telegramNo" to bean.telegramNo,
"remark" to bean.remark,
"images" to uploadedUrls.joinToString(","),
).toRequestBody(removeEmptyOrNull = true)
// 2. 设置图片字段
bean.picNumber = images.size.toString()
bean.pic = images.joinToString(",") { MediaUtil.removeUrl(it.url) }
bean.originalPic = images.joinToString(",") { MediaUtil.removeUrl(it.originalPic) }
NetApply.api.saveGnjYiKu(params)
// 3. 提交表单数据
NetApply.api.saveGnjYiKu(bean.toRequestBody())
}) {
onSuccess = {
showToast(if (pageType.value == DetailsPageType.Add) "新增成功" else "保存成功")

View File

@@ -100,16 +100,14 @@ class GnjYiKuHandoverViewModel : BaseViewModel(), IOnItemClickListener {
launchLoadingCollect({
// 上传图片
val uploadedUrls = mutableListOf<String>()
images.forEach { fileBean ->
if (fileBean.url.isNotEmpty()) {
// 已上传的图片,直接用文件名
uploadedUrls.add(fileBean.url)
// 已上传的图片,保持原有的 url 和 originalPic
} else {
val result = UploadUtil.upload(fileBean.path)
if (result.verifySuccess()) {
uploadedUrls.add(result.data?.newName ?: "")
}
// 本地新图片需要上传
val data = UploadUtil.upload(fileBean.path).data
fileBean.url = data?.newName ?: ""
fileBean.originalPic = data?.zipFileName ?: ""
}
}
@@ -117,7 +115,9 @@ class GnjYiKuHandoverViewModel : BaseViewModel(), IOnItemClickListener {
val params = mapOf(
"mawbId" to mawbId,
"remark" to bean.remark,
"originalPic" to uploadedUrls.joinToString(","),
"picNumber" to images.size.toString(),
"pic" to images.joinToString(",") { MediaUtil.removeUrl(it.url) },
"originalPic" to images.joinToString(",") { MediaUtil.removeUrl(it.originalPic) },
).toRequestBody(removeEmptyOrNull = true)
NetApply.api.modifyGnjMoveStash(params)