From 07b2b9d4597c2ae2e89c374937564e9c24764412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 13 Jul 2026 15:14:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=A0=B7=E7=89=87=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E8=A7=A3=E6=9E=90=E5=B9=B6=E4=BC=98=E5=8C=96=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E4=B8=8A=E4=B8=8B=E6=9E=B6=E4=BA=A4=E4=BA=92=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 兼容 Android 媒体项 size 字段格式,增强灵活解码容错,并统一样片列表开关确认逻辑与安全区布局。 Co-authored-by: Cursor --- .../Models/SampleManagementModels.swift | 35 +++++++++++-- .../SampleManagementViewModels.swift | 2 + .../SampleDetailViewController.swift | 2 +- .../SampleListViewController.swift | 50 ++++--------------- .../UploadSampleViewController.swift | 10 +++- suixinkanTests/SampleManagementAPITests.swift | 20 ++++++++ .../SampleManagementViewModelTests.swift | 24 ++++++++- 7 files changed, 96 insertions(+), 47 deletions(-) diff --git a/suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift b/suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift index 70c08c3..62a13d9 100644 --- a/suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift +++ b/suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift @@ -329,6 +329,31 @@ struct SampleDetailMediaItem: Decodable, Sendable, Equatable, Hashable, Identifi case height } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0 + originalName = try container.decodeIfPresent(String.self, forKey: .originalName) + paidMaterialId = try container.decodeIfPresent(Int.self, forKey: .paidMaterialId) + ossUrl = try container.decodeIfPresent(String.self, forKey: .ossUrl) + thumbnailUrl = try container.decodeIfPresent(String.self, forKey: .thumbnailUrl) + type = try container.decodeIfPresent(Int.self, forKey: .type) ?? 0 + if let numericSize = try? container.decode(Int64.self, forKey: .size) { + size = numericSize + } else if let stringSize = try? container.decode(String.self, forKey: .size) { + size = Int64(stringSize) + } else { + size = nil + } + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) + updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt) + deletedAt = try container.decodeIfPresent(String.self, forKey: .deletedAt) + likesCount = try container.decodeIfPresent(Int.self, forKey: .likesCount) ?? 0 + downloadCount = try container.decodeIfPresent(Int.self, forKey: .downloadCount) ?? 0 + showUrl = try container.decodeIfPresent(String.self, forKey: .showUrl) + width = try container.decodeIfPresent(Int.self, forKey: .width) + height = try container.decodeIfPresent(Int.self, forKey: .height) + } + /// 详情轮播优先展示地址。 var displayURL: String { (ossUrl?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) @@ -558,23 +583,23 @@ enum SampleDisplayFormatter { private extension KeyedDecodingContainer { func decodeFlexibleDouble(forKey key: Key) throws -> Double? { - if let value = try decodeIfPresent(Double.self, forKey: key) { + if let value = try? decode(Double.self, forKey: key) { return value } - if let string = try decodeIfPresent(String.self, forKey: key) { + if let string = try? decode(String.self, forKey: key) { return Double(string) } return nil } func decodeFlexibleString(forKey key: Key) throws -> String? { - if let value = try decodeIfPresent(String.self, forKey: key) { + if let value = try? decode(String.self, forKey: key) { return value } - if let value = try decodeIfPresent(Double.self, forKey: key) { + if let value = try? decode(Double.self, forKey: key) { return String(value) } - if let value = try decodeIfPresent(Int.self, forKey: key) { + if let value = try? decode(Int.self, forKey: key) { return String(value) } return nil diff --git a/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift b/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift index fd27fbf..7eff8ed 100644 --- a/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift +++ b/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift @@ -503,6 +503,7 @@ final class UploadSampleViewModel { guard let self else { return } Task { @MainActor in guard self.uploadedMediaList.indices.contains(index) else { return } + guard self.uploadedMediaList[index].isUploading else { return } self.uploadedMediaList[index].uploadProgress = progress self.uploadedMediaList[index].isUploading = progress < 100 self.uploadDialogState = SampleUploadDialogState( @@ -552,6 +553,7 @@ final class UploadSampleViewModel { guard let self else { return } Task { @MainActor in guard var current = self.coverImage else { return } + guard current.isUploading else { return } current.uploadProgress = progress current.isUploading = progress < 100 self.coverImage = current diff --git a/suixinkan/UI/SampleManagement/SampleDetailViewController.swift b/suixinkan/UI/SampleManagement/SampleDetailViewController.swift index bb1fa85..fe1c1ea 100644 --- a/suixinkan/UI/SampleManagement/SampleDetailViewController.swift +++ b/suixinkan/UI/SampleManagement/SampleDetailViewController.swift @@ -68,7 +68,7 @@ final class SampleDetailViewController: SampleBaseViewController { override func setupConstraints() { scrollView.snp.makeConstraints { make in - make.top.equalToSuperview() + make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide) } contentStack.snp.makeConstraints { make in diff --git a/suixinkan/UI/SampleManagement/SampleListViewController.swift b/suixinkan/UI/SampleManagement/SampleListViewController.swift index 25be5b9..b36801a 100644 --- a/suixinkan/UI/SampleManagement/SampleListViewController.swift +++ b/suixinkan/UI/SampleManagement/SampleListViewController.swift @@ -97,15 +97,10 @@ final class SampleListViewController: SampleBaseViewController { withIdentifier: SampleListCell.reuseIdentifier, for: indexPath ) as! SampleListCell - cell.apply(item: item, isOperating: self?.viewModel.operatingSampleIDs.contains(item.id) == true) - cell.onToggle = { [weak self, weak cell] checked in + cell.apply(item: item) + cell.onToggle = { [weak self] checked in guard let self else { return } - Task { - let resolved = await self.viewModel.toggleListing(sampleId: item.id, checked: checked, api: self.api) - await MainActor.run { - cell?.resolveToggle(sampleId: item.id, isOn: resolved) - } - } + Task { await self.viewModel.toggleListing(sampleId: item.id, checked: checked, api: self.api) } } return cell } @@ -142,7 +137,7 @@ final class SampleListViewController: SampleBaseViewController { override func setupConstraints() { searchContainer.snp.makeConstraints { make in - make.top.equalToSuperview() + make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalToSuperview() make.height.equalTo(68) } @@ -349,13 +344,12 @@ private final class SampleListCell: UITableViewCell { private let scenicSpotLabel = UILabel() private let statusBadge = SampleStatusBadgeView() private let listingSwitch = UISwitch() - private let listingActivityIndicator = UIActivityIndicatorView(style: .medium) private let statsStack = UIStackView() private let shareStatView = SampleStatView(iconName: "sample_ic_share_count") private let likeStatView = SampleStatView(iconName: "sample_ic_like_count") private let collectStatView = SampleStatView(iconName: "sample_ic_collect_count") private var isApplying = false - private var representedSampleID: Int? + private var confirmedListingState = false var onToggle: ((Bool) -> Void)? @@ -371,21 +365,17 @@ private final class SampleListCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() - representedSampleID = nil onToggle = nil } - func apply(item: SampleMaterialItem, isOperating: Bool) { + func apply(item: SampleMaterialItem) { isApplying = true - representedSampleID = item.id + confirmedListingState = item.listingStatus == 1 titleLabel.text = item.name projectLabel.text = "项目: \(item.projectName)" scenicSpotLabel.text = "打卡地: \(item.scenicSpotName)" statusBadge.apply(status: item.status) - listingSwitch.setOn(item.listingStatus == 1, animated: false) - listingSwitch.isEnabled = item.status == 1 && !isOperating - listingSwitch.isHidden = isOperating - isOperating ? listingActivityIndicator.startAnimating() : listingActivityIndicator.stopAnimating() + listingSwitch.setOn(confirmedListingState, animated: false) shareStatView.setCount(item.shareCount) likeStatView.setCount(item.likesCount) collectStatView.setCount(item.collectCount) @@ -400,17 +390,6 @@ private final class SampleListCell: UITableViewCell { isApplying = false } - /// 请求结束后恢复服务端确认的开关状态,避免审核拒绝或接口失败时停留在错误位置。 - func resolveToggle(sampleId: Int, isOn: Bool) { - guard representedSampleID == sampleId else { return } - isApplying = true - listingSwitch.setOn(isOn, animated: true) - listingSwitch.isEnabled = true - listingSwitch.isHidden = false - listingActivityIndicator.stopAnimating() - isApplying = false - } - private func setupUI() { selectionStyle = .none backgroundColor = SampleManagementUITokens.pageBackground @@ -443,8 +422,6 @@ private final class SampleListCell: UITableViewCell { listingSwitch.onTintColor = AppColor.primary listingSwitch.accessibilityLabel = "样片上下架" listingSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged) - listingActivityIndicator.color = AppColor.primary - listingActivityIndicator.hidesWhenStopped = true statsStack.axis = .horizontal statsStack.spacing = 16 @@ -458,7 +435,6 @@ private final class SampleListCell: UITableViewCell { cardView.addSubview(scenicSpotLabel) cardView.addSubview(statusBadge) cardView.addSubview(listingSwitch) - cardView.addSubview(listingActivityIndicator) cardView.addSubview(statsStack) [shareStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview) @@ -498,9 +474,6 @@ private final class SampleListCell: UITableViewCell { make.trailing.equalToSuperview().inset(12) make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8) } - listingActivityIndicator.snp.makeConstraints { make in - make.center.equalTo(listingSwitch) - } statsStack.snp.makeConstraints { make in make.top.equalTo(statusBadge.snp.bottom).offset(10) make.leading.equalTo(titleLabel) @@ -511,10 +484,9 @@ private final class SampleListCell: UITableViewCell { @objc private func switchChanged() { guard !isApplying else { return } - listingSwitch.isEnabled = false - listingSwitch.isHidden = true - listingActivityIndicator.startAnimating() - onToggle?(listingSwitch.isOn) + let requestedState = listingSwitch.isOn + listingSwitch.setOn(confirmedListingState, animated: true) + onToggle?(requestedState) } } diff --git a/suixinkan/UI/SampleManagement/UploadSampleViewController.swift b/suixinkan/UI/SampleManagement/UploadSampleViewController.swift index 3e7020b..9a472b1 100644 --- a/suixinkan/UI/SampleManagement/UploadSampleViewController.swift +++ b/suixinkan/UI/SampleManagement/UploadSampleViewController.swift @@ -138,7 +138,7 @@ final class UploadSampleViewController: SampleBaseViewController { make.trailing.equalToSuperview().inset(15) } scrollView.snp.makeConstraints { make in - make.top.equalToSuperview() + make.top.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.bottom.equalTo(bottomBar.snp.top) } @@ -698,6 +698,10 @@ private final class SampleUploadThumbView: UIControl { deleteButton.layer.cornerRadius = 10 playIcon.tintColor = .white playIcon.isHidden = true + [imageView, addCircle, addIcon, textLabel, playIcon].forEach { + $0.isUserInteractionEnabled = false + } + accessibilityLabel = mediaType == .image ? "上传图片" : "上传视频" addSubview(imageView) addSubview(addCircle) @@ -828,6 +832,10 @@ private final class SampleCoverUploadView: UIControl { hintLabel.font = .systemFont(ofSize: 12) hintLabel.textColor = UIColor(hex: 0xB6BECA) hintLabel.textAlignment = .center + [imageView, addCircle, addIcon, titleLabel, hintLabel].forEach { + $0.isUserInteractionEnabled = false + } + accessibilityLabel = "上传封面图片" deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal) deleteButton.tintColor = .white deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6) diff --git a/suixinkanTests/SampleManagementAPITests.swift b/suixinkanTests/SampleManagementAPITests.swift index 9a2374d..dc45455 100644 --- a/suixinkanTests/SampleManagementAPITests.swift +++ b/suixinkanTests/SampleManagementAPITests.swift @@ -70,6 +70,26 @@ final class SampleManagementAPITests: XCTestCase { XCTAssertEqual(response.mediaList?.first?.resolvedType(fallback: response.type ?? 1), 2) } + func testDetailDecodesUploadedAlbumMediaWithoutItemTypeOrCounters() async throws { + let detail = envelopeJSON( + #"{"id":113,"name":"样片相册iOS测试","type":1,"status":0,"listing_status":0,"project_price":0.01,"project_price_deposit":"0.00","media_list":[{"id":392,"original_name":"IMG_2741.jpeg","oss_url":"https://cdn/IMG_2741.jpeg","show_url":"https://cdn/IMG_274_cover.jpeg","thumbnail_url":"https://cdn/thumb.jpeg","size":285534,"width":828,"height":1792,"created_at":"2026-07-13 15:06:26","updated_at":"2026-07-13 15:06:26"}]}"# + ) + let session = MockURLSession(responses: [detail]) + let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.detail(id: 113) + let media = try XCTUnwrap(response.mediaList?.first) + + XCTAssertEqual(response.projectPrice, 0.01) + XCTAssertEqual(media.type, 0) + XCTAssertEqual(media.likesCount, 0) + XCTAssertEqual(media.downloadCount, 0) + XCTAssertEqual(media.size, 285_534) + XCTAssertEqual(media.width, 828) + XCTAssertEqual(media.height, 1_792) + XCTAssertEqual(media.resolvedType(fallback: response.type ?? 1), 1) + } + func testUploadEncodesAndroidBodyKeys() async throws { let session = MockURLSession(responses: [envelopeJSON("{}")]) let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session)) diff --git a/suixinkanTests/SampleManagementViewModelTests.swift b/suixinkanTests/SampleManagementViewModelTests.swift index eae1c39..0c669c4 100644 --- a/suixinkanTests/SampleManagementViewModelTests.swift +++ b/suixinkanTests/SampleManagementViewModelTests.swift @@ -187,6 +187,19 @@ final class SampleManagementViewModelTests: XCTestCase { XCTAssertNil(viewModel.uploadDialogState) } + func testLateProgressCallbackCannotReopenCompletedUploadDialog() async { + let viewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 }) + let uploader = MockSampleUploader(urls: ["https://cdn/a.jpg"]) + uploader.emitLateProgress = true + + await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader) + try? await Task.sleep(nanoseconds: 1_000_000) + + XCTAssertEqual(viewModel.uploadedMediaList.first?.ossUrl, "https://cdn/a.jpg") + XCTAssertFalse(viewModel.uploadedMediaList.first?.isUploading ?? true) + XCTAssertNil(viewModel.uploadDialogState) + } + func testUploadValidationMessages() async { let api = MockSampleAPI() let viewModel = UploadSampleViewModel( @@ -324,6 +337,7 @@ private final class MockSampleAPI: SampleManagementServing { @MainActor private final class MockSampleUploader: SampleOSSUploading { private var results: [Result] + var emitLateProgress = false init(urls: [String]) { results = urls.map(Result.success) @@ -340,7 +354,15 @@ private final class MockSampleUploader: SampleOSSUploading { scenicId: Int, onProgress: @escaping (Int) -> Void ) async throws -> String { - onProgress(100) + if emitLateProgress { + onProgress(20) + Task { @MainActor in + await Task.yield() + onProgress(100) + } + } else { + onProgress(100) + } guard !results.isEmpty else { return "https://cdn/default.jpg" } return try results.removeFirst().get() }