feat: 支付成功后直接进入打印页
This commit is contained in:
@@ -44,7 +44,6 @@ class LocalAudioPlayService @Inject constructor(
|
||||
AppRoutes.FACE_RECOGNITION_RESULT to R.raw.face_recognition_success_result_page,
|
||||
AppRoutes.UPLOAD_PHOTO to R.raw.scan_qrcode_upload_page,
|
||||
AppRoutes.PHOTO_SELECT to R.raw.upload_unpaid_result_page,
|
||||
AppRoutes.PAY_SUCCESS to R.raw.pay_success_select_photo_print_page,
|
||||
AppRoutes.PRINTING to R.raw.printing_page,
|
||||
AppRoutes.PRINT_SUCCESS to R.raw.print_complete_page,
|
||||
// 特殊状态页面
|
||||
|
||||
@@ -17,7 +17,6 @@ import com.yzx.kiosk.ui.printer.view.PrinterManageScreen
|
||||
import com.yzx.kiosk.ui.setting.view.SettingScreen
|
||||
import com.yzx.kiosk.ui.face.view.FaceRecognitionScreen
|
||||
import com.yzx.kiosk.ui.face.view.FaceRecognitionResultScreen
|
||||
import com.yzx.kiosk.ui.upload.view.PaySuccessScreen
|
||||
import com.yzx.kiosk.ui.upload.view.PhotoSelectScreen
|
||||
import com.yzx.kiosk.ui.upload.view.PrintingScreen
|
||||
import com.yzx.kiosk.ui.upload.view.UploadPhotoScreen
|
||||
@@ -157,48 +156,6 @@ fun AppNavHost(
|
||||
FaceRecognitionResultScreen(results = results)
|
||||
}
|
||||
|
||||
// 支付成功页面
|
||||
composable(route = "${AppRoutes.PAY_SUCCESS}?urls={urls}&orderNumber={orderNumber}&captureType={captureType}") { backStackEntry ->
|
||||
val urlsParam = backStackEntry.arguments?.getString("urls") ?: ""
|
||||
val orderNumberParam = backStackEntry.arguments?.getString("orderNumber") ?: ""
|
||||
val captureTypeParam = backStackEntry.arguments?.getString("captureType") ?: ""
|
||||
|
||||
val photoUrls = if (urlsParam.isNotEmpty()) {
|
||||
try {
|
||||
val decoded = URLDecoder.decode(urlsParam, "UTF-8")
|
||||
// 如果是 JSON 格式(以 [ 开头),作为单个元素传递
|
||||
if (decoded.startsWith("[")) {
|
||||
listOf(decoded)
|
||||
} else {
|
||||
// 旧格式:逗号分隔的 URL 列表
|
||||
decoded.split(",")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
val orderNumber = if (orderNumberParam.isNotEmpty()) {
|
||||
try {
|
||||
URLDecoder.decode(orderNumberParam, "UTF-8")
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val captureType = captureTypeParam.toIntOrNull()
|
||||
|
||||
PaySuccessScreen(
|
||||
photoUrls = photoUrls,
|
||||
orderNumber = orderNumber,
|
||||
captureType = captureType
|
||||
)
|
||||
}
|
||||
|
||||
// 打印中页面
|
||||
composable(route = "${AppRoutes.PRINTING}?urls={urls}&orderNumber={orderNumber}&captureType={captureType}") { backStackEntry ->
|
||||
val urlsParam = backStackEntry.arguments?.getString("urls") ?: ""
|
||||
|
||||
@@ -7,11 +7,24 @@ object AppRoutes {
|
||||
const val DEVICE_CONFIG = "device_config"
|
||||
const val UPLOAD_PHOTO = "upload_photo"
|
||||
const val PHOTO_SELECT = "photo_select"
|
||||
const val PAY_SUCCESS = "pay_success"
|
||||
const val PRINTING = "printing"
|
||||
const val PRINT_SUCCESS = "print_success"
|
||||
const val FACE_RECOGNITION = "face_recognition"
|
||||
const val FACE_RECOGNITION_RESULT = "face_recognition_result"
|
||||
|
||||
const val AGREEMENT = "agreement"
|
||||
|
||||
fun buildPrintingRoute(
|
||||
photoData: String,
|
||||
orderNumber: String?,
|
||||
captureType: Int?
|
||||
): String {
|
||||
val urlsParam = java.net.URLEncoder.encode(photoData, "UTF-8")
|
||||
val orderNumberParam = orderNumber
|
||||
?.let { java.net.URLEncoder.encode(it, "UTF-8") }
|
||||
.orEmpty()
|
||||
val captureTypeParam = captureType?.toString().orEmpty()
|
||||
|
||||
return "$PRINTING?urls=$urlsParam&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
|
||||
}
|
||||
}
|
||||
|
||||
+15
-11
@@ -4,6 +4,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.yzx.kiosk.base.BaseViewModel
|
||||
import com.yzx.kiosk.datastore.AppState
|
||||
import com.yzx.kiosk.navigation.AppNavigator
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import com.yzx.kiosk.network.model.request.GetPayUrlRequest
|
||||
import com.yzx.kiosk.network.model.request.PaySuccessMessageRequest
|
||||
import com.yzx.kiosk.network.model.request.VerifyResultRequest
|
||||
@@ -480,17 +481,26 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
stopPayStatusPolling()
|
||||
_payQrCodeUrl.value = null
|
||||
_dismissPayDialogEvents.tryEmit(Unit)
|
||||
navigateToPaySuccess(orderNumber, captureType, imageIds)
|
||||
navigateToPrinting(orderNumber, captureType, imageIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到支付成功页面
|
||||
* 支付成功后直接跳转到打印页面
|
||||
* @param orderNumber 订单号
|
||||
* @param captureType 抓拍类型
|
||||
* @param imageIds 图片ID数组(从支付成功消息中获取)
|
||||
*/
|
||||
private fun navigateToPaySuccess(orderNumber: String?, captureType: Int?, imageIds: List<Int>) {
|
||||
private fun navigateToPrinting(orderNumber: String?, captureType: Int?, imageIds: List<Int>) {
|
||||
viewModelScope.launch {
|
||||
if (orderNumber.isNullOrBlank() || captureType == null) {
|
||||
LogUtils.e(
|
||||
TAG,
|
||||
"支付成功消息订单信息不完整 - orderNumber: $orderNumber, captureType: $captureType"
|
||||
)
|
||||
ToastUtils.show("支付成功,但订单信息不完整")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 根据 imageIds 从 originalResults 中获取对应的 FaceSearchResult
|
||||
val selectedResults = originalResults.filter { imageIds.contains(it.id) }
|
||||
|
||||
@@ -514,19 +524,13 @@ class FaceRecognitionResultViewModel @Inject constructor(
|
||||
// 将 FileMapData 数组序列化为 JSON 数组字符串
|
||||
val gson = Gson()
|
||||
val jsonArray = gson.toJson(fileMapDataList)
|
||||
val encodedJson = java.net.URLEncoder.encode(jsonArray, "UTF-8")
|
||||
|
||||
// 构建路由参数
|
||||
val orderNumberParam = orderNumber?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: ""
|
||||
val captureTypeParam = captureType?.toString() ?: ""
|
||||
|
||||
// 创建 NavOptions,清除当前页面
|
||||
val navOptions = androidx.navigation.NavOptions.Builder()
|
||||
.setPopUpTo(com.yzx.kiosk.navigation.routes.AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true)
|
||||
.setPopUpTo(AppRoutes.FACE_RECOGNITION_RESULT, inclusive = true)
|
||||
.build()
|
||||
|
||||
// 跳转到支付成功页面
|
||||
val route = "${com.yzx.kiosk.navigation.routes.AppRoutes.PAY_SUCCESS}?urls=$encodedJson&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
|
||||
val route = AppRoutes.buildPrintingRoute(jsonArray, orderNumber, captureType)
|
||||
toPage(route, navOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
package com.yzx.kiosk.ui.upload.view
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
|
||||
import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.animation.core.CubicBezierEasing
|
||||
import androidx.compose.animation.core.Easing
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import com.yzx.kiosk.R
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.yzx.kiosk.component.appbar.AppTitleBarNoStatusBarPadding
|
||||
import com.yzx.kiosk.component.appbar.FaceBarNoStatusBarPadding
|
||||
import com.yzx.kiosk.component.scaffold.AppScaffold
|
||||
import com.yzx.kiosk.ui.common.view.FullScreenMode
|
||||
import com.yzx.kiosk.ui.upload.viewmodel.PaySuccessViewModel
|
||||
|
||||
/** 与心跳动画 target一致;槽位高度 = 72.dp * 该值,避免放大时盖住下方区域 */
|
||||
private const val PRINT_BTN_HEARTBEAT_MAX_SCALE = 1.09f
|
||||
|
||||
private fun isValidRemoteImageUrl(url: String): Boolean {
|
||||
val t = url.trim()
|
||||
if (t.isEmpty()) return false
|
||||
return t.startsWith("http://", ignoreCase = true) ||
|
||||
t.startsWith("https://", ignoreCase = true)
|
||||
}
|
||||
|
||||
/** 按钮左右缩进,宽度变短;同等缩放比例下横向多出的像素更少,不易贴边/溢出 */
|
||||
private val PRINT_BTN_HORIZONTAL_INSET = 20.dp
|
||||
|
||||
/**
|
||||
* 心跳 `tween` 的缓动曲线(两头「略缓」强弱可在这里换):
|
||||
* - [FastOutSlowInEasing]:Material 默认,起止略慢、中间略快
|
||||
* - LinearEasing:匀速,无额外缓急(需自行 `import …LinearEasing` 并赋值)
|
||||
* - [CubicBezierEasing]:自定义贝塞尔;(x1,y1) 靠近起点、(x2,y2) 靠近终点,数值越大往往起止越「肉」、需边调边看
|
||||
*/
|
||||
private val PRINT_BTN_HEARTBEAT_EASING: Easing = LinearEasing
|
||||
// private val PRINT_BTN_HEARTBEAT_EASING = CubicBezierEasing(0.45f, 0f, 0.55f, 1f) // 示例:两头再柔和一点
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PaySuccessScreen(
|
||||
photoUrls: List<String>,
|
||||
orderNumber: String = "",
|
||||
captureType: Int? = null,
|
||||
viewModel: PaySuccessViewModel = hiltViewModel()
|
||||
) {
|
||||
val photoList by viewModel.photoList.collectAsState()
|
||||
val selectedPhotoIds by viewModel.selectedPhotoIds.collectAsState()
|
||||
val buttonPrintUrl by viewModel.buttonPrintUrl.collectAsState()
|
||||
val printBtnRemoteUrl = buttonPrintUrl.trim()
|
||||
val useRemotePrintButton = isValidRemoteImageUrl(printBtnRemoteUrl)
|
||||
|
||||
// 初始化照片列表和订单信息
|
||||
LaunchedEffect(photoUrls, orderNumber, captureType) {
|
||||
viewModel.initPhotoList(photoUrls, orderNumber, captureType)
|
||||
}
|
||||
|
||||
val selectedCount = selectedPhotoIds.size
|
||||
val isAllSelected = photoList.isNotEmpty() && selectedPhotoIds.size == photoList.size
|
||||
|
||||
FullScreenMode()
|
||||
|
||||
AppScaffold(
|
||||
topBar = {
|
||||
Column {
|
||||
AppTitleBarNoStatusBarPadding(
|
||||
backgroundColor = Color(0xFF4CAF50), // 绿色
|
||||
title = "支付成功",
|
||||
isWhite = true,
|
||||
isShowBackIcon = true,
|
||||
onBackClick = { viewModel.navigateBack() },
|
||||
isShowButtonLine = false
|
||||
)
|
||||
FaceBarNoStatusBarPadding(
|
||||
title = "为保护您的隐私与权益,系统不保存人脸照片。素材将在 24 小时后自动清理"
|
||||
)
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.White)
|
||||
) {
|
||||
// 打印选中照片按钮:心跳缩放走 graphicsLayer;外层槽高为 72dp×最大缩放,预留上下空间,不压盖下方
|
||||
val heartbeatTransition = rememberInfiniteTransition(label = "printBtnHeartbeat")
|
||||
val pulseScale by heartbeatTransition.animateFloat(
|
||||
initialValue = 1f,
|
||||
targetValue = PRINT_BTN_HEARTBEAT_MAX_SCALE,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(600, easing = PRINT_BTN_HEARTBEAT_EASING),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "pulseScale"
|
||||
)
|
||||
val drawScale = if (selectedCount > 0) pulseScale else 1f
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp)
|
||||
.height(72.dp * PRINT_BTN_HEARTBEAT_MAX_SCALE),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Button(
|
||||
onClick = { viewModel.onPrintClick() },
|
||||
modifier = Modifier
|
||||
.padding(horizontal = PRINT_BTN_HORIZONTAL_INSET)
|
||||
.fillMaxWidth()
|
||||
.height(72.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = drawScale
|
||||
scaleY = drawScale
|
||||
transformOrigin = TransformOrigin.Center
|
||||
},
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0073FF)),
|
||||
enabled = selectedCount > 0
|
||||
) {
|
||||
// Row(
|
||||
// modifier = Modifier.fillMaxWidth(),
|
||||
// horizontalArrangement = Arrangement.Center,
|
||||
// verticalAlignment = Alignment.CenterVertically
|
||||
// ) {
|
||||
// Text(
|
||||
// text = "选中照片点击打印",
|
||||
// fontSize = 24.sp,
|
||||
// color = Color.White,
|
||||
// fontWeight = FontWeight.Bold
|
||||
// )
|
||||
// Spacer(modifier = Modifier.width(6.dp))
|
||||
if (useRemotePrintButton) {
|
||||
AsyncImage(
|
||||
model = printBtnRemoteUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit,
|
||||
placeholder = painterResource(id = R.mipmap.btn_pay_success),
|
||||
error = painterResource(id = R.mipmap.btn_pay_success)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(id = R.mipmap.btn_pay_success),
|
||||
contentDescription = null,
|
||||
tint = Color.Unspecified,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// 全选行
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.clickable { viewModel.toggleSelectAll() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 复选框
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(if (isAllSelected) Color(0xFF2196F3) else Color.White)
|
||||
.border(
|
||||
width = 2.dp,
|
||||
color = if (isAllSelected) Color(0xFF2196F3) else Color(0xFFCCCCCC),
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isAllSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(18.dp))
|
||||
|
||||
Text(
|
||||
text = "全选",
|
||||
fontSize = 24.sp,
|
||||
color = Color.Black
|
||||
)
|
||||
Text(
|
||||
text = "(共${photoList.size}张)",
|
||||
fontSize = 24.sp,
|
||||
color = Color.Black
|
||||
)
|
||||
}
|
||||
|
||||
// 已选择数量
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "已选择",
|
||||
fontSize = 24.sp,
|
||||
color = Color.Black
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
text = selectedCount.toString(),
|
||||
fontSize = 28.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Color(0xFF2196F3)
|
||||
)
|
||||
Text(
|
||||
text = "张",
|
||||
fontSize = 24.sp,
|
||||
color = Color.Black
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(18.dp))
|
||||
|
||||
// 图片瀑布流列表
|
||||
LazyVerticalStaggeredGrid(
|
||||
columns = StaggeredGridCells.Fixed(2),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
verticalItemSpacing = 18.dp
|
||||
) {
|
||||
itemsIndexed(photoList) { index, photo ->
|
||||
val isSelected = selectedPhotoIds.contains(photo.id)
|
||||
PaySuccessPhotoItem(
|
||||
photoUrl = photo.url,
|
||||
aspectRatio = photo.aspectRatio,
|
||||
isSelected = isSelected,
|
||||
onToggleSelection = { viewModel.togglePhotoSelection(photo.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PaySuccessPhotoItem(
|
||||
photoUrl: String,
|
||||
aspectRatio: Float,
|
||||
isSelected: Boolean,
|
||||
onToggleSelection: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(aspectRatio) // 根据原比例计算高度
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.clickable { onToggleSelection() }
|
||||
) {
|
||||
// 图片 - 按原比例显示
|
||||
AsyncImage(
|
||||
model = photoUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
|
||||
// 选中遮罩
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
if (isSelected) Color.Black.copy(alpha = 0.3f) else Color.Transparent
|
||||
)
|
||||
)
|
||||
|
||||
// 右上角复选框
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(18.dp)
|
||||
.size(24.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(if (isSelected) Color(0xFF2196F3) else Color.Black.copy(alpha = 0.5f))
|
||||
.border(
|
||||
width = 2.dp,
|
||||
color = if (isSelected) Color(0xFF2196F3) else Color(0xFF666666),
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,16 +275,10 @@ fun PhotoSelectScreen(
|
||||
|
||||
// 支付弹框
|
||||
if (showPayDialog && selectedCount > 0 && payQrCodeUrl != null) {
|
||||
val selectedUrls = photoList.filter { selectedPhotoIds.contains(it.id) }.map { it.url }
|
||||
WechatPayDialog(
|
||||
qrCodeUrl = payQrCodeUrl!!,
|
||||
totalPrice = if (totalPrice != "--") totalPrice else "0",
|
||||
onDismiss = { showPayDialog = false },
|
||||
onPaySuccess = {
|
||||
showPayDialog = false
|
||||
// 支付成功,等待 WebSocket 消息(code=5)触发跳转到支付成功页面
|
||||
// 跳转逻辑在 PhotoSelectViewModel 的 navigateToPaySuccess 方法中处理
|
||||
}
|
||||
onDismiss = { showPayDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -453,8 +447,7 @@ fun PhotoItem(
|
||||
fun WechatPayDialog(
|
||||
qrCodeUrl: String,
|
||||
totalPrice: String,
|
||||
onDismiss: () -> Unit,
|
||||
onPaySuccess: () -> Unit
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
// 使用接口返回的URL生成支付二维码
|
||||
val qrCodeBitmap = remember(qrCodeUrl) {
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
package com.yzx.kiosk.ui.upload.viewmodel
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavOptions
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.size.Size
|
||||
import com.yzx.kiosk.App
|
||||
import com.yzx.kiosk.base.BaseViewModel
|
||||
import com.yzx.kiosk.datastore.AppState
|
||||
import com.yzx.kiosk.navigation.AppNavigator
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.yzx.kiosk.datastore.AppStoreDataSource
|
||||
import com.yzx.kiosk.utils.LogUtils
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class PaySuccessViewModel @Inject constructor(
|
||||
navigator: AppNavigator,
|
||||
private val appStoreDataSource: AppStoreDataSource,
|
||||
appState: AppState
|
||||
) : BaseViewModel(
|
||||
navigator = navigator,
|
||||
appState = appState
|
||||
) {
|
||||
// 图片列表(包含宽高比)
|
||||
private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList())
|
||||
val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow()
|
||||
|
||||
// 已选中的图片ID
|
||||
private val _selectedPhotoIds = MutableStateFlow<Set<Int>>(emptySet())
|
||||
val selectedPhotoIds: StateFlow<Set<Int>> = _selectedPhotoIds.asStateFlow()
|
||||
|
||||
// 订单号
|
||||
private val _orderNumber = MutableStateFlow<String>("")
|
||||
val orderNumber: StateFlow<String> = _orderNumber.asStateFlow()
|
||||
|
||||
// 抓拍类型
|
||||
private val _captureType = MutableStateFlow<Int?>(null)
|
||||
val captureType: StateFlow<Int?> = _captureType.asStateFlow()
|
||||
|
||||
/** 设备配置下发的打印按钮图 URL(可能为空) */
|
||||
private val _buttonPrintUrl = MutableStateFlow("")
|
||||
val buttonPrintUrl: StateFlow<String> = _buttonPrintUrl.asStateFlow()
|
||||
|
||||
init {
|
||||
_buttonPrintUrl.value = appStoreDataSource.getButtonPrintUrl()
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化图片列表,默认全选
|
||||
* @param photoUrls 图片URL列表(JSON 格式或旧格式)
|
||||
* @param orderNumber 订单号
|
||||
* @param captureType 抓拍类型
|
||||
* urls 格式:
|
||||
* - JSON 格式(新):FileMapData 数组的 JSON 字符串
|
||||
* - id:url 格式(兼容):id:url,id:url
|
||||
* - url 格式(兼容):url,url
|
||||
*/
|
||||
fun initPhotoList(photoUrls: List<String>, orderNumber: String = "", captureType: Int? = null) {
|
||||
// 保存订单信息
|
||||
_orderNumber.value = orderNumber
|
||||
_captureType.value = captureType
|
||||
LogUtils.d("PaySuccessViewModel", "初始化 - orderNumber: $orderNumber, captureType: $captureType")
|
||||
|
||||
if (photoUrls.isEmpty()) {
|
||||
_photoList.value = emptyList()
|
||||
_selectedPhotoIds.value = emptySet()
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
var initialPhotos: List<PhotoData>
|
||||
|
||||
// 尝试解析为 JSON 格式(新格式:file_map 数组的 JSON 字符串)
|
||||
if (photoUrls.size == 1 && photoUrls[0].startsWith("[")) {
|
||||
// JSON 数组格式:将 file_map 列表序列化后的 JSON 数组字符串反序列化为 FileMapData 对象数组
|
||||
try {
|
||||
val jsonString = java.net.URLDecoder.decode(photoUrls[0], "UTF-8")
|
||||
LogUtils.d("PaySuccessViewModel", "接收到 JSON 数组字符串: $jsonString")
|
||||
|
||||
// 使用 Gson 将 JSON 数组反序列化为 FileMapData 对象数组
|
||||
val type = object : TypeToken<List<FileMapData>>() {}.type
|
||||
val fileMapList: List<FileMapData> = Gson().fromJson(jsonString, type) ?: emptyList()
|
||||
LogUtils.d("PaySuccessViewModel", "反序列化成功,FileMapData 对象数量: ${fileMapList.size}")
|
||||
|
||||
// 将 FileMapData 对象数组转换为 PhotoData 列表
|
||||
initialPhotos = fileMapList.mapNotNull { fileMap ->
|
||||
val thumbnailUrl = if (appStoreDataSource.getUseLan()) fileMap.thumbnailLanUrl else fileMap.thumbnailOssUrl
|
||||
if (thumbnailUrl.isNullOrEmpty()) {
|
||||
LogUtils.i("PaySuccessViewModel", "FileMapData id=${fileMap.id} 的 thumbnailOssUrl 为空,跳过")
|
||||
null
|
||||
} else {
|
||||
PhotoData(
|
||||
id = fileMap.id,
|
||||
url = thumbnailUrl.trim(),
|
||||
aspectRatio = 1f,
|
||||
fileMapData = fileMap // 保存完整的 FileMapData 对象,供后续使用
|
||||
)
|
||||
}
|
||||
}
|
||||
LogUtils.d("PaySuccessViewModel", "成功创建 PhotoData 列表,数量: ${initialPhotos.size}")
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e("PaySuccessViewModel", "解析 JSON 格式失败: ${e.message}")
|
||||
e.printStackTrace()
|
||||
initialPhotos = emptyList()
|
||||
}
|
||||
} else {
|
||||
// 兼容旧格式:id:url 或 url
|
||||
initialPhotos = photoUrls.mapNotNull { urlStr ->
|
||||
val trimmed = urlStr.trim()
|
||||
if (trimmed.contains(":")) {
|
||||
// id:url 格式
|
||||
val parts = trimmed.split(":", limit = 2)
|
||||
if (parts.size == 2) {
|
||||
val id = parts[0].toIntOrNull()
|
||||
val url = parts[1]
|
||||
if (id != null && url.isNotEmpty()) {
|
||||
PhotoData(
|
||||
id = id,
|
||||
url = url,
|
||||
aspectRatio = 1f
|
||||
)
|
||||
} else null
|
||||
} else null
|
||||
} else {
|
||||
// 只有 url,使用 hashCode 作为临时 id(不推荐,但兼容)
|
||||
PhotoData(
|
||||
id = trimmed.hashCode(),
|
||||
url = trimmed,
|
||||
aspectRatio = 1f
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (initialPhotos.isEmpty()) {
|
||||
_photoList.value = emptyList()
|
||||
_selectedPhotoIds.value = emptySet()
|
||||
return@launch
|
||||
}
|
||||
|
||||
_photoList.value = initialPhotos
|
||||
// 默认全选(使用 id)
|
||||
_selectedPhotoIds.value = initialPhotos.map { it.id }.toSet()
|
||||
|
||||
// 并发获取所有图片的真实尺寸并更新宽高比
|
||||
val updatedPhotos = initialPhotos.mapIndexed { index, photo ->
|
||||
async(Dispatchers.IO) {
|
||||
try {
|
||||
val aspectRatio = getImageAspectRatio(photo.url)
|
||||
// 确保 aspectRatio 是有效值
|
||||
val validAspectRatio = if (aspectRatio > 0 && aspectRatio.isFinite()) {
|
||||
aspectRatio
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
photo.copy(aspectRatio = validAspectRatio)
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e("PaySuccessViewModel", "获取图片 ${photo.url} 尺寸失败: ${e.message}")
|
||||
// 获取失败,保持默认值
|
||||
photo
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
_photoList.value = updatedPhotos
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图片的宽高比
|
||||
*/
|
||||
private suspend fun getImageAspectRatio(imageUrl: String): Float = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val imageLoader = ImageLoader(App.instance)
|
||||
val request = ImageRequest.Builder(App.instance)
|
||||
.data(imageUrl)
|
||||
.size(Size.ORIGINAL)
|
||||
.build()
|
||||
|
||||
val drawable = imageLoader.execute(request).drawable
|
||||
if (drawable != null) {
|
||||
val width = drawable.intrinsicWidth
|
||||
val height = drawable.intrinsicHeight
|
||||
if (width > 0 && height > 0) {
|
||||
return@withContext width.toFloat() / height.toFloat()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// 获取失败,使用默认值
|
||||
}
|
||||
return@withContext 1f // 默认1:1
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换单张图片选中状态
|
||||
*/
|
||||
fun togglePhotoSelection(id: Int) {
|
||||
val current = _selectedPhotoIds.value.toMutableSet()
|
||||
if (current.contains(id)) {
|
||||
current.remove(id)
|
||||
} else {
|
||||
current.add(id)
|
||||
}
|
||||
_selectedPhotoIds.value = current
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换全选状态
|
||||
*/
|
||||
fun toggleSelectAll() {
|
||||
val allIds = _photoList.value.map { it.id }.toSet()
|
||||
val newSelected = if (_selectedPhotoIds.value.size == allIds.size) {
|
||||
// 当前全选,取消全选
|
||||
emptySet()
|
||||
} else {
|
||||
// 全选
|
||||
allIds
|
||||
}
|
||||
_selectedPhotoIds.value = newSelected
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击打印
|
||||
*/
|
||||
fun onPrintClick() {
|
||||
val selectedIds = _selectedPhotoIds.value.toList()
|
||||
if (selectedIds.isEmpty()) return
|
||||
|
||||
// 获取选中的图片的完整 FileMapData 列表
|
||||
val selectedFileMapData = _photoList.value
|
||||
.filter { selectedIds.contains(it.id) }
|
||||
.mapNotNull { it.fileMapData }
|
||||
|
||||
if (selectedFileMapData.isEmpty()) {
|
||||
LogUtils.e("PaySuccessViewModel", "选中的图片没有 FileMapData,无法打印")
|
||||
return
|
||||
}
|
||||
|
||||
// 将 FileMapData 数组序列化为 JSON 数组字符串
|
||||
val gson = Gson()
|
||||
val jsonArray = gson.toJson(selectedFileMapData)
|
||||
val encodedJson = java.net.URLEncoder.encode(jsonArray, "UTF-8")
|
||||
|
||||
// 构建路由参数
|
||||
val orderNumberParam = _orderNumber.value.let {
|
||||
if (it.isNotEmpty()) java.net.URLEncoder.encode(it, "UTF-8") else ""
|
||||
}
|
||||
val captureTypeParam = _captureType.value?.toString() ?: ""
|
||||
|
||||
// 创建 NavOptions,清除回退栈(清除到支付成功页面,包括它自己)
|
||||
val navOptions = NavOptions.Builder()
|
||||
.setPopUpTo(AppRoutes.PAY_SUCCESS, inclusive = true)
|
||||
.build()
|
||||
|
||||
// 跳转到打印页面
|
||||
val route = "${AppRoutes.PRINTING}?urls=$encodedJson&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
|
||||
toPage(route, navOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.yzx.kiosk.App
|
||||
import com.yzx.kiosk.base.BaseViewModel
|
||||
import com.yzx.kiosk.datastore.AppState
|
||||
import com.yzx.kiosk.navigation.AppNavigator
|
||||
import com.yzx.kiosk.navigation.routes.AppRoutes
|
||||
import com.yzx.kiosk.network.model.request.GetPayUrlRequest
|
||||
import com.yzx.kiosk.network.model.request.VerifyResultRequest
|
||||
import com.yzx.kiosk.network.repository.NetWorkRepository
|
||||
@@ -32,6 +33,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import com.yzx.kiosk.utils.LogUtils
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -80,6 +82,8 @@ class PhotoSelectViewModel @Inject constructor(
|
||||
navigator = navigator,
|
||||
appState = appState
|
||||
) {
|
||||
private val paymentHandled = AtomicBoolean(false)
|
||||
|
||||
// 图片列表
|
||||
private val _photoList = MutableStateFlow<List<PhotoData>>(emptyList())
|
||||
val photoList: StateFlow<List<PhotoData>> = _photoList.asStateFlow()
|
||||
@@ -126,9 +130,9 @@ class PhotoSelectViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
is UploadPhotoEvent.PaySuccess -> {
|
||||
// 支付成功,跳转到支付成功页面
|
||||
// 支付成功,直接进入打印页面
|
||||
LogUtils.d("PhotoSelectViewModel", "收到支付成功事件 - order_number: ${event.orderNumber}, capture_type: ${event.captureType}, fileMap: ${event.fileMap}")
|
||||
navigateToPaySuccess(event.orderNumber, event.captureType, event.fileMap)
|
||||
navigateToPrinting(event.orderNumber, event.captureType, event.fileMap)
|
||||
}
|
||||
else -> {
|
||||
// 其他事件不处理
|
||||
@@ -472,6 +476,7 @@ class PhotoSelectViewModel @Inject constructor(
|
||||
if (url.isNullOrEmpty()) {
|
||||
ToastUtils.show("获取支付二维码失败")
|
||||
} else {
|
||||
paymentHandled.set(false)
|
||||
_payQrCodeUrl.value = url
|
||||
onSuccess(url)
|
||||
}
|
||||
@@ -501,32 +506,60 @@ class PhotoSelectViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到支付成功页面
|
||||
* 支付成功后直接跳转到打印页面
|
||||
* @param orderNumber 订单号
|
||||
* @param captureType 抓拍类型
|
||||
* @param imageIds 图片ID数组(从支付成功消息中获取)
|
||||
* @param fileMap 支付成功消息中确认的图片数据
|
||||
*/
|
||||
private fun navigateToPaySuccess(orderNumber: String?, captureType: Int?, fileMap: String) {
|
||||
private fun navigateToPrinting(orderNumber: String?, captureType: Int?, fileMap: String) {
|
||||
viewModelScope.launch {
|
||||
|
||||
if (captureType != 1) {
|
||||
LogUtils.i(
|
||||
"PhotoSelectViewModel",
|
||||
"忽略非手机上传流程的支付成功消息 - captureType: $captureType"
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (orderNumber.isNullOrBlank()) {
|
||||
LogUtils.e("PhotoSelectViewModel", "支付成功消息缺少订单号")
|
||||
ToastUtils.show("支付成功,但订单信息不完整")
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (fileMap.isBlank()) {
|
||||
LogUtils.e("PhotoSelectViewModel", "未找到对应的 FileMapData,fileMap: $fileMap")
|
||||
ToastUtils.show("支付成功,但未找到对应的图片数据")
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
// 构建路由参数
|
||||
val orderNumberParam = orderNumber?.let { java.net.URLEncoder.encode(it, "UTF-8") } ?: ""
|
||||
val captureTypeParam = captureType?.toString() ?: ""
|
||||
|
||||
val paidFileMapList = try {
|
||||
val type = object : TypeToken<List<FileMapData>>() {}.type
|
||||
Gson().fromJson<List<FileMapData>>(fileMap, type).orEmpty()
|
||||
} catch (e: Exception) {
|
||||
LogUtils.e("PhotoSelectViewModel", "解析支付成功图片数据失败: ${e.message}")
|
||||
emptyList()
|
||||
}
|
||||
if (paidFileMapList.isEmpty()) {
|
||||
ToastUtils.show("支付成功,但未找到对应的图片数据")
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (!paymentHandled.compareAndSet(false, true)) {
|
||||
LogUtils.d("PhotoSelectViewModel", "支付成功已处理,忽略重复消息 - orderNumber: $orderNumber")
|
||||
return@launch
|
||||
}
|
||||
|
||||
// 创建 NavOptions,清除选择页面
|
||||
val navOptions = androidx.navigation.NavOptions.Builder()
|
||||
.setPopUpTo(com.yzx.kiosk.navigation.routes.AppRoutes.PHOTO_SELECT, inclusive = true)
|
||||
.setPopUpTo(AppRoutes.PHOTO_SELECT, inclusive = true)
|
||||
.build()
|
||||
|
||||
// 跳转到支付成功页面
|
||||
val route = "${com.yzx.kiosk.navigation.routes.AppRoutes.PAY_SUCCESS}?urls=$fileMap&orderNumber=$orderNumberParam&captureType=$captureTypeParam"
|
||||
|
||||
val route = AppRoutes.buildPrintingRoute(
|
||||
photoData = Gson().toJson(paidFileMapList),
|
||||
orderNumber = orderNumber,
|
||||
captureType = captureType
|
||||
)
|
||||
toPage(route, navOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.yzx.kiosk.navigation.routes
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
import java.net.URLDecoder
|
||||
|
||||
class AppRoutesTest {
|
||||
|
||||
@Test
|
||||
fun `printing route safely encodes photo data and order number`() {
|
||||
val photoData =
|
||||
"""[{"id":1,"original_oss_url":"https://example.com/photo.jpg?token=a&size=4x6"}]"""
|
||||
val orderNumber = "ORDER 1&2"
|
||||
|
||||
val route = AppRoutes.buildPrintingRoute(photoData, orderNumber, 1)
|
||||
val urlsParam = route.substringAfter("urls=").substringBefore("&orderNumber=")
|
||||
val orderNumberParam = route.substringAfter("&orderNumber=").substringBefore("&captureType=")
|
||||
|
||||
assertEquals(photoData, URLDecoder.decode(urlsParam, "UTF-8"))
|
||||
assertEquals(orderNumber, URLDecoder.decode(orderNumberParam, "UTF-8"))
|
||||
assertEquals("1", route.substringAfter("&captureType="))
|
||||
assertFalse(route.contains("token=a&size=4x6"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `printing route leaves missing order metadata empty`() {
|
||||
val route = AppRoutes.buildPrintingRoute("[]", null, null)
|
||||
|
||||
assertEquals("printing?urls=%5B%5D&orderNumber=&captureType=", route)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user