@@ -111,6 +111,7 @@ data class CollectionHistoryItem(
|
||||
data class HistoryPage<T>(val items: List<T>, val total: Long, val page: Int, val pageSize: Int)
|
||||
data class HistoryDimension(val key: String, val name: String, val values: List<String>)
|
||||
data class HistoryColorPrice(val color: String, val priceCent: Long)
|
||||
data class HistoryColorImage(val color: String, val imagePath: String, val width: Int, val height: Int)
|
||||
data class HistorySku(val specs: Map<String, String>, val priceCent: Long, val available: Boolean, val complete: Boolean)
|
||||
data class CollectionHistoryDetail(
|
||||
val task: CollectionHistoryItem,
|
||||
@@ -120,6 +121,7 @@ data class CollectionHistoryDetail(
|
||||
val reviewCount: Long?,
|
||||
val dimensions: List<HistoryDimension>,
|
||||
val colorPrices: List<HistoryColorPrice>,
|
||||
val colorImages: List<HistoryColorImage>,
|
||||
val skus: List<HistorySku>,
|
||||
val missing: List<String>,
|
||||
val replacementEligible: Boolean,
|
||||
@@ -455,6 +457,7 @@ class AgentApiClient(private val serverUrl: String) {
|
||||
reviewCount = data.nullableLong("reviewCount"),
|
||||
dimensions = data.getJSONArray("dimensions").objects { item -> HistoryDimension(item.getString("key"), item.getString("name"), item.getJSONArray("values").strings()) },
|
||||
colorPrices = data.getJSONArray("colorPrices").objects { item -> HistoryColorPrice(item.getString("color"), item.getLong("priceCent")) },
|
||||
colorImages = data.historyColorImages(),
|
||||
skus = data.getJSONArray("skus").objects { item ->
|
||||
val specsJson = item.getJSONObject("specs")
|
||||
val specs = specsJson.keys().asSequence().associateWith(specsJson::getString)
|
||||
@@ -633,3 +636,6 @@ private fun JSONObject.nullableString(key: String): String? = if (!has(key) || i
|
||||
private fun JSONObject.nullableLong(key: String): Long? = if (!has(key) || isNull(key)) null else getLong(key)
|
||||
|
||||
internal fun JSONObject.stringArrayOrEmpty(key: String): List<String> = optJSONArray(key)?.strings().orEmpty()
|
||||
internal fun JSONObject.historyColorImages(): List<HistoryColorImage> = optJSONArray("colorImages")?.objects { item ->
|
||||
HistoryColorImage(item.getString("color"), item.getString("imagePath"), item.getInt("width"), item.getInt("height"))
|
||||
}.orEmpty()
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package cn.ilapage.goauto.agent.ui
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.LruCache
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
internal object HistoryImagePolicy {
|
||||
private const val COLOR_IMAGE_PREFIX = "/static/uploadfile/goauto-color/"
|
||||
|
||||
fun resolveUrl(serverUrl: String, imagePath: String): String? {
|
||||
if (!imagePath.startsWith(COLOR_IMAGE_PREFIX) || imagePath.contains("..")) return null
|
||||
return runCatching {
|
||||
val base = URL(serverUrl.trim().trimEnd('/') + "/")
|
||||
if (base.protocol !in setOf("http", "https")) return null
|
||||
URL(base, imagePath).toExternalForm()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun sampleSize(width: Int, height: Int, targetPixels: Int): Int {
|
||||
if (width <= 0 || height <= 0 || targetPixels <= 0) return 1
|
||||
var sample = 1
|
||||
while (width / (sample * 2) >= targetPixels && height / (sample * 2) >= targetPixels) sample *= 2
|
||||
return sample
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class HistoryImageResult {
|
||||
data class Success(val bitmap: Bitmap) : HistoryImageResult()
|
||||
data object Failure : HistoryImageResult()
|
||||
}
|
||||
|
||||
internal fun interface HistoryImageRequest {
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
internal class HistoryImageLoader {
|
||||
private val executor = Executors.newFixedThreadPool(3)
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
private val closed = AtomicBoolean(false)
|
||||
private val cache = object : LruCache<String, Bitmap>((Runtime.getRuntime().maxMemory() / 16).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()) {
|
||||
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
|
||||
}
|
||||
|
||||
fun load(url: String, targetPixels: Int, callback: (HistoryImageResult) -> Unit): HistoryImageRequest {
|
||||
val cancelled = AtomicBoolean(false)
|
||||
val cacheKey = "$url@$targetPixels"
|
||||
cache.get(cacheKey)?.let { bitmap ->
|
||||
main.post { if (!cancelled.get() && !closed.get()) callback(HistoryImageResult.Success(bitmap)) }
|
||||
return HistoryImageRequest { cancelled.set(true) }
|
||||
}
|
||||
executor.execute {
|
||||
val result = runCatching { download(url, targetPixels) }
|
||||
.fold({ bitmap -> HistoryImageResult.Success(bitmap) }, { HistoryImageResult.Failure })
|
||||
if (result is HistoryImageResult.Success) cache.put(cacheKey, result.bitmap)
|
||||
main.post { if (!cancelled.get() && !closed.get()) callback(result) }
|
||||
}
|
||||
return HistoryImageRequest { cancelled.set(true) }
|
||||
}
|
||||
|
||||
fun close() {
|
||||
if (!closed.compareAndSet(false, true)) return
|
||||
executor.shutdownNow()
|
||||
cache.evictAll()
|
||||
}
|
||||
|
||||
private fun download(rawUrl: String, targetPixels: Int): Bitmap {
|
||||
val connection = URL(rawUrl).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.requestMethod = "GET"
|
||||
connection.connectTimeout = 5_000
|
||||
connection.readTimeout = 8_000
|
||||
connection.instanceFollowRedirects = false
|
||||
connection.setRequestProperty("Accept", "image/*")
|
||||
val status = connection.responseCode
|
||||
require(status in 200..299)
|
||||
val contentType = connection.contentType.orEmpty().substringBefore(';').trim().lowercase()
|
||||
require(contentType.startsWith("image/"))
|
||||
require(connection.contentLengthLong <= MAX_IMAGE_BYTES || connection.contentLengthLong < 0)
|
||||
val bytes = connection.inputStream.use { input ->
|
||||
val output = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(16 * 1024)
|
||||
var total = 0
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
require(total <= MAX_IMAGE_BYTES)
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
output.toByteArray()
|
||||
}
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
require(bounds.outWidth > 0 && bounds.outHeight > 0)
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = HistoryImagePolicy.sampleSize(bounds.outWidth, bounds.outHeight, targetPixels)
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
}
|
||||
return requireNotNull(BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options))
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
@@ -15,7 +16,10 @@ import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.GridLayout
|
||||
import android.widget.HorizontalScrollView
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Toast
|
||||
@@ -29,6 +33,7 @@ import cn.ilapage.goauto.agent.network.AgentApiClient
|
||||
import cn.ilapage.goauto.agent.network.CollectionHistoryDetail
|
||||
import cn.ilapage.goauto.agent.network.CollectionHistoryItem
|
||||
import cn.ilapage.goauto.agent.network.HistoryPage
|
||||
import cn.ilapage.goauto.agent.network.HistoryColorImage
|
||||
import cn.ilapage.goauto.agent.network.PurchaseHistoryDetail
|
||||
import cn.ilapage.goauto.agent.network.PurchaseHistoryItem
|
||||
import cn.ilapage.goauto.agent.network.PurchaseResetResult
|
||||
@@ -87,15 +92,18 @@ internal object PurchaseRetryPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class ReplacementPresentation { ACTION, ACTIVATION_FAILED, MATCHING, MATCHED, MANUAL_REQUIRED, HIDDEN }
|
||||
internal enum class ReplacementPresentation { ACTION, ACTIVATION_FAILED, MATCHING, MATCHED, MANUAL_REQUIRED, PURCHASE_IN_PROGRESS, HIDDEN }
|
||||
|
||||
internal object ReplacementActionPolicy {
|
||||
fun presentation(eligible: Boolean, mappingStatus: String?, activationStatus: String?): ReplacementPresentation = when {
|
||||
const val PURCHASE_IN_PROGRESS_REASON = "该商品有采购任务正在执行或结果待核对,暂不能替换,请稍后重试"
|
||||
|
||||
fun presentation(eligible: Boolean, mappingStatus: String?, activationStatus: String?, disabledReason: String? = null): ReplacementPresentation = when {
|
||||
activationStatus == "failed" -> ReplacementPresentation.ACTIVATION_FAILED
|
||||
mappingStatus == "matching" -> ReplacementPresentation.MATCHING
|
||||
mappingStatus == "matched" -> ReplacementPresentation.MATCHED
|
||||
mappingStatus == "manual_required" -> ReplacementPresentation.MANUAL_REQUIRED
|
||||
eligible -> ReplacementPresentation.ACTION
|
||||
disabledReason == PURCHASE_IN_PROGRESS_REASON -> ReplacementPresentation.PURCHASE_IN_PROGRESS
|
||||
else -> ReplacementPresentation.HIDDEN
|
||||
}
|
||||
}
|
||||
@@ -130,6 +138,8 @@ class TaskHistoryFragment : Fragment() {
|
||||
private var loading = false
|
||||
private var pageSignature: String? = null
|
||||
private var detailState = TaskDetailState()
|
||||
private val imageLoader = HistoryImageLoader()
|
||||
private val imageRequests = mutableListOf<HistoryImageRequest>()
|
||||
private var currentPageReceiverRegistered = false
|
||||
private val currentPageReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
@@ -198,11 +208,13 @@ class TaskHistoryFragment : Fragment() {
|
||||
|
||||
override fun onDestroyView() {
|
||||
requestGeneration++
|
||||
cancelImageRequests()
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unregisterCurrentPageReceiver()
|
||||
imageLoader.close()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -503,6 +515,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
|
||||
private fun renderDetail(detail: Any) {
|
||||
if (this::swipeRefresh.isInitialized) swipeRefresh.isEnabled = false
|
||||
cancelImageRequests()
|
||||
resultColumn.removeAllViews()
|
||||
resultColumn.addView(MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
|
||||
text = if (collection) "返回采集记录" else "返回采购记录"
|
||||
@@ -527,12 +540,14 @@ class TaskHistoryFragment : Fragment() {
|
||||
append("评价数量:${detail.reviewCount?.toString() ?: "未采集到"}\n\n")
|
||||
append("$prices\n\n$dimensions\n\nSKU:${detail.skus.size} 条")
|
||||
}
|
||||
resultColumn.addView(context.card(context.cardColumn().apply {
|
||||
val detailCard = context.cardColumn().apply {
|
||||
addView(context.label("#${task.taskId} · 第 ${task.attemptNumber} 次 · ${task.goodsId.ifBlank { "未识别商品" }}", 20f, context.getColor(R.color.agent_text), true))
|
||||
if (task.source == "agent_current_page") addView(context.label("来源:Agent 当前页面", 13f, context.getColor(R.color.agent_text_muted)))
|
||||
addView(context.label(collectionStatus(task.status), 14f, statusColor(task.status)).apply { setPadding(0, context.dp(4), 0, context.dp(12)) })
|
||||
addView(context.label(info, 14f))
|
||||
}), collectionCardParams())
|
||||
addView(colorImageSection(detail.colorImages, AgentSettingsStore(context).serverUrl()))
|
||||
}
|
||||
resultColumn.addView(context.card(detailCard), collectionCardParams())
|
||||
if (detail.missing.isNotEmpty()) resultColumn.addView(context.centeredMessage("缺失项", detail.missing.joinToString("、")))
|
||||
if (task.errorMessage != null) resultColumn.addView(context.centeredMessage(task.errorMessage, "错误代码:${task.errorCode ?: "—"}"))
|
||||
if (detail.attempts.isNotEmpty()) {
|
||||
@@ -555,6 +570,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
mappingStatus = detail.replacementMappingStatus,
|
||||
activationStatus = detail.replacementActivationStatus,
|
||||
activationErrorMessage = detail.replacementActivationErrorMessage,
|
||||
disabledReason = detail.replacementDisabledReason,
|
||||
originType = "collection",
|
||||
taskId = task.taskId,
|
||||
taskNo = "#${task.taskId}",
|
||||
@@ -744,6 +760,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
mappingStatus = detail.replacementMappingStatus,
|
||||
activationStatus = detail.replacementActivationStatus,
|
||||
activationErrorMessage = detail.replacementActivationErrorMessage,
|
||||
disabledReason = detail.replacementDisabledReason,
|
||||
originType = "purchase",
|
||||
taskId = task.taskId,
|
||||
taskNo = "CG-${task.taskId}",
|
||||
@@ -758,6 +775,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
mappingStatus: String?,
|
||||
activationStatus: String?,
|
||||
activationErrorMessage: String?,
|
||||
disabledReason: String?,
|
||||
originType: String,
|
||||
taskId: Long,
|
||||
taskNo: String,
|
||||
@@ -775,7 +793,7 @@ class TaskHistoryFragment : Fragment() {
|
||||
}, collectionCardParams())
|
||||
return
|
||||
}
|
||||
when (ReplacementActionPolicy.presentation(eligible, mappingStatus, activationStatus)) {
|
||||
when (ReplacementActionPolicy.presentation(eligible, mappingStatus, activationStatus, disabledReason)) {
|
||||
ReplacementPresentation.ACTIVATION_FAILED -> resultColumn.addView(
|
||||
context.centeredMessage(
|
||||
"采集成功,但替换生效失败",
|
||||
@@ -796,26 +814,34 @@ class TaskHistoryFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
ReplacementPresentation.MANUAL_REQUIRED -> resultColumn.addView(context.centeredMessage("需要人工处理", "需在 Admin 人工匹配规格。"))
|
||||
ReplacementPresentation.PURCHASE_IN_PROGRESS -> resultColumn.addView(
|
||||
context.centeredMessage("暂不能替换", "该商品下有采购任务正在执行或订单结果待核对,任务结束后可再发起替换。"),
|
||||
)
|
||||
ReplacementPresentation.ACTION -> resultColumn.addView(MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
|
||||
text = "采集替代商品"
|
||||
minimumHeight = context.dp(48)
|
||||
contentDescription = "为任务 $taskNo 采集替代商品"
|
||||
setOnClickListener { confirmReplacement(originType, taskId, taskNo) }
|
||||
setOnClickListener { confirmReplacement(originType, taskId) }
|
||||
}, collectionCardParams())
|
||||
ReplacementPresentation.HIDDEN -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmReplacement(originType: String, taskId: Long, taskNo: String) {
|
||||
private fun confirmReplacement(originType: String, taskId: Long) {
|
||||
currentPageCollectionProblem()?.let { problem ->
|
||||
showCurrentPageBlocked(problem)
|
||||
return
|
||||
}
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("采集替代商品")
|
||||
.setMessage("用当前商品替换 $taskNo 的失效商品?")
|
||||
.setMessage(
|
||||
"用当前商品替换关联的拼多多商品?将会:\n" +
|
||||
"· 把所有关联该商品的虾皮商品一并改指向新商品,原有规格映射清空,需重新匹配\n" +
|
||||
"· 取消该商品下尚未开始执行的采购任务\n" +
|
||||
"原商品会被停用,此操作暂不能自动撤销。",
|
||||
)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("替换") { _, _ -> launchCurrentPageCollection(originType, taskId) }
|
||||
.setPositiveButton("确认替换") { _, _ -> launchCurrentPageCollection(originType, taskId) }
|
||||
.show()
|
||||
}
|
||||
|
||||
@@ -964,6 +990,159 @@ class TaskHistoryFragment : Fragment() {
|
||||
else -> R.color.agent_primary_light
|
||||
})
|
||||
|
||||
private fun colorImageSection(images: List<HistoryColorImage>, serverUrl: String): View {
|
||||
val context = requireContext()
|
||||
return LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(0, context.dp(20), 0, 0)
|
||||
addView(context.label("颜色图片", 16f, context.getColor(R.color.agent_text), true))
|
||||
if (images.isEmpty()) {
|
||||
addView(context.label("暂无颜色图片", 14f, context.getColor(R.color.agent_text_muted)).apply {
|
||||
setPadding(0, context.dp(8), 0, 0)
|
||||
})
|
||||
addView(context.label("同色图片被后续采集覆盖后,也不会在本任务中显示。", 12f, context.getColor(R.color.agent_text_muted)).apply {
|
||||
setPadding(0, context.dp(4), 0, 0)
|
||||
})
|
||||
return@apply
|
||||
}
|
||||
addView(GridLayout(context).apply {
|
||||
columnCount = 3
|
||||
alignmentMode = GridLayout.ALIGN_BOUNDS
|
||||
useDefaultMargins = false
|
||||
setPadding(0, context.dp(8), 0, 0)
|
||||
images.forEach { image ->
|
||||
addView(colorImageTile(image, serverUrl), GridLayout.LayoutParams().apply {
|
||||
width = 0
|
||||
height = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
columnSpec = GridLayout.spec(GridLayout.UNDEFINED, 1f)
|
||||
setMargins(context.dp(4), context.dp(4), context.dp(4), context.dp(8))
|
||||
})
|
||||
}
|
||||
}, fullWidth())
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorImageTile(item: HistoryColorImage, serverUrl: String): View {
|
||||
val context = requireContext()
|
||||
val preview = FrameLayout(context).apply {
|
||||
val shape = GradientDrawable().apply {
|
||||
setColor(context.getColor(R.color.agent_surface_high))
|
||||
cornerRadius = context.dp(8).toFloat()
|
||||
}
|
||||
background = shape
|
||||
clipToOutline = true
|
||||
minimumWidth = context.dp(88)
|
||||
minimumHeight = context.dp(88)
|
||||
isFocusable = true
|
||||
isClickable = false
|
||||
contentDescription = "${item.color}图片正在加载"
|
||||
val attributes = context.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||
foreground = attributes.getDrawable(0)
|
||||
attributes.recycle()
|
||||
}
|
||||
val bitmapView = ImageView(context).apply {
|
||||
visibility = View.INVISIBLE
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
||||
}
|
||||
val progress = ProgressBar(context)
|
||||
val failure = context.label("图片加载\n失败", 12f, context.getColor(R.color.agent_text_muted), true).apply {
|
||||
gravity = Gravity.CENTER
|
||||
visibility = View.GONE
|
||||
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
|
||||
}
|
||||
preview.addView(bitmapView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
preview.addView(progress, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
|
||||
preview.addView(failure, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.CENTER))
|
||||
val resolved = HistoryImagePolicy.resolveUrl(serverUrl, item.imagePath)
|
||||
if (resolved == null) {
|
||||
progress.visibility = View.GONE
|
||||
failure.visibility = View.VISIBLE
|
||||
preview.contentDescription = "${item.color}图片加载失败"
|
||||
} else {
|
||||
imageRequests += imageLoader.load(resolved, context.dp(176)) { result ->
|
||||
if (!isAdded) return@load
|
||||
progress.visibility = View.GONE
|
||||
when (result) {
|
||||
is HistoryImageResult.Success -> {
|
||||
bitmapView.setImageBitmap(result.bitmap)
|
||||
bitmapView.visibility = View.VISIBLE
|
||||
failure.visibility = View.GONE
|
||||
preview.isClickable = true
|
||||
preview.contentDescription = "${item.color},查看大图"
|
||||
preview.setOnClickListener { showHistoryImageDialog(item, resolved) }
|
||||
}
|
||||
HistoryImageResult.Failure -> {
|
||||
bitmapView.visibility = View.INVISIBLE
|
||||
failure.visibility = View.VISIBLE
|
||||
preview.contentDescription = "${item.color}图片加载失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
addView(preview, LinearLayout.LayoutParams(context.dp(88), context.dp(88)))
|
||||
addView(context.label(item.color, 13f, context.getColor(R.color.agent_text)).apply {
|
||||
gravity = Gravity.CENTER
|
||||
maxLines = 2
|
||||
setPadding(0, context.dp(6), 0, 0)
|
||||
}, fullWidth())
|
||||
}
|
||||
}
|
||||
|
||||
private fun showHistoryImageDialog(item: HistoryColorImage, resolvedUrl: String) {
|
||||
val context = requireContext()
|
||||
val container = FrameLayout(context).apply {
|
||||
setBackgroundColor(context.getColor(R.color.agent_surface_high))
|
||||
setPadding(context.dp(8), context.dp(8), context.dp(8), context.dp(8))
|
||||
}
|
||||
val image = ImageView(context).apply {
|
||||
scaleType = ImageView.ScaleType.FIT_CENTER
|
||||
visibility = View.INVISIBLE
|
||||
contentDescription = "${item.color}颜色图片"
|
||||
}
|
||||
val progress = ProgressBar(context)
|
||||
val failure = context.label("图片加载失败,请关闭后重试。", 14f, context.getColor(R.color.agent_text_muted), true).apply {
|
||||
gravity = Gravity.CENTER
|
||||
visibility = View.GONE
|
||||
}
|
||||
container.addView(image, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
container.addView(progress, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
|
||||
container.addView(failure, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.CENTER))
|
||||
val target = (context.resources.displayMetrics.widthPixels - context.dp(48)).coerceAtMost(context.dp(720))
|
||||
val request = imageLoader.load(resolvedUrl, target) { result ->
|
||||
progress.visibility = View.GONE
|
||||
when (result) {
|
||||
is HistoryImageResult.Success -> {
|
||||
image.setImageBitmap(result.bitmap)
|
||||
image.visibility = View.VISIBLE
|
||||
}
|
||||
HistoryImageResult.Failure -> failure.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
imageRequests += request
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(item.color)
|
||||
.setView(container)
|
||||
.setNegativeButton("关闭", null)
|
||||
.create()
|
||||
.apply {
|
||||
setCanceledOnTouchOutside(true)
|
||||
setOnDismissListener { request.cancel() }
|
||||
show()
|
||||
container.layoutParams = container.layoutParams?.apply {
|
||||
height = (context.resources.displayMetrics.heightPixels * 0.62f).toInt().coerceAtMost(context.dp(560))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelImageRequests() {
|
||||
imageRequests.forEach(HistoryImageRequest::cancel)
|
||||
imageRequests.clear()
|
||||
}
|
||||
|
||||
private fun money(cents: Long?, currency: String): String {
|
||||
if (cents == null) return "未记录"
|
||||
val symbol = if (currency.equals("CNY", true)) "¥" else "$currency "
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.network.stringArrayOrEmpty
|
||||
import cn.ilapage.goauto.agent.network.historyColorImages
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -19,4 +20,19 @@ class AgentApiClientJsonTest {
|
||||
assertEquals(emptyList<String>(), JSONObject().put("missing", JSONObject.NULL).stringArrayOrEmpty("missing"))
|
||||
assertEquals(emptyList<String>(), JSONObject().stringArrayOrEmpty("missing"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `history color images parse fields and preserve empty compatibility`() {
|
||||
val data = JSONObject().put("colorImages", JSONArray().put(JSONObject()
|
||||
.put("color", "红色")
|
||||
.put("imagePath", "/static/uploadfile/goauto-color/red.jpg")
|
||||
.put("width", 300)
|
||||
.put("height", 280)))
|
||||
|
||||
val images = data.historyColorImages()
|
||||
assertEquals(1, images.size)
|
||||
assertEquals("红色", images.single().color)
|
||||
assertEquals(300, images.single().width)
|
||||
assertEquals(emptyList<Any>(), JSONObject().historyColorImages())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.ui.HistoryImagePolicy
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class HistoryImagePolicyTest {
|
||||
@Test
|
||||
fun `resolves only configured color image paths`() {
|
||||
assertEquals(
|
||||
"http://192.0.2.1:8010/static/uploadfile/goauto-color/red.jpg",
|
||||
HistoryImagePolicy.resolveUrl("http://192.0.2.1:8010", "/static/uploadfile/goauto-color/red.jpg"),
|
||||
)
|
||||
assertNull(HistoryImagePolicy.resolveUrl("http://192.0.2.1:8010", "https://example.com/red.jpg"))
|
||||
assertNull(HistoryImagePolicy.resolveUrl("http://192.0.2.1:8010", "/static/uploadfile/goauto-color/../secret"))
|
||||
assertNull(HistoryImagePolicy.resolveUrl("file:///tmp", "/static/uploadfile/goauto-color/red.jpg"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sampling keeps a power of two near the target`() {
|
||||
assertEquals(4, HistoryImagePolicy.sampleSize(1600, 1200, 256))
|
||||
assertEquals(1, HistoryImagePolicy.sampleSize(300, 280, 256))
|
||||
assertEquals(1, HistoryImagePolicy.sampleSize(0, 0, 256))
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ class ReplacementActionPolicyTest {
|
||||
fun serverEligibilityIsTheOnlyWayToShowTheAction() {
|
||||
assertEquals(ReplacementPresentation.ACTION, ReplacementActionPolicy.presentation(true, null, null))
|
||||
assertEquals(ReplacementPresentation.HIDDEN, ReplacementActionPolicy.presentation(false, null, null))
|
||||
assertEquals(
|
||||
ReplacementPresentation.PURCHASE_IN_PROGRESS,
|
||||
ReplacementActionPolicy.presentation(false, null, null, ReplacementActionPolicy.PURCHASE_IN_PROGRESS_REASON),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user