diff --git a/app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt b/app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt index 9bd5b7d..08de517 100644 --- a/app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt +++ b/app/src/main/java/com/yzx/kiosk/audio/LocalAudioPlayService.kt @@ -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, // 特殊状态页面 diff --git a/app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt b/app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt index 19c1899..7c64007 100644 --- a/app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt +++ b/app/src/main/java/com/yzx/kiosk/navigation/AppNavHost.kt @@ -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") ?: "" diff --git a/app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt b/app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt index bfc4edd..2d82cd3 100644 --- a/app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt +++ b/app/src/main/java/com/yzx/kiosk/navigation/routes/AppRoutes.kt @@ -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" + } } diff --git a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt index 302d7d2..3fcc1be 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/face/viewmodel/FaceRecognitionResultViewModel.kt @@ -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) { + private fun navigateToPrinting(orderNumber: String?, captureType: Int?, imageIds: List) { 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) } } diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/view/PaySuccessScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/view/PaySuccessScreen.kt deleted file mode 100644 index 2c98968..0000000 --- a/app/src/main/java/com/yzx/kiosk/ui/upload/view/PaySuccessScreen.kt +++ /dev/null @@ -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, - 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) - ) - } - } - - - } -} - diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/view/PhotoSelectScreen.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/view/PhotoSelectScreen.kt index 7ab13ec..f60a341 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/upload/view/PhotoSelectScreen.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/upload/view/PhotoSelectScreen.kt @@ -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) { diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PaySuccessViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PaySuccessViewModel.kt deleted file mode 100644 index 3726b31..0000000 --- a/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PaySuccessViewModel.kt +++ /dev/null @@ -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>(emptyList()) - val photoList: StateFlow> = _photoList.asStateFlow() - - // 已选中的图片ID - private val _selectedPhotoIds = MutableStateFlow>(emptySet()) - val selectedPhotoIds: StateFlow> = _selectedPhotoIds.asStateFlow() - - // 订单号 - private val _orderNumber = MutableStateFlow("") - val orderNumber: StateFlow = _orderNumber.asStateFlow() - - // 抓拍类型 - private val _captureType = MutableStateFlow(null) - val captureType: StateFlow = _captureType.asStateFlow() - - /** 设备配置下发的打印按钮图 URL(可能为空) */ - private val _buttonPrintUrl = MutableStateFlow("") - val buttonPrintUrl: StateFlow = _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, 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 - - // 尝试解析为 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>() {}.type - val fileMapList: List = 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) - } -} - diff --git a/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PhotoSelectViewModel.kt b/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PhotoSelectViewModel.kt index f357944..a963ab2 100644 --- a/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PhotoSelectViewModel.kt +++ b/app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PhotoSelectViewModel.kt @@ -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>(emptyList()) val photoList: StateFlow> = _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>() {}.type + Gson().fromJson>(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) } } diff --git a/app/src/test/java/com/yzx/kiosk/navigation/routes/AppRoutesTest.kt b/app/src/test/java/com/yzx/kiosk/navigation/routes/AppRoutesTest.kt new file mode 100644 index 0000000..f98843a --- /dev/null +++ b/app/src/test/java/com/yzx/kiosk/navigation/routes/AppRoutesTest.kt @@ -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) + } +} diff --git a/docs/工程业务与技术导读.md b/docs/工程业务与技术导读.md index 38592b0..b2a02f9 100644 --- a/docs/工程业务与技术导读.md +++ b/docs/工程业务与技术导读.md @@ -92,7 +92,7 @@ sequenceDiagram K->>API: get-pay-url 获取支付二维码 User->>API: 手机支付 WS-->>K: code=5 支付成功及订单信息 - K-->>User: 支付成功,确认要打印的照片 + K-->>User: 支付成功,直接进入打印页 K->>P: 逐张下载、处理并打印 K->>API: print-notify 上报每张结果 K->>API: print-complete 上报订单完成 @@ -104,7 +104,6 @@ sequenceDiagram 首页 -> 上传照片二维码页 UploadPhotoScreen -> 照片选择页 PhotoSelectScreen - -> 支付成功页 PaySuccessScreen -> 打印页 PrintingScreen -> 自动返回首页 ``` @@ -112,8 +111,7 @@ sequenceDiagram 核心类: - [`UploadPhotoViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/UploadPhotoViewModel.kt):加载上传二维码,监听扫码和文件列表事件 -- [`PhotoSelectViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PhotoSelectViewModel.kt):选片、计价、获取支付二维码 -- [`PaySuccessViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PaySuccessViewModel.kt):支付后再次确认打印照片 +- [`PhotoSelectViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PhotoSelectViewModel.kt):选片、计价、获取支付二维码,并在支付成功后直接进入打印页 - [`PrintingViewModel.kt`](../app/src/main/java/com/yzx/kiosk/ui/upload/viewmodel/PrintingViewModel.kt):逐张下载、打印、记录并上报结果 服务端通过 `file_map` 同时下发 OSS 和局域网地址。客户端根据设置中的“使用 LAN”开关选择 `*_lan_url` 或 `*_oss_url`。 @@ -130,7 +128,7 @@ sequenceDiagram 4. 服务根据人脸相似度返回当天匹配的照片。 5. 终端展示缩略图,游客选择照片。 6. 客户端请求后端计算价格并生成支付二维码。 -7. WebSocket 收到支付成功事件后进入支付成功页和打印页。 +7. WebSocket 或 HTTP 轮询确认支付成功后直接进入打印页。 主要代码: