diff --git a/suixinkan/Features/MaterialManagement/Models/MaterialManagementModels.swift b/suixinkan/Features/MaterialManagement/Models/MaterialManagementModels.swift index 4d2d122..972852a 100644 --- a/suixinkan/Features/MaterialManagement/Models/MaterialManagementModels.swift +++ b/suixinkan/Features/MaterialManagement/Models/MaterialManagementModels.swift @@ -121,6 +121,22 @@ struct MaterialItem: Decodable, Sendable, Equatable, Hashable, Identifiable { self.projectName = projectName self.listingStatus = listingStatus } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0 + name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + coverUrl = try container.decodeIfPresent(String.self, forKey: .coverUrl) ?? "" + createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? "" + status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0 + downloadCount = try container.decodeIfPresent(Int.self, forKey: .downloadCount) ?? 0 + likesCount = try container.decodeIfPresent(Int.self, forKey: .likesCount) ?? 0 + collectCount = try container.decodeIfPresent(Int.self, forKey: .collectCount) ?? 0 + shareCount = try container.decodeIfPresent(Int.self, forKey: .shareCount) ?? 0 + scenicSpotName = try container.decodeIfPresent(String.self, forKey: .scenicSpotName) ?? "" + projectName = try container.decodeIfPresent(String.self, forKey: .projectName) ?? "" + listingStatus = try container.decodeIfPresent(Int.self, forKey: .listingStatus) ?? 0 + } } /// 素材列表响应,对齐 Android `MaterialListResponse`。 diff --git a/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift b/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift index c7fc3cd..b691f4a 100644 --- a/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift +++ b/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift @@ -49,7 +49,7 @@ final class MaterialDetailViewController: BaseViewController { override func setupUI() { view.backgroundColor = UIColor(hex: 0xF4F4F4) contentStack.axis = .vertical - contentStack.spacing = 12 + contentStack.spacing = 0 scrollView.backgroundColor = UIColor(hex: 0xF4F4F4) bottomBar.backgroundColor = .white @@ -60,8 +60,11 @@ final class MaterialDetailViewController: BaseViewController { scrollView.addSubview(contentStack) contentStack.addArrangedSubview(carouselView) contentStack.addArrangedSubview(infoSection) + contentStack.addArrangedSubview(makeVerticalSpacer(height: 12)) contentStack.addArrangedSubview(uploaderSection) + contentStack.addArrangedSubview(makeVerticalSpacer(height: 12)) contentStack.addArrangedSubview(reviewSection) + contentStack.addArrangedSubview(makeVerticalSpacer(height: 16)) view.addSubview(bottomBar) bottomBar.addSubview(deleteButton) bottomBar.addSubview(editButton) @@ -131,6 +134,15 @@ final class MaterialDetailViewController: BaseViewController { button.layer.cornerRadius = 8 } + private func makeVerticalSpacer(height: CGFloat) -> UIView { + let spacer = UIView() + spacer.backgroundColor = .clear + spacer.snp.makeConstraints { make in + make.height.equalTo(height) + } + return spacer + } + @MainActor private func applyViewModel() { if (viewModel.isLoading || viewModel.isDeleting) && viewModel.detail == nil { @@ -470,6 +482,7 @@ private final class MaterialDetailInfoSection: UIView { private let downloadStat = MaterialDetailStatView(iconName: "material_ic_download_count") private let likeStat = MaterialDetailStatView(iconName: "sample_ic_like_count") private let collectStat = MaterialDetailStatView(iconName: "sample_ic_collect_count") + private var statsTopConstraint: Constraint? override init(frame: CGRect) { super.init(frame: frame) @@ -483,7 +496,7 @@ private final class MaterialDetailInfoSection: UIView { listingBadge.textAlignment = .center tagStack.axis = .horizontal tagStack.spacing = 8 - tagStack.alignment = .leading + tagStack.alignment = .center statsStack.axis = .horizontal statsStack.spacing = 16 [downloadStat, likeStat, collectStat].forEach(statsStack.addArrangedSubview) @@ -507,7 +520,7 @@ private final class MaterialDetailInfoSection: UIView { make.leading.trailing.equalToSuperview().inset(16) } statsStack.snp.makeConstraints { make in - make.top.equalTo(tagStack.snp.bottom).offset(12) + statsTopConstraint = make.top.equalTo(tagStack.snp.bottom).offset(12).constraint make.leading.equalToSuperview().offset(16) make.trailing.lessThanOrEqualToSuperview().inset(16) make.bottom.equalToSuperview().inset(16) @@ -527,14 +540,20 @@ private final class MaterialDetailInfoSection: UIView { listingBadge.textColor = color listingBadge.backgroundColor = color.withAlphaComponent(0.1) tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() } - (detail.materialTag ?? []).forEach { tagStack.addArrangedSubview(makeTag($0.name)) } + let tags = detail.materialTag ?? [] + tags.forEach { tagStack.addArrangedSubview(makeTag($0.name)) } + if !tags.isEmpty { + tagStack.addArrangedSubview(UIView()) + } + tagStack.isHidden = tags.isEmpty + statsTopConstraint?.update(offset: tags.isEmpty ? 0 : 12) downloadStat.setCount(detail.downloadCount) likeStat.setCount(detail.likesCount) collectStat.setCount(detail.collectCount) } private func makeTag(_ title: String) -> UILabel { - let label = UILabel() + let label = MaterialDetailTagLabel(horizontalInset: 6) label.text = title label.font = .systemFont(ofSize: 12) label.textColor = AppColor.primary @@ -542,9 +561,10 @@ private final class MaterialDetailInfoSection: UIView { label.backgroundColor = UIColor(hex: 0xEFF6FF) label.layer.cornerRadius = 4 label.clipsToBounds = true + label.setContentHuggingPriority(.required, for: .horizontal) + label.setContentCompressionResistancePriority(.required, for: .horizontal) label.snp.makeConstraints { make in make.height.equalTo(22) - make.width.greaterThanOrEqualTo(40) } return label } @@ -632,7 +652,7 @@ private final class MaterialDetailUploaderSection: UIView { } private func makeTag(_ title: String) -> UILabel { - let label = UILabel() + let label = MaterialDetailTagLabel(horizontalInset: 6) label.text = title label.textColor = .white label.font = .systemFont(ofSize: 8) @@ -642,12 +662,35 @@ private final class MaterialDetailUploaderSection: UIView { label.clipsToBounds = true label.snp.makeConstraints { make in make.height.equalTo(16) - make.width.greaterThanOrEqualTo(44) } return label } } +/// 为详情页标签文本提供固定水平内边距,使背景宽度随内容自适应。 +private final class MaterialDetailTagLabel: UILabel { + private let horizontalInset: CGFloat + + init(horizontalInset: CGFloat) { + self.horizontalInset = horizontalInset + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func drawText(in rect: CGRect) { + super.drawText(in: rect.insetBy(dx: horizontalInset, dy: 0)) + } + + override var intrinsicContentSize: CGSize { + let size = super.intrinsicContentSize + return CGSize(width: size.width + horizontalInset * 2, height: size.height) + } +} + /// 素材审核信息区。 /// 素材详情的审核信息区块。 private final class MaterialDetailReviewSection: UIView { diff --git a/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift index cd6e704..758d60d 100644 --- a/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift +++ b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift @@ -972,7 +972,7 @@ private final class MaterialTagEditorView: UIView { stack.spacing = 8 tagsStack.axis = .horizontal tagsStack.spacing = 8 - tagsStack.alignment = .leading + tagsStack.alignment = .center inputRow.axis = .horizontal inputRow.spacing = 8 textField.font = .systemFont(ofSize: 14) @@ -1027,6 +1027,8 @@ private final class MaterialTagEditorView: UIView { button.backgroundColor = UIColor(hex: 0xEFF6FF) button.layer.cornerRadius = 4 button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) button.snp.makeConstraints { make in make.height.equalTo(28) } @@ -1035,6 +1037,10 @@ private final class MaterialTagEditorView: UIView { }, for: .touchUpInside) tagsStack.addArrangedSubview(button) } + if !tags.isEmpty { + // Android Row 中标签按内容宽度排列;尾部弹性占位吸收剩余空间。 + tagsStack.addArrangedSubview(UIView()) + } tagsStack.isHidden = tags.isEmpty } diff --git a/suixinkan/UI/MaterialManagement/MaterialListViewController.swift b/suixinkan/UI/MaterialManagement/MaterialListViewController.swift index f10e44e..c28b720 100644 --- a/suixinkan/UI/MaterialManagement/MaterialListViewController.swift +++ b/suixinkan/UI/MaterialManagement/MaterialListViewController.swift @@ -229,7 +229,7 @@ final class MaterialListViewController: BaseViewController { @MainActor private func applyViewModel() { - filterTitleLabel.text = "筛选" + filterTitleLabel.text = viewModel.filterStatus.title filterOptionButtons.forEach { status, button in let selected = status == viewModel.filterStatus button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal) @@ -436,6 +436,7 @@ private final class MaterialListCell: UITableViewCell { private let cardView = UIView() private let coverImageView = UIImageView() private let placeholderLabel = UILabel() + private let detailContentView = UIView() private let titleLabel = UILabel() private let timeLabel = UILabel() private let statusBadge = MaterialStatusBadgeView() @@ -445,6 +446,7 @@ private final class MaterialListCell: UITableViewCell { private let likeStatView = MaterialStatView(iconName: "sample_ic_like_count") private let collectStatView = MaterialStatView(iconName: "sample_ic_collect_count") private var isApplying = false + private var confirmedListingState = false var onToggle: ((Bool) -> Void)? @@ -460,10 +462,11 @@ private final class MaterialListCell: UITableViewCell { func apply(item: MaterialItem) { isApplying = true + confirmedListingState = item.listingStatus == 1 titleLabel.text = item.name timeLabel.text = item.createdAt statusBadge.apply(status: item.status) - listingSwitch.setOn(item.listingStatus == 1, animated: false) + listingSwitch.setOn(confirmedListingState, animated: false) downloadStatView.setCount(item.downloadCount) likeStatView.setCount(item.likesCount) collectStatView.setCount(item.collectCount) @@ -515,11 +518,12 @@ private final class MaterialListCell: UITableViewCell { contentView.addSubview(cardView) cardView.addSubview(coverImageView) coverImageView.addSubview(placeholderLabel) - cardView.addSubview(titleLabel) - cardView.addSubview(timeLabel) - cardView.addSubview(statusBadge) - cardView.addSubview(listingSwitch) - cardView.addSubview(statsStack) + cardView.addSubview(detailContentView) + detailContentView.addSubview(titleLabel) + detailContentView.addSubview(timeLabel) + detailContentView.addSubview(statusBadge) + detailContentView.addSubview(listingSwitch) + detailContentView.addSubview(statsStack) [downloadStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview) cardView.snp.makeConstraints { make in @@ -535,13 +539,18 @@ private final class MaterialListCell: UITableViewCell { make.center.equalToSuperview() make.leading.trailing.equalToSuperview().inset(8) } - titleLabel.snp.makeConstraints { make in - make.top.equalToSuperview().offset(16) + detailContentView.snp.makeConstraints { make in make.leading.equalTo(coverImageView.snp.trailing).offset(12) make.trailing.equalToSuperview().inset(12) + make.centerY.equalTo(coverImageView) + make.top.greaterThanOrEqualToSuperview().inset(12) + make.bottom.lessThanOrEqualToSuperview().inset(12) + } + titleLabel.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() } timeLabel.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(4) + make.top.equalTo(titleLabel.snp.bottom).offset(2) make.leading.trailing.equalTo(titleLabel) } statusBadge.snp.makeConstraints { make in @@ -551,20 +560,22 @@ private final class MaterialListCell: UITableViewCell { } listingSwitch.snp.makeConstraints { make in make.centerY.equalTo(statusBadge) - make.trailing.equalToSuperview().inset(12) + make.trailing.equalToSuperview() make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8) } statsStack.snp.makeConstraints { make in make.top.equalTo(statusBadge.snp.bottom).offset(8) - make.leading.equalTo(titleLabel) - make.trailing.lessThanOrEqualToSuperview().inset(12) - make.bottom.lessThanOrEqualToSuperview().inset(12) + make.leading.equalToSuperview() + make.trailing.lessThanOrEqualToSuperview() + make.bottom.equalToSuperview() } } @objc private func switchChanged() { guard !isApplying else { return } - onToggle?(listingSwitch.isOn) + let requestedState = listingSwitch.isOn + listingSwitch.setOn(confirmedListingState, animated: true) + onToggle?(requestedState) } } diff --git a/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift index e12f648..cf034c0 100644 --- a/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift +++ b/suixinkan/UI/Profile/ProfileSpaceSettingsViewController.swift @@ -607,14 +607,13 @@ final class ProfileSpaceSettingsViewController: BaseViewController { private func showTimePicker(initialTime: String?, onConfirm: @escaping (String) -> Void) { let dialog = ProfileTimePickerDialogViewController( - initialDate: date(fromTime: "00:00"), + initialDate: date(fromTime: initialTime ?? "00:00"), onConfirm: { [weak self] date in guard let self else { return } onConfirm(timeString(from: date)) } ) present(dialog, animated: false) - _ = initialTime } private func date(fromTime value: String) -> Date { @@ -976,7 +975,7 @@ final class ProfileAddScheduleViewController: BaseViewController { } private func showTimePicker(initialTime: String?, onConfirm: @escaping (String) -> Void) { - let date = Self.date(from: "00:00") + let date = Self.date(from: initialTime) let dialog = ProfileTimePickerDialogViewController(initialDate: date) { selectedDate in let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") @@ -984,7 +983,6 @@ final class ProfileAddScheduleViewController: BaseViewController { onConfirm(formatter.string(from: selectedDate)) } present(dialog, animated: false) - _ = initialTime } @objc private func startTapped() { @@ -1063,7 +1061,9 @@ final class ProfileAddScheduleViewController: BaseViewController { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "HH:mm" - return formatter.date(from: time ?? "00:00") ?? Date() + return formatter.date(from: time ?? "") + ?? formatter.date(from: "00:00") + ?? Date(timeIntervalSince1970: 0) } } @@ -1265,6 +1265,9 @@ private final class ProfileTagWrapView: UIView { case .device: button.backgroundColor = AppColor.inputBackground button.setTitleColor(UIColor(hex: 0x4B5563), for: .normal) + button.snp.makeConstraints { make in + make.height.equalTo(28) + } } if removable { button.addAction(UIAction { [weak self] _ in diff --git a/suixinkanTests/MaterialManagementAPITests.swift b/suixinkanTests/MaterialManagementAPITests.swift index 4769444..9654f72 100644 --- a/suixinkanTests/MaterialManagementAPITests.swift +++ b/suixinkanTests/MaterialManagementAPITests.swift @@ -38,6 +38,20 @@ final class MaterialManagementAPITests: XCTestCase { XCTAssertEqual(response.order?.totalNum, 0) } + func testListDecodesAndroidDefaultsWhenNamesAreMissing() async throws { + let item = #"{"id":56,"name":"iOS视频素材测试","cover_url":"https://cdn/cover.jpg","created_at":"2026-07-13 11:36:30","status":0,"download_count":0,"likes_count":0,"collect_count":0,"share_count":0,"listing_status":0,"type":"2"}"# + let session = MockURLSession(responses: [envelopeJSON(#"{"total":1,"list":[\#(item)],"order":{"avg_change":1,"avg_order_amount":null,"refund_total":null,"total_num":0}}"#)]) + let api = MaterialManagementAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.list(status: 4, keyword: nil, page: 1, pageSize: 10) + + let material = try XCTUnwrap(response.list.first) + XCTAssertEqual(material.id, 56) + XCTAssertEqual(material.name, "iOS视频素材测试") + XCTAssertEqual(material.scenicSpotName, "") + XCTAssertEqual(material.projectName, "") + } + func testDetailOperationDeleteAndTagUseAndroidPaths() async throws { let detail = envelopeJSON(#"{"id":8,"status":1,"listing_status":1}"#) let empty = envelopeJSON("{}")