From f863004c8de0798f881d8c342c5f3a9b5e0a50db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Fri, 31 Jul 2026 14:07:05 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E6=94=B6=E6=AC=BE?= =?UTF-8?q?=E7=A0=81=E5=8A=A0=E8=BD=BD=E6=80=81=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E9=A6=96=E5=B1=8F=E9=94=99=E8=AF=AF=E5=8D=A0=E4=BD=8D=E9=97=AA?= =?UTF-8?q?=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../PaymentCollectionDetailsViewModel.swift | 10 ++++- ...ymentCollectionDetailsViewController.swift | 44 ++++++++++++++++--- ...ymentCollectionDetailsViewModelTests.swift | 21 +++++++++ 3 files changed, 66 insertions(+), 9 deletions(-) diff --git a/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift b/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift index 9a2c43d..439e265 100644 --- a/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift +++ b/suixinkan/Features/Payment/ViewModels/PaymentCollectionDetailsViewModel.swift @@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewModel { private var staticPayURL = "" private var dynamicPayURL = "" + private var hasCompletedPayCodeRequest = false private let appStore: AppStore private let payPageConfigCache: PayPageConfigCaching @@ -50,6 +51,11 @@ final class PaymentCollectionDetailsViewModel { qrImage != nil && !staticPayURL.isEmpty } + /// 是否应在二维码区域展示加载状态,首次请求开始前也视为加载中,避免短暂闪现错误占位。 + var isPayCodeLoading: Bool { + loading || !hasCompletedPayCodeRequest + } + var displayScenicName: String { scenicName.isEmpty ? "暂无景区" : scenicName } @@ -85,11 +91,11 @@ final class PaymentCollectionDetailsViewModel { func loadPayCode(api: PaymentAPI) async { guard !loading else { return } - refreshLocalState() loading = true - notifyStateChange() + refreshLocalState() defer { loading = false + hasCompletedPayCodeRequest = true notifyStateChange() } diff --git a/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift b/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift index f89b9d7..a2b791f 100644 --- a/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift +++ b/suixinkan/UI/Payment/PaymentCollectionDetailsViewController.swift @@ -28,6 +28,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { private let qrContainerView = UIView() private let qrImageView = UIImageView() private let qrPlaceholderView = UIView() + private let qrLoadingIndicator = UIActivityIndicatorView(style: .medium) private let qrErrorLabel = UILabel() private let refreshButton = UIButton(type: .system) private let setAmountButton = UIButton(type: .system) @@ -127,6 +128,10 @@ final class PaymentCollectionDetailsViewController: BaseViewController { qrPlaceholderView.layer.cornerRadius = usesBranding ? 10 : 24 qrPlaceholderView.isHidden = true + qrLoadingIndicator.color = AppColor.primary + qrLoadingIndicator.hidesWhenStopped = true + qrLoadingIndicator.accessibilityLabel = "收款码加载中" + qrErrorLabel.text = "未选景区或网络问题" qrErrorLabel.font = .systemFont(ofSize: 14) qrErrorLabel.textColor = AppColor.danger @@ -150,6 +155,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController { trailing: 24 ) } + actionRow.alpha = 0 + actionRow.isUserInteractionEnabled = false + actionRow.accessibilityElementsHidden = true configureLinkButton(setAmountButton, title: "设置金额") configureLinkButton(saveQRButton, title: "保存二维码") @@ -206,6 +214,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { qrContentStack.spacing = usesBranding ? brandSectionSpacing : AppSpacing.lg qrCardView.addSubview(qrContentStack) + qrPlaceholderView.addSubview(qrLoadingIndicator) qrPlaceholderView.addSubview(qrErrorLabel) qrPlaceholderView.addSubview(refreshButton) @@ -275,6 +284,9 @@ final class PaymentCollectionDetailsViewController: BaseViewController { qrPlaceholderView.snp.makeConstraints { make in make.width.height.equalTo(usesBranding ? brandQRContainerSize : 182) } + qrLoadingIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + } qrErrorLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(56) make.leading.trailing.equalToSuperview().inset(AppSpacing.sm) @@ -370,9 +382,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { private func loadData() async { async let pageConfig: Void = viewModel.loadPayPageConfig(api: paymentAPI) - showLoading() await viewModel.loadPayCode(api: paymentAPI) - hideLoading() await pageConfig } @@ -387,17 +397,33 @@ final class PaymentCollectionDetailsViewController: BaseViewController { voiceSwitch.isOn = viewModel.isVoiceBroadcastOpen applyBrandConfigIfNeeded() - if let image = viewModel.qrImage { + if viewModel.isPayCodeLoading { + qrImageView.isHidden = true + qrContainerView.isHidden = true + qrPlaceholderView.backgroundColor = viewModel.usesNalatiBranding + ? .white + : AppColor.inputBackground + qrPlaceholderView.isHidden = false + qrErrorLabel.isHidden = true + refreshButton.isHidden = true + qrLoadingIndicator.startAnimating() + setActionRowAvailable(false) + } else if let image = viewModel.qrImage { qrImageView.image = image qrImageView.isHidden = false qrContainerView.isHidden = !viewModel.usesNalatiBranding qrPlaceholderView.isHidden = true - actionRow.isHidden = false + qrLoadingIndicator.stopAnimating() + setActionRowAvailable(true) } else { qrImageView.isHidden = true qrContainerView.isHidden = true + qrPlaceholderView.backgroundColor = AppColor.inputBackground qrPlaceholderView.isHidden = false - actionRow.isHidden = true + qrErrorLabel.isHidden = false + refreshButton.isHidden = false + qrLoadingIndicator.stopAnimating() + setActionRowAvailable(false) } if viewModel.showAmountDialog { @@ -408,6 +434,12 @@ final class PaymentCollectionDetailsViewController: BaseViewController { } } + private func setActionRowAvailable(_ isAvailable: Bool) { + actionRow.alpha = isAvailable ? 1 : 0 + actionRow.isUserInteractionEnabled = isAvailable + actionRow.accessibilityElementsHidden = !isAvailable + } + private func presentAmountDialogIfNeeded() { guard amountDialog == nil else { amountDialog?.apply(amount: viewModel.amount, remark: viewModel.remark) @@ -625,9 +657,7 @@ final class PaymentCollectionDetailsViewController: BaseViewController { @objc private func refreshTapped() { Task { - showLoading() await viewModel.refresh(api: paymentAPI) - hideLoading() } } diff --git a/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift b/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift index fe4fe17..4bfa64b 100644 --- a/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift +++ b/suixinkanTests/PaymentCollectionDetailsViewModelTests.swift @@ -69,6 +69,26 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase { XCTAssertFalse(viewModel.showAmountDialog) } + func testPayCodeLoadingStateCoversInitialRenderAndRequest() async { + let payCodeJSON = """ + {"code":100000,"msg":"success","data":{"static_pay_url":"https://pay.example.com/static","dynamic_pay_url":"https://pay.example.com/dynamic"}} + """.data(using: .utf8)! + let session = MockURLSession(responses: [payCodeJSON]) + let api = PaymentAPI(client: APIClient(environment: .testing, session: session)) + let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) + var loadingStates = [viewModel.isPayCodeLoading] + viewModel.onStateChange = { + loadingStates.append(viewModel.isPayCodeLoading) + } + + await viewModel.loadPayCode(api: api) + + XCTAssertEqual(loadingStates.first, true) + XCTAssertTrue(loadingStates.dropLast().allSatisfy { $0 }) + XCTAssertEqual(loadingStates.last, false) + XCTAssertTrue(viewModel.hasPayCode) + } + func testToggleReceiveVoicePersistsInAppStore() { let viewModel = PaymentCollectionDetailsViewModel(appStore: appStore) viewModel.refreshLocalState() @@ -105,6 +125,7 @@ final class PaymentCollectionDetailsViewModelTests: XCTestCase { await viewModel.loadPayCode(api: api) + XCTAssertFalse(viewModel.isPayCodeLoading) XCTAssertFalse(viewModel.hasPayCode) XCTAssertEqual(session.requests.count, 0) }