修复样片详情解析并优化列表上下架交互。

兼容 Android 媒体项 size 字段格式,增强灵活解码容错,并统一样片列表开关确认逻辑与安全区布局。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 15:14:40 +08:00
parent afcd412f11
commit 07b2b9d459
7 changed files with 96 additions and 47 deletions

View File

@ -329,6 +329,31 @@ struct SampleDetailMediaItem: Decodable, Sendable, Equatable, Hashable, Identifi
case height 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 { var displayURL: String {
(ossUrl?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) (ossUrl?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty)
@ -558,23 +583,23 @@ enum SampleDisplayFormatter {
private extension KeyedDecodingContainer { private extension KeyedDecodingContainer {
func decodeFlexibleDouble(forKey key: Key) throws -> Double? { 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 return value
} }
if let string = try decodeIfPresent(String.self, forKey: key) { if let string = try? decode(String.self, forKey: key) {
return Double(string) return Double(string)
} }
return nil return nil
} }
func decodeFlexibleString(forKey key: Key) throws -> String? { 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 return value
} }
if let value = try decodeIfPresent(Double.self, forKey: key) { if let value = try? decode(Double.self, forKey: key) {
return String(value) 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 String(value)
} }
return nil return nil

View File

@ -503,6 +503,7 @@ final class UploadSampleViewModel {
guard let self else { return } guard let self else { return }
Task { @MainActor in Task { @MainActor in
guard self.uploadedMediaList.indices.contains(index) else { return } guard self.uploadedMediaList.indices.contains(index) else { return }
guard self.uploadedMediaList[index].isUploading else { return }
self.uploadedMediaList[index].uploadProgress = progress self.uploadedMediaList[index].uploadProgress = progress
self.uploadedMediaList[index].isUploading = progress < 100 self.uploadedMediaList[index].isUploading = progress < 100
self.uploadDialogState = SampleUploadDialogState( self.uploadDialogState = SampleUploadDialogState(
@ -552,6 +553,7 @@ final class UploadSampleViewModel {
guard let self else { return } guard let self else { return }
Task { @MainActor in Task { @MainActor in
guard var current = self.coverImage else { return } guard var current = self.coverImage else { return }
guard current.isUploading else { return }
current.uploadProgress = progress current.uploadProgress = progress
current.isUploading = progress < 100 current.isUploading = progress < 100
self.coverImage = current self.coverImage = current

View File

@ -68,7 +68,7 @@ final class SampleDetailViewController: SampleBaseViewController {
override func setupConstraints() { override func setupConstraints() {
scrollView.snp.makeConstraints { make in scrollView.snp.makeConstraints { make in
make.top.equalToSuperview() make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
} }
contentStack.snp.makeConstraints { make in contentStack.snp.makeConstraints { make in

View File

@ -97,15 +97,10 @@ final class SampleListViewController: SampleBaseViewController {
withIdentifier: SampleListCell.reuseIdentifier, withIdentifier: SampleListCell.reuseIdentifier,
for: indexPath for: indexPath
) as! SampleListCell ) as! SampleListCell
cell.apply(item: item, isOperating: self?.viewModel.operatingSampleIDs.contains(item.id) == true) cell.apply(item: item)
cell.onToggle = { [weak self, weak cell] checked in cell.onToggle = { [weak self] checked in
guard let self else { return } guard let self else { return }
Task { Task { await self.viewModel.toggleListing(sampleId: item.id, checked: checked, api: self.api) }
let resolved = await self.viewModel.toggleListing(sampleId: item.id, checked: checked, api: self.api)
await MainActor.run {
cell?.resolveToggle(sampleId: item.id, isOn: resolved)
}
}
} }
return cell return cell
} }
@ -142,7 +137,7 @@ final class SampleListViewController: SampleBaseViewController {
override func setupConstraints() { override func setupConstraints() {
searchContainer.snp.makeConstraints { make in searchContainer.snp.makeConstraints { make in
make.top.equalToSuperview() make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview() make.leading.trailing.equalToSuperview()
make.height.equalTo(68) make.height.equalTo(68)
} }
@ -349,13 +344,12 @@ private final class SampleListCell: UITableViewCell {
private let scenicSpotLabel = UILabel() private let scenicSpotLabel = UILabel()
private let statusBadge = SampleStatusBadgeView() private let statusBadge = SampleStatusBadgeView()
private let listingSwitch = UISwitch() private let listingSwitch = UISwitch()
private let listingActivityIndicator = UIActivityIndicatorView(style: .medium)
private let statsStack = UIStackView() private let statsStack = UIStackView()
private let shareStatView = SampleStatView(iconName: "sample_ic_share_count") private let shareStatView = SampleStatView(iconName: "sample_ic_share_count")
private let likeStatView = SampleStatView(iconName: "sample_ic_like_count") private let likeStatView = SampleStatView(iconName: "sample_ic_like_count")
private let collectStatView = SampleStatView(iconName: "sample_ic_collect_count") private let collectStatView = SampleStatView(iconName: "sample_ic_collect_count")
private var isApplying = false private var isApplying = false
private var representedSampleID: Int? private var confirmedListingState = false
var onToggle: ((Bool) -> Void)? var onToggle: ((Bool) -> Void)?
@ -371,21 +365,17 @@ private final class SampleListCell: UITableViewCell {
override func prepareForReuse() { override func prepareForReuse() {
super.prepareForReuse() super.prepareForReuse()
representedSampleID = nil
onToggle = nil onToggle = nil
} }
func apply(item: SampleMaterialItem, isOperating: Bool) { func apply(item: SampleMaterialItem) {
isApplying = true isApplying = true
representedSampleID = item.id confirmedListingState = item.listingStatus == 1
titleLabel.text = item.name titleLabel.text = item.name
projectLabel.text = "项目: \(item.projectName)" projectLabel.text = "项目: \(item.projectName)"
scenicSpotLabel.text = "打卡地: \(item.scenicSpotName)" scenicSpotLabel.text = "打卡地: \(item.scenicSpotName)"
statusBadge.apply(status: item.status) statusBadge.apply(status: item.status)
listingSwitch.setOn(item.listingStatus == 1, animated: false) listingSwitch.setOn(confirmedListingState, animated: false)
listingSwitch.isEnabled = item.status == 1 && !isOperating
listingSwitch.isHidden = isOperating
isOperating ? listingActivityIndicator.startAnimating() : listingActivityIndicator.stopAnimating()
shareStatView.setCount(item.shareCount) shareStatView.setCount(item.shareCount)
likeStatView.setCount(item.likesCount) likeStatView.setCount(item.likesCount)
collectStatView.setCount(item.collectCount) collectStatView.setCount(item.collectCount)
@ -400,17 +390,6 @@ private final class SampleListCell: UITableViewCell {
isApplying = false 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() { private func setupUI() {
selectionStyle = .none selectionStyle = .none
backgroundColor = SampleManagementUITokens.pageBackground backgroundColor = SampleManagementUITokens.pageBackground
@ -443,8 +422,6 @@ private final class SampleListCell: UITableViewCell {
listingSwitch.onTintColor = AppColor.primary listingSwitch.onTintColor = AppColor.primary
listingSwitch.accessibilityLabel = "样片上下架" listingSwitch.accessibilityLabel = "样片上下架"
listingSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged) listingSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged)
listingActivityIndicator.color = AppColor.primary
listingActivityIndicator.hidesWhenStopped = true
statsStack.axis = .horizontal statsStack.axis = .horizontal
statsStack.spacing = 16 statsStack.spacing = 16
@ -458,7 +435,6 @@ private final class SampleListCell: UITableViewCell {
cardView.addSubview(scenicSpotLabel) cardView.addSubview(scenicSpotLabel)
cardView.addSubview(statusBadge) cardView.addSubview(statusBadge)
cardView.addSubview(listingSwitch) cardView.addSubview(listingSwitch)
cardView.addSubview(listingActivityIndicator)
cardView.addSubview(statsStack) cardView.addSubview(statsStack)
[shareStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview) [shareStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview)
@ -498,9 +474,6 @@ private final class SampleListCell: UITableViewCell {
make.trailing.equalToSuperview().inset(12) make.trailing.equalToSuperview().inset(12)
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8) make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
} }
listingActivityIndicator.snp.makeConstraints { make in
make.center.equalTo(listingSwitch)
}
statsStack.snp.makeConstraints { make in statsStack.snp.makeConstraints { make in
make.top.equalTo(statusBadge.snp.bottom).offset(10) make.top.equalTo(statusBadge.snp.bottom).offset(10)
make.leading.equalTo(titleLabel) make.leading.equalTo(titleLabel)
@ -511,10 +484,9 @@ private final class SampleListCell: UITableViewCell {
@objc private func switchChanged() { @objc private func switchChanged() {
guard !isApplying else { return } guard !isApplying else { return }
listingSwitch.isEnabled = false let requestedState = listingSwitch.isOn
listingSwitch.isHidden = true listingSwitch.setOn(confirmedListingState, animated: true)
listingActivityIndicator.startAnimating() onToggle?(requestedState)
onToggle?(listingSwitch.isOn)
} }
} }

View File

@ -138,7 +138,7 @@ final class UploadSampleViewController: SampleBaseViewController {
make.trailing.equalToSuperview().inset(15) make.trailing.equalToSuperview().inset(15)
} }
scrollView.snp.makeConstraints { make in scrollView.snp.makeConstraints { make in
make.top.equalToSuperview() make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top) make.bottom.equalTo(bottomBar.snp.top)
} }
@ -698,6 +698,10 @@ private final class SampleUploadThumbView: UIControl {
deleteButton.layer.cornerRadius = 10 deleteButton.layer.cornerRadius = 10
playIcon.tintColor = .white playIcon.tintColor = .white
playIcon.isHidden = true playIcon.isHidden = true
[imageView, addCircle, addIcon, textLabel, playIcon].forEach {
$0.isUserInteractionEnabled = false
}
accessibilityLabel = mediaType == .image ? "上传图片" : "上传视频"
addSubview(imageView) addSubview(imageView)
addSubview(addCircle) addSubview(addCircle)
@ -828,6 +832,10 @@ private final class SampleCoverUploadView: UIControl {
hintLabel.font = .systemFont(ofSize: 12) hintLabel.font = .systemFont(ofSize: 12)
hintLabel.textColor = UIColor(hex: 0xB6BECA) hintLabel.textColor = UIColor(hex: 0xB6BECA)
hintLabel.textAlignment = .center hintLabel.textAlignment = .center
[imageView, addCircle, addIcon, titleLabel, hintLabel].forEach {
$0.isUserInteractionEnabled = false
}
accessibilityLabel = "上传封面图片"
deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal) deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
deleteButton.tintColor = .white deleteButton.tintColor = .white
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6) deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)

View File

@ -70,6 +70,26 @@ final class SampleManagementAPITests: XCTestCase {
XCTAssertEqual(response.mediaList?.first?.resolvedType(fallback: response.type ?? 1), 2) 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 { func testUploadEncodesAndroidBodyKeys() async throws {
let session = MockURLSession(responses: [envelopeJSON("{}")]) let session = MockURLSession(responses: [envelopeJSON("{}")])
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session)) let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))

View File

@ -187,6 +187,19 @@ final class SampleManagementViewModelTests: XCTestCase {
XCTAssertNil(viewModel.uploadDialogState) 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 { func testUploadValidationMessages() async {
let api = MockSampleAPI() let api = MockSampleAPI()
let viewModel = UploadSampleViewModel( let viewModel = UploadSampleViewModel(
@ -324,6 +337,7 @@ private final class MockSampleAPI: SampleManagementServing {
@MainActor @MainActor
private final class MockSampleUploader: SampleOSSUploading { private final class MockSampleUploader: SampleOSSUploading {
private var results: [Result<String, Error>] private var results: [Result<String, Error>]
var emitLateProgress = false
init(urls: [String]) { init(urls: [String]) {
results = urls.map(Result.success) results = urls.map(Result.success)
@ -340,7 +354,15 @@ private final class MockSampleUploader: SampleOSSUploading {
scenicId: Int, scenicId: Int,
onProgress: @escaping (Int) -> Void onProgress: @escaping (Int) -> Void
) async throws -> String { ) async throws -> String {
if emitLateProgress {
onProgress(20)
Task { @MainActor in
await Task.yield()
onProgress(100) onProgress(100)
}
} else {
onProgress(100)
}
guard !results.isEmpty else { return "https://cdn/default.jpg" } guard !results.isEmpty else { return "https://cdn/default.jpg" }
return try results.removeFirst().get() return try results.removeFirst().get()
} }