diff --git a/AGENTS.md b/AGENTS.md index ca07ede..5364d1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ Swift · UIKit · SnapKit(代码布局,约束写在 `setupUI()` / `setupCons ## 单元测试 -每增一功能必须附带单元测试且全部通过(`Cmd + U` / `xcodebuild test`),与功能同次提交。 +每增一功能必须附带单元测试且全部通过,与功能同次提交。单元测试必须在已连接的 iPhone 真机上运行,不使用模拟器;支持使用与开发机处于同一 Wi-Fi 下并已启用无线连接的真机。可在 Xcode 中选择真机后执行 `Cmd + U`,或使用 `xcodebuild test -destination 'platform=iOS,id=<设备 UDID>'`。 | 层级 | 优先级 | |------|--------| diff --git a/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift b/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift index f8bbf22..9f3931b 100644 --- a/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift +++ b/suixinkan/Features/Wallet/ViewModels/WalletViewModels.swift @@ -173,6 +173,16 @@ final class WalletViewModel { await loadWithdraw(api: api, refresh: true) } + /// 仅刷新当前选中的钱包 Tab。 + func refreshSelectedTab(api: any WalletPageServing) async { + switch selectedTab { + case .withdraw: + await refreshWithdraw(api: api) + case .transaction: + await refreshTransaction(api: api) + } + } + /// 滚动到底部时加载更多提现记录。 func loadMoreWithdrawIfNeeded(currentIndex: Int, api: any WalletPageServing) async { guard currentIndex >= withdrawRecords.count - 2, withdrawCanLoadMore, !withdrawLoading else { return } @@ -336,6 +346,85 @@ enum WalletTransactionEntry: Hashable { case item(EarningDetailItem) } +/// 钱包记录状态的语义色调,由页面映射为模块内固定色值。 +enum WalletStatusTone: Equatable { + case info + case success + case warning + case danger +} + +/// 银行卡提现记录的状态展示映射,对齐 Android 结算进度和时间线可见性。 +struct WalletWithdrawStatusDisplay: Equatable { + let tone: WalletStatusTone + let progress: Int + let showsTimeline: Bool + + /// 根据后端结算状态创建展示数据。 + init(status: Int) { + switch status { + case 20: + tone = .success + progress = 80 + showsTimeline = true + case 30: + tone = .danger + progress = 100 + showsTimeline = true + case 40: + tone = .warning + progress = 40 + showsTimeline = true + case 50: + tone = .success + progress = 100 + showsTimeline = false + case 60: + tone = .danger + progress = 0 + showsTimeline = true + default: + tone = .info + progress = status == 10 ? 10 : 100 + showsTimeline = true + } + } +} + +/// 积分兑现记录的状态文案、进度与时间字段映射。 +struct PointWithdrawStatusDisplay: Equatable { + let title: String + let tone: WalletStatusTone + let progress: Int + let timeTitle: String + + /// 根据后端积分提现状态创建展示数据。 + init(status: Int) { + switch status { + case 1: + title = "提现中" + tone = .warning + progress = 40 + timeTitle = "预计到账时间:" + case 2: + title = "提现成功" + tone = .success + progress = 0 + timeTitle = "到账时间:" + case 3: + title = "提现失败" + tone = .danger + progress = 100 + timeTitle = "审核时间:" + default: + title = "未知" + tone = .info + progress = 0 + timeTitle = "预计到账时间:" + } + } +} + /// 提现申请 ViewModel,管理金额、短信验证码、倒计时和提交状态。 final class WithdrawViewModel { private(set) var withdrawInfo: WithdrawInfoResponse? diff --git a/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift index b0a8072..29e5f69 100644 --- a/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift +++ b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift @@ -888,8 +888,7 @@ struct WildReportDetailData: Decodable, Equatable, Hashable { id: content.id > 0 ? String(content.id) : content.time, submitTime: content.time, text: content.desc, - imageCount: content.imageCount, - videoCount: content.videoCount + evidences: content.evidences ) } } @@ -956,8 +955,11 @@ struct WildReportSupplementaryEvidence: Hashable { let id: String let submitTime: String let text: String - let imageCount: Int - let videoCount: Int + let evidences: [WildReportDetailEvidence] + + var imageCount: Int { evidences.filter(\.isImage).count } + var videoCount: Int { evidences.filter(\.isVideo).count } + var hasMedia: Bool { imageCount > 0 || videoCount > 0 } var summaryText: String { var parts: [String] = [] diff --git a/suixinkan/UI/Wallet/PointsRedemptionViewController.swift b/suixinkan/UI/Wallet/PointsRedemptionViewController.swift index 2744f1c..b1b6ebb 100644 --- a/suixinkan/UI/Wallet/PointsRedemptionViewController.swift +++ b/suixinkan/UI/Wallet/PointsRedemptionViewController.swift @@ -372,11 +372,12 @@ private final class PointWithdrawRecordCell: UITableViewCell { amountLabel.text = record.amount != nil && record.status == 2 ? "+\(formatAmount(record.amount))" : "" amountLabel.isHidden = amountLabel.text?.isEmpty ?? true timeLabel.text = record.createdAt.isEmpty ? "----" : record.createdAt - let style = statusStyle(for: record.status) - statusLabel.text = statusText(for: record.status) + let display = PointWithdrawStatusDisplay(status: record.status) + let style = statusStyle(for: display.tone) + statusLabel.text = display.title statusLabel.textColor = style.text statusLabel.backgroundColor = style.background - let progress = progress(for: record.status) + let progress = display.progress progressView.progress = Float(progress) / 100.0 progressView.progressTintColor = style.progress progressView.isHidden = progress == 0 @@ -498,45 +499,28 @@ private final class PointWithdrawRecordCell: UITableViewCell { } } - private func statusText(for status: Int) -> String { - switch status { - case 2: "提现成功" - case 1: "提现中" - case 3: "提现失败" - default: "未知" - } - } - - private func statusStyle(for status: Int) -> (background: UIColor, text: UIColor, progress: UIColor) { - switch status { - case 2: + private func statusStyle(for tone: WalletStatusTone) -> (background: UIColor, text: UIColor, progress: UIColor) { + switch tone { + case .success: (WalletTokens.successBackground, WalletTokens.success, WalletTokens.summaryBackground) - case 1: + case .warning: (WalletTokens.pointsPendingBackground, WalletTokens.points, WalletTokens.summaryBackground) - case 3: + case .danger: (WalletTokens.dangerBackground, WalletTokens.danger, WalletTokens.progressDanger) - default: + case .info: (WalletTokens.infoBackground, WalletTokens.summaryBackground, WalletTokens.summaryBackground) } } - private func progress(for status: Int) -> Int { - switch status { - case 1: 40 - case 3: 100 - default: 0 - } - } - private func infoText(for record: PointWithdrawItem) -> String { + let display = PointWithdrawStatusDisplay(status: record.status) + let value: String switch record.status { - case 3: - return "审核时间:\(record.reviewedAt.isEmpty ? "暂无" : record.reviewedAt)" - case 2: - return "到账时间:\(record.paymentTime.isEmpty ? "暂无" : record.paymentTime)" - default: - return "预计到账时间:\(record.estimatedReceiptTime.isEmpty ? "暂无" : record.estimatedReceiptTime)" + case 3: value = record.reviewedAt + case 2: value = record.paymentTime + default: value = record.estimatedReceiptTime } + return "\(display.timeTitle)\(value.isEmpty ? "暂无" : value)" } private func formatAmount(_ value: Double?) -> String { diff --git a/suixinkan/UI/Wallet/WalletCells.swift b/suixinkan/UI/Wallet/WalletCells.swift index d90ffe2..a55f29b 100644 --- a/suixinkan/UI/Wallet/WalletCells.swift +++ b/suixinkan/UI/Wallet/WalletCells.swift @@ -33,14 +33,16 @@ final class WalletWithdrawRecordCell: UITableViewCell { amountLabel.text = WalletViewModel.formatAmount(record.amount) statusLabel.text = record.statusLabel.isEmpty ? "--" : record.statusLabel timeLabel.text = record.createdAt.isEmpty ? "--" : record.createdAt - let style = statusStyle(for: record.settlementStatus) + let display = WalletWithdrawStatusDisplay(status: record.settlementStatus) + let style = statusStyle(for: display.tone) statusLabel.textColor = style.text statusLabel.backgroundColor = style.background progressView.progressTintColor = style.progress - progressView.progress = progress(for: record.settlementStatus) - configureSteps(progress: progress(for: record.settlementStatus), tint: style.progress) - progressView.isHidden = record.settlementStatus == 50 - stepsStack.isHidden = record.settlementStatus == 50 + let progress = Float(display.progress) / 100 + progressView.progress = progress + configureSteps(progress: progress, tint: style.progress) + progressView.isHidden = !display.showsTimeline + stepsStack.isHidden = !display.showsTimeline infoLabel.attributedText = infoText(for: record) infoLabel.isHidden = infoLabel.attributedText?.length == 0 infoLabel.snp.remakeConstraints { make in @@ -141,30 +143,19 @@ final class WalletWithdrawRecordCell: UITableViewCell { stepsStack.addArrangedSubview(percent) } - private func statusStyle(for status: Int) -> (background: UIColor, text: UIColor, progress: UIColor) { - switch status { - case 50, 20: + private func statusStyle(for tone: WalletStatusTone) -> (background: UIColor, text: UIColor, progress: UIColor) { + switch tone { + case .success: (WalletTokens.settlementSuccessBackground, WalletTokens.settlementSuccess, WalletTokens.summaryBackground) - case 60, 30: + case .danger: (WalletTokens.settlementDangerBackground, WalletTokens.progressDanger, WalletTokens.progressDanger) - case 40: + case .warning: (WalletTokens.warningBackground, WalletTokens.warning, WalletTokens.summaryBackground) - default: + case .info: (WalletTokens.infoBackground, WalletTokens.summaryBackground, WalletTokens.summaryBackground) } } - private func progress(for status: Int) -> Float { - switch status { - case 10: 0.1 - case 40: 0.4 - case 20: 0.8 - case 30: 1.0 - case 60: 0.0 - default: 1.0 - } - } - private func infoText(for record: WalletWithdrawItem) -> NSAttributedString { let result = NSMutableAttributedString() let normal: [NSAttributedString.Key: Any] = [ diff --git a/suixinkan/UI/Wallet/WalletViewController.swift b/suixinkan/UI/Wallet/WalletViewController.swift index 5a62258..cb53dc7 100644 --- a/suixinkan/UI/Wallet/WalletViewController.swift +++ b/suixinkan/UI/Wallet/WalletViewController.swift @@ -320,14 +320,7 @@ final class WalletViewController: BaseViewController, UITableViewDelegate { } @objc private func refreshPulled() { - Task { - switch viewModel.selectedTab { - case .withdraw: - await viewModel.refreshWithdraw(api: walletAPI) - case .transaction: - await viewModel.refreshTransaction(api: walletAPI) - } - } + Task { await viewModel.refreshSelectedTab(api: walletAPI) } } @objc private func withdrawTapped() { diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift index 15e952a..1ab7adf 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift @@ -816,23 +816,11 @@ final class WildPhotographerReportDetailViewController: BaseViewController { card.stack.addArrangedSubview(WildReportUI.label("补充证据记录", font: .systemFont(ofSize: 18, weight: .bold), color: AppColor.textPrimary)) let items = viewModel.supplementaryEvidences items.enumerated().forEach { index, item in - let itemStack = UIStackView() - itemStack.axis = .vertical - itemStack.spacing = 8 - - let header = UIStackView() - header.axis = .horizontal - header.alignment = .center - header.addArrangedSubview(WildReportUI.label(item.submitTime, font: .app(.captionMedium), color: AppColor.textSecondary)) - header.addArrangedSubview(UIView()) - header.addArrangedSubview(WildReportUI.label( - WildReportSupplementDisplayFormatter.sequenceTitle(forDisplayIndex: index), - font: .app(.captionMedium), - color: AppColor.primary - )) - itemStack.addArrangedSubview(header) - itemStack.addArrangedSubview(WildReportUI.label(item.summaryText, font: .app(.bodyMedium), color: AppColor.textPrimary, lines: 0)) - card.stack.addArrangedSubview(itemStack) + let row = WildReportSupplementEvidenceRowView(item: item, displayIndex: index) + row.onPreview = { [weak self] in + self?.presentSupplementMediaPreview(item) + } + card.stack.addArrangedSubview(row) if index < items.count - 1 { card.stack.addArrangedSubview(makeDivider()) } @@ -1288,6 +1276,23 @@ final class WildPhotographerReportDetailViewController: BaseViewController { presentMediaPreview(entries: entries, selectedKey: selectedKey, emptyMessage: "当前处理凭证暂无可预览文件") } + private func presentSupplementMediaPreview(_ record: WildReportSupplementaryEvidence) { + let items = record.evidences.compactMap { evidence -> MediaPreviewItem? in + if evidence.isVideo { + return .remoteVideo(evidence.fileURL) + } + if evidence.isImage { + return .remoteImage(evidence.fileURL) + } + return nil + } + guard !items.isEmpty else { + showToast("当前补充记录暂无可预览文件") + return + } + presentMediaPreview(items: items, startIndex: 0) + } + private func previewEntries(for mediaItems: [WildReportReporterMediaItem]) -> [(key: String, item: MediaPreviewItem)] { mediaItems.compactMap { mediaItem in guard let key = mediaItem.previewKey, @@ -1372,6 +1377,79 @@ private final class WildReportDetailIconTextButton: UIControl { } } +/// 举报详情页补充证据记录行,包含媒体时提供整行预览交互。 +private final class WildReportSupplementEvidenceRowView: UIControl { + var onPreview: (() -> Void)? + + private let previewEnabled: Bool + + init(item: WildReportSupplementaryEvidence, displayIndex: Int) { + previewEnabled = item.hasMedia + super.init(frame: .zero) + + let sequenceTitle = WildReportSupplementDisplayFormatter.sequenceTitle(forDisplayIndex: displayIndex) + let header = UIStackView() + header.axis = .horizontal + header.alignment = .center + header.spacing = 6 + header.isUserInteractionEnabled = false + header.addArrangedSubview(WildReportUI.label(item.submitTime, font: .app(.captionMedium), color: AppColor.textSecondary)) + header.addArrangedSubview(UIView()) + header.addArrangedSubview(WildReportUI.label(sequenceTitle, font: .app(.captionMedium), color: AppColor.primary)) + + if previewEnabled { + let chevron = UIImageView(image: UIImage(systemName: "chevron.right")) + chevron.tintColor = AppColor.primary + chevron.contentMode = .scaleAspectFit + chevron.snp.makeConstraints { make in + make.size.equalTo(12) + } + header.addArrangedSubview(chevron) + } + + let summaryLabel = WildReportUI.label(item.summaryText, font: .app(.bodyMedium), color: AppColor.textPrimary, lines: 0) + summaryLabel.isUserInteractionEnabled = false + let contentStack = UIStackView(arrangedSubviews: [header, summaryLabel]) + contentStack.axis = .vertical + contentStack.spacing = 8 + contentStack.isUserInteractionEnabled = false + + addSubview(contentStack) + contentStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + isEnabled = previewEnabled + guard previewEnabled else { return } + + addTarget(self, action: #selector(previewTapped), for: .touchUpInside) + isAccessibilityElement = true + accessibilityTraits = .button + accessibilityLabel = [sequenceTitle, item.submitTime, item.summaryText] + .filter { !$0.isEmpty } + .joined(separator: ",") + accessibilityHint = "双击全屏预览图片或视频" + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var isHighlighted: Bool { + didSet { + guard previewEnabled else { return } + UIView.animate(withDuration: 0.15) { + self.alpha = self.isHighlighted ? 0.55 : 1 + } + } + } + + @objc private func previewTapped() { + onPreview?() + } +} + /// 举报详情页信息行,适配短文本与多行说明。 private final class WildReportDetailInfoRowView: UIView { init(title: String, value: String, multiline: Bool = false, trailingIconName: String? = nil) { diff --git a/suixinkanTests/WalletFeatureTests.swift b/suixinkanTests/WalletFeatureTests.swift index bc40a89..ba8e917 100644 --- a/suixinkanTests/WalletFeatureTests.swift +++ b/suixinkanTests/WalletFeatureTests.swift @@ -37,6 +37,7 @@ final class WalletFeatureTests: XCTestCase { func testWalletAPIUsesExpectedRequests() async throws { let session = MockURLSession(responses: [ Self.envelope(#"{"amount_total":"100.00","amount_current_balance":"80.00","amount_withdrawable":"50.00"}"#), + Self.envelope(#"{"total":0,"list":[]}"#), Self.envelope(#"{"total":0,"item":[]}"#), Self.envelope(#"{"total_amount":"0.00","total_points":0,"total":0,"list":[]}"#), Self.envelope(#"{"amount_withdrawable":"50.00","min_withdraw_amount":"1","max_single_withdraw_amount":"100","max_daily_withdraw_amount":"200","user_phone":"13800138000","bank_card":{"real_name":"张三","bank_name":"招商银行","card_number":"622200001234"},"settlement":"T+1","withdraw_info":["说明"]}"#), @@ -49,6 +50,7 @@ final class WalletFeatureTests: XCTestCase { let api = WalletAPI(client: APIClient(environment: .testing, session: session)) _ = try await api.walletSummary(type: 0) + _ = try await api.walletTransactionList(startDate: "2026-07-01", endDate: "2026-07-09", page: 0, pageSize: 0, type: 2) _ = try await api.walletWithdrawList(page: 0, pageSize: 0) _ = try await api.earningDetail(startDate: "2026-07-01", endDate: "2026-07-09", page: 2, pageSize: 10) _ = try await api.withdrawInfo() @@ -60,6 +62,7 @@ final class WalletFeatureTests: XCTestCase { XCTAssertEqual(session.requests.map { $0.url?.path }, [ "/api/yf-handset-app/photog/wallet/summary", + "/api/yf-handset-app/photog/wallet/transaction-list", "/api/yf-handset-app/photog/wallet/withdraw-list", "/api/yf-handset-app/photog/wallet/earning-detail", "/api/yf-handset-app/photog/wallet/withdraw-info", @@ -69,14 +72,21 @@ final class WalletFeatureTests: XCTestCase { "/api/yf-handset-app/photog/point/withdraw/apply", "/api/yf-handset-app/photog/point/withdraw/list", ]) - XCTAssertEqual(Self.queryItems(from: session.requests[1])["page"], "1") - XCTAssertEqual(Self.queryItems(from: session.requests[2])["start_date"], "2026-07-01") - XCTAssertEqual(Self.queryItems(from: session.requests[6])["staff_id"], "9") - XCTAssertNil(session.requests[4].httpBody) - XCTAssertNil(session.requests[6].httpBody) - XCTAssertEqual(Self.bodyString(from: session.requests[5]).contains(#""sms_code":"123456""#), true) - XCTAssertEqual(Self.bodyString(from: session.requests[7]).contains(#""points":24"#), true) - XCTAssertEqual(Self.bodyString(from: session.requests[8]).contains(#""page_size":10"#), true) + XCTAssertEqual(session.requests.map(\.httpMethod), ["GET", "GET", "GET", "GET", "GET", "POST", "POST", "POST", "POST", "POST"]) + XCTAssertEqual(Self.queryItems(from: session.requests[0])["type"], "0") + XCTAssertEqual(Self.queryItems(from: session.requests[1]), [ + "start_date": "2026-07-01", "end_date": "2026-07-09", "page": "1", "page_size": "1", "type": "2", + ]) + XCTAssertEqual(Self.queryItems(from: session.requests[2]), ["page": "1", "page_size": "1"]) + XCTAssertEqual(Self.queryItems(from: session.requests[3]), [ + "start_date": "2026-07-01", "end_date": "2026-07-09", "page": "2", "page_size": "10", + ]) + XCTAssertEqual(Self.queryItems(from: session.requests[7])["staff_id"], "9") + XCTAssertNil(session.requests[5].httpBody) + XCTAssertNil(session.requests[7].httpBody) + XCTAssertEqual(Self.bodyString(from: session.requests[6]).contains(#""sms_code":"123456""#), true) + XCTAssertEqual(Self.bodyString(from: session.requests[8]).contains(#""points":24"#), true) + XCTAssertEqual(Self.bodyString(from: session.requests[9]).contains(#""page_size":10"#), true) } func testWalletViewModelLoadsAndPaginates() async { @@ -139,6 +149,38 @@ final class WalletFeatureTests: XCTestCase { XCTAssertEqual(pointsViewModel.withdrawRecords.count, 1) } + func testCurrentTabRefreshAndPaginationThresholds() async { + let api = FakeWalletPageService() + api.pagedRecordTotal = 25 + let walletViewModel = WalletViewModel(staffIdProvider: { 9 }) + let pointsViewModel = PointsRedemptionViewModel(staffIdProvider: { 9 }) + + await walletViewModel.refreshSelectedTab(api: api) + XCTAssertEqual(api.withdrawListPages, [1]) + XCTAssertTrue(api.earningQueries.isEmpty) + + await walletViewModel.loadMoreWithdrawIfNeeded(currentIndex: 7, api: api) + XCTAssertEqual(api.withdrawListPages, [1]) + await walletViewModel.loadMoreWithdrawIfNeeded(currentIndex: 8, api: api) + XCTAssertEqual(api.withdrawListPages, [1, 2]) + + await walletViewModel.selectTab(.transaction, api: api) + XCTAssertEqual(api.earningQueries.map(\.page), [1]) + await walletViewModel.refreshSelectedTab(api: api) + XCTAssertEqual(api.earningQueries.map(\.page), [1, 1]) + await walletViewModel.loadMoreTransactionIfNeeded(currentIndex: 8, api: api) + XCTAssertEqual(api.earningQueries.map(\.page), [1, 1]) + await walletViewModel.loadMoreTransactionIfNeeded(currentIndex: 9, api: api) + XCTAssertEqual(api.earningQueries.map(\.page), [1, 1, 2]) + + await pointsViewModel.refreshWithdrawList(api: api) + XCTAssertEqual(api.pointWithdrawListPages, [1]) + await pointsViewModel.loadMoreWithdrawListIfNeeded(currentIndex: 7, api: api) + XCTAssertEqual(api.pointWithdrawListPages, [1]) + await pointsViewModel.loadMoreWithdrawListIfNeeded(currentIndex: 8, api: api) + XCTAssertEqual(api.pointWithdrawListPages, [1, 2]) + } + func testWalletWithdrawDestinationFlow() async throws { let viewModel = WalletViewModel() @@ -201,6 +243,22 @@ final class WalletFeatureTests: XCTestCase { XCTAssertEqual(viewModel.countdown, 59) } + func testWithdrawAllAndSubmitFailureKeepsForm() async { + let api = FakeWalletPageService() + let viewModel = WithdrawViewModel() + await viewModel.load(api: api) + viewModel.withdrawAll() + viewModel.updateVerificationCode("123456") + api.shouldFailWithdrawApply = true + + let success = await viewModel.submit(api: api) + + XCTAssertFalse(success) + XCTAssertEqual(viewModel.amount, "50.00") + XCTAssertEqual(viewModel.verificationCode, "123456") + XCTAssertEqual(viewModel.errorMessage, "请求失败") + } + func testPointsRedemptionViewModelClampsAndRefreshes() async { let api = FakeWalletPageService() let viewModel = PointsRedemptionViewModel(staffIdProvider: { 9 }) @@ -218,6 +276,20 @@ final class WalletFeatureTests: XCTestCase { XCTAssertEqual(viewModel.withdrawRecords.count, 1) } + func testPointApplyFailureKeepsInputAndExistingRecords() async { + let api = FakeWalletPageService() + let viewModel = PointsRedemptionViewModel(staffIdProvider: { 9 }) + await viewModel.loadInitial(api: api) + viewModel.updatePointsInput("24") + api.shouldFailPointApply = true + + await viewModel.applyWithdraw(api: api) + + XCTAssertEqual(viewModel.pointsInput, "24") + XCTAssertEqual(viewModel.withdrawRecords.count, 1) + XCTAssertEqual(viewModel.errorMessage, "请求失败") + } + func testWalletTokensAndHomeIconMatchAndroid() { XCTAssertEqual(WalletTokens.pageBackground.walletHexRGB, 0xF5F6F8) XCTAssertEqual(WalletTokens.withdrawPageBackground.walletHexRGB, 0xF9FAFB) @@ -230,6 +302,32 @@ final class WalletFeatureTests: XCTestCase { XCTAssertNotNil(UIImage(named: "home_menu_wallet")) } + func testWalletAndPointStatusPresentationMatchesAndroid() { + XCTAssertEqual( + [10, 20, 30, 40, 50, 60].map { WalletWithdrawStatusDisplay(status: $0).progress }, + [10, 80, 100, 40, 100, 0] + ) + XCTAssertEqual( + [10, 20, 30, 40, 50, 60].map { WalletWithdrawStatusDisplay(status: $0).showsTimeline }, + [true, true, true, true, false, true] + ) + + let pending = PointWithdrawStatusDisplay(status: 1) + XCTAssertEqual(pending.title, "提现中") + XCTAssertEqual(pending.progress, 40) + XCTAssertEqual(pending.timeTitle, "预计到账时间:") + + let success = PointWithdrawStatusDisplay(status: 2) + XCTAssertEqual(success.title, "提现成功") + XCTAssertEqual(success.progress, 0) + XCTAssertEqual(success.timeTitle, "到账时间:") + + let failed = PointWithdrawStatusDisplay(status: 3) + XCTAssertEqual(failed.title, "提现失败") + XCTAssertEqual(failed.progress, 100) + XCTAssertEqual(failed.timeTitle, "审核时间:") + } + private static func envelope(_ dataJSON: String) -> Data { Data(#"{"code":100000,"msg":"success","data":\#(dataJSON)}"#.utf8) } @@ -252,11 +350,16 @@ final class WalletFeatureTests: XCTestCase { /// 钱包页面服务测试替身。 private final class FakeWalletPageService: WalletPageServing { private(set) var earningQueries: [(start: String, end: String, page: Int)] = [] + private(set) var withdrawListPages: [Int] = [] + private(set) var pointWithdrawListPages: [Int] = [] private(set) var withdrawApplyRequests: [(amount: String, smsCode: String)] = [] private(set) var pointApplyRequests: [Int] = [] + var pagedRecordTotal = 1 var shouldFailWithdrawList = false var shouldFailEarningDetail = false var shouldFailPointWithdrawList = false + var shouldFailWithdrawApply = false + var shouldFailPointApply = false func walletSummary(type: Int) async throws -> WalletSummaryResponse { WalletSummaryResponse(amountTotal: "100.00", amountCurrentBalance: "80.00", amountWithdrawable: "50.00") @@ -274,25 +377,32 @@ private final class FakeWalletPageService: WalletPageServing { func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse { if shouldFailWithdrawList { throw FakeWalletError.requestFailed } - WalletWithdrawListResponse( - total: 1, - item: [WalletWithdrawItem(id: page, amount: "10.00", createdAt: "2026-07-01", settlementStatus: 40, statusLabel: "打款中")] + withdrawListPages.append(page) + let count = pagedRecordTotal > 1 ? pageSize : 1 + return WalletWithdrawListResponse( + total: pagedRecordTotal, + item: (0 ..< count).map { + WalletWithdrawItem(id: page * 100 + $0, amount: "10.00", createdAt: "2026-07-01", settlementStatus: 40, statusLabel: "打款中") + } ) } func earningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> EarningDetailResponse { if shouldFailEarningDetail { throw FakeWalletError.requestFailed } earningQueries.append((startDate, endDate, page)) + let count = pagedRecordTotal > 1 ? pageSize : 1 return EarningDetailResponse( totalAmount: "12.30", totalPoints: 7, - total: 1, + total: pagedRecordTotal, list: [ EarningDetailGroup( - date: "2026-07-01", + date: "2026-07-\(String(format: "%02d", page))", dayAmount: "12.30", dayPoints: 7, - items: [EarningDetailItem(id: page, amount: "12.30", points: 7, typeLabel: "订单收益", createdAt: "10:00", withdrawLabel: "可提现")] + items: (0 ..< count).map { + EarningDetailItem(id: page * 100 + $0, amount: "12.30", points: 7, typeLabel: "订单收益", createdAt: "10:00", withdrawLabel: "可提现") + } ), ] ) @@ -314,6 +424,7 @@ private final class FakeWalletPageService: WalletPageServing { func withdrawSendSMS() async throws {} func withdrawApply(amount: String, smsCode: String) async throws { + if shouldFailWithdrawApply { throw FakeWalletError.requestFailed } withdrawApplyRequests.append((amount, smsCode)) } @@ -322,12 +433,20 @@ private final class FakeWalletPageService: WalletPageServing { } func pointWithdrawApply(points: Int, remark: String) async throws { + if shouldFailPointApply { throw FakeWalletError.requestFailed } pointApplyRequests.append(points) } func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse { if shouldFailPointWithdrawList { throw FakeWalletError.requestFailed } - PointWithdrawListResponse(total: 1, list: [PointWithdrawItem(id: page, points: 24, amount: 2, status: 1, createdAt: "2026-07-01")]) + pointWithdrawListPages.append(page) + let count = pagedRecordTotal > 1 ? pageSize : 1 + return PointWithdrawListResponse( + total: pagedRecordTotal, + list: (0 ..< count).map { + PointWithdrawItem(id: page * 100 + $0, points: 24, amount: 2, status: 1, createdAt: "2026-07-01") + } + ) } } diff --git a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift index 4d1465b..bbdcae4 100644 --- a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift +++ b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift @@ -548,6 +548,91 @@ final class WildPhotographerReportTests: XCTestCase { XCTAssertEqual(record.imageCount, 1) XCTAssertEqual(record.videoCount, 1) XCTAssertEqual(detail.supplementRecords.first?.summaryText, "补充说明 · 1张图片") + XCTAssertEqual(detail.supplementRecords.first?.evidences.first?.fileURL, "https://example.com/supplement.jpg") + } + + func testSupplementRecordsPreserveMixedMediaOrderAndSummary() throws { + let evidences = [ + WildReportDetailEvidence( + fileType: 1, + fileTypeText: "图片", + fileURL: "https://example.com/one.jpg", + fileName: "one.jpg" + ), + WildReportDetailEvidence( + fileType: 2, + fileTypeText: "视频", + fileURL: "https://example.com/two.mp4", + fileName: "two.mp4" + ), + WildReportDetailEvidence( + fileType: 5, + fileTypeText: "图片", + fileURL: "https://example.com/three.png", + fileName: "three.png" + ) + ] + let detail = WildReportDetailData( + supplements: [ + WildReportDetailContent( + type: "supplement", + id: 7, + desc: "补充现场材料", + time: "2026-07-13 10:00:00", + evidences: evidences + ) + ] + ) + + let record = try XCTUnwrap(detail.supplementRecords.first) + + XCTAssertEqual(record.evidences.map(\.fileType), [1, 2, 5]) + XCTAssertEqual(record.evidences.map(\.fileURL), [ + "https://example.com/one.jpg", + "https://example.com/two.mp4", + "https://example.com/three.png" + ]) + XCTAssertEqual(record.imageCount, 2) + XCTAssertEqual(record.videoCount, 1) + XCTAssertTrue(record.hasMedia) + XCTAssertEqual(record.summaryText, "补充现场材料 · 2张图片 · 1段视频") + } + + func testSupplementRecordsFallBackToReportContentsAndKeepMedia() throws { + let detail = WildReportDetailData( + supplements: [], + reportContents: [ + WildReportDetailContent( + type: "report", + id: 1, + desc: "初始举报", + time: "2026-07-13 09:00:00" + ), + WildReportDetailContent( + type: "supplement", + id: 2, + desc: "补充视频", + time: "2026-07-13 10:00:00", + evidences: [ + WildReportDetailEvidence( + fileType: 2, + fileTypeText: "视频", + fileURL: "https://example.com/fallback.mp4", + fileName: "fallback.mp4" + ) + ] + ) + ] + ) + + let record = try XCTUnwrap(detail.supplementRecords.first) + + XCTAssertEqual(detail.supplementRecords.count, 1) + XCTAssertEqual(record.id, "2") + XCTAssertEqual(record.evidences.map(\.fileURL), ["https://example.com/fallback.mp4"]) + XCTAssertEqual(record.imageCount, 0) + XCTAssertEqual(record.videoCount, 1) + XCTAssertEqual(record.summaryText, "补充视频 · 1段视频") } func testSupplementRecordSequenceTitleStartsFromDisplayOrder() {