From afcd412f11e537cb4ed03e5d13ecbcb15c2effa0 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:00:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=A0=B7=E7=89=87=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=88=97=E8=A1=A8=E3=80=81=E8=AF=A6=E6=83=85=E4=B8=8E?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E9=A1=B5=20UI=20=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 样片模块布局与加载状态,补充共享 UI 组件与图片资源,并增强列表解析容错与单元测试覆盖。 Co-authored-by: Cursor --- .../sample_img_error.imageset/Contents.json | 5 + .../sample_img_error.svg | 9 + .../sample_img_loading.imageset/Contents.json | 5 + .../sample_img_loading.svg | 9 + .../Home/Services/HomeMenuCatalog.swift | 2 +- .../Models/SampleManagementModels.swift | 5 + .../SampleManagementViewModels.swift | 44 +++- .../MaterialListViewController.swift | 2 +- .../PunchPointDetailViewController.swift | 6 +- .../SampleDetailViewController.swift | 213 +++++++++++++--- .../SampleListViewController.swift | 69 ++++-- .../SampleManagement/SampleManagementUI.swift | 155 ++++++++++++ .../UploadSampleViewController.swift | 234 +++++++++++++----- suixinkanTests/HomeCommonMenuStoreTests.swift | 2 +- suixinkanTests/SampleManagementAPITests.swift | 44 ++++ .../SampleManagementViewModelTests.swift | 192 +++++++++++++- 16 files changed, 862 insertions(+), 134 deletions(-) create mode 100644 suixinkan/Assets.xcassets/SampleManagement/sample_img_error.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/SampleManagement/sample_img_error.imageset/sample_img_error.svg create mode 100644 suixinkan/Assets.xcassets/SampleManagement/sample_img_loading.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/SampleManagement/sample_img_loading.imageset/sample_img_loading.svg create mode 100644 suixinkan/UI/SampleManagement/SampleManagementUI.swift diff --git a/suixinkan/Assets.xcassets/SampleManagement/sample_img_error.imageset/Contents.json b/suixinkan/Assets.xcassets/SampleManagement/sample_img_error.imageset/Contents.json new file mode 100644 index 0000000..8e15fc1 --- /dev/null +++ b/suixinkan/Assets.xcassets/SampleManagement/sample_img_error.imageset/Contents.json @@ -0,0 +1,5 @@ +{ + "images" : [{ "filename" : "sample_img_error.svg", "idiom" : "universal" }], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} diff --git a/suixinkan/Assets.xcassets/SampleManagement/sample_img_error.imageset/sample_img_error.svg b/suixinkan/Assets.xcassets/SampleManagement/sample_img_error.imageset/sample_img_error.svg new file mode 100644 index 0000000..3a7adb5 --- /dev/null +++ b/suixinkan/Assets.xcassets/SampleManagement/sample_img_error.imageset/sample_img_error.svg @@ -0,0 +1,9 @@ + + + + + + + + 加载失败... + diff --git a/suixinkan/Assets.xcassets/SampleManagement/sample_img_loading.imageset/Contents.json b/suixinkan/Assets.xcassets/SampleManagement/sample_img_loading.imageset/Contents.json new file mode 100644 index 0000000..f41e416 --- /dev/null +++ b/suixinkan/Assets.xcassets/SampleManagement/sample_img_loading.imageset/Contents.json @@ -0,0 +1,5 @@ +{ + "images" : [{ "filename" : "sample_img_loading.svg", "idiom" : "universal" }], + "info" : { "author" : "xcode", "version" : 1 }, + "properties" : { "preserves-vector-representation" : true } +} diff --git a/suixinkan/Assets.xcassets/SampleManagement/sample_img_loading.imageset/sample_img_loading.svg b/suixinkan/Assets.xcassets/SampleManagement/sample_img_loading.imageset/sample_img_loading.svg new file mode 100644 index 0000000..1c277e2 --- /dev/null +++ b/suixinkan/Assets.xcassets/SampleManagement/sample_img_loading.imageset/sample_img_loading.svg @@ -0,0 +1,9 @@ + + + + + + + + 加载中... + diff --git a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift index 1ecbd10..a2a56ba 100644 --- a/suixinkan/Features/Home/Services/HomeMenuCatalog.swift +++ b/suixinkan/Features/Home/Services/HomeMenuCatalog.swift @@ -20,7 +20,7 @@ enum HomeMenuCatalog { HomeMenuItem(uri: "system_settings", title: "设置中心", iconName: "gearshape.fill"), HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell.fill"), HomeMenuItem(uri: "checkin_points", title: "打卡点管理", iconName: "mappin.and.ellipse"), - HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "point.3.connected.trianglepath.dotted"), + HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "sample_ic_home_menu_route"), HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "dot.radiowaves.left.and.right"), HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal.fill"), HomeMenuItem(uri: "travel_album", title: "新增相册", iconName: "photo.badge.plus"), diff --git a/suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift b/suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift index 51122a9..70c08c3 100644 --- a/suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift +++ b/suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift @@ -335,6 +335,11 @@ struct SampleDetailMediaItem: Decodable, Sendable, Equatable, Hashable, Identifi ?? (showUrl?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty) ?? "" } + + /// 返回可展示的媒体类型;服务端媒体项为 0 时按 Android 规则回退到详情顶层类型。 + func resolvedType(fallback: Int) -> Int { + type == 0 ? fallback : type + } } /// 样片关联项目项,对齐 Android `ProjectItem`。 diff --git a/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift b/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift index 3d366bc..fd27fbf 100644 --- a/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift +++ b/suixinkan/Features/SampleManagement/ViewModels/SampleManagementViewModels.swift @@ -26,6 +26,7 @@ final class SampleListViewModel { private(set) var canLoadMore = false private(set) var filterStatus: SampleFilterStatus = .all private(set) var searchText = "" + private(set) var operatingSampleIDs: Set = [] var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? @@ -33,6 +34,11 @@ final class SampleListViewModel { private var currentPage = 1 private var totalCount = 0 private let pageSize = 10 + private var isRequestingList = false + private var isLoadingMore = false + + /// 是否正在执行样片上下架操作。 + var isOperating: Bool { !operatingSampleIDs.isEmpty } /// 首次加载样片列表。 func loadInitial(api: any SampleManagementServing) async { @@ -67,15 +73,26 @@ final class SampleListViewModel { } /// 切换样片上下架状态。 - func toggleListing(sampleId: Int, checked: Bool, api: any SampleManagementServing) async { - guard let currentItem = items.first(where: { $0.id == sampleId }) else { return } + @discardableResult + func toggleListing(sampleId: Int, checked: Bool, api: any SampleManagementServing) async -> Bool { + guard let currentItem = items.first(where: { $0.id == sampleId }) else { return !checked } + let previousState = currentItem.isListed guard currentItem.status == 1 else { onShowMessage?("审核通过才可以操作上下架") - return + notifyStateChange() + return previousState } let targetStatus = checked ? 1 : 0 - guard currentItem.listingStatus != targetStatus else { return } + guard currentItem.listingStatus != targetStatus else { return previousState } + + guard !operatingSampleIDs.contains(sampleId) else { return previousState } + operatingSampleIDs.insert(sampleId) + notifyStateChange() + defer { + operatingSampleIDs.remove(sampleId) + notifyStateChange() + } do { try await api.setListingStatus(id: sampleId, listingStatus: targetStatus) @@ -97,25 +114,38 @@ final class SampleListViewModel { ) } notifyStateChange() + return checked } catch is CancellationError { - return + return previousState } catch { onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription) + return previousState } } private func load(reset: Bool, showLoading: Bool, api: any SampleManagementServing) async { + guard !isRequestingList else { + if isRefreshing { + isRefreshing = false + notifyStateChange() + } + return + } if reset { currentPage = 1 canLoadMore = false } else { - guard canLoadMore, !isLoading else { return } + guard canLoadMore, !isLoading, !isLoadingMore else { return } + isLoadingMore = true currentPage += 1 } + isRequestingList = true isLoading = showLoading notifyStateChange() defer { + isRequestingList = false + if !reset { isLoadingMore = false } isLoading = false isRefreshing = false notifyStateChange() @@ -276,7 +306,7 @@ final class UploadSampleViewModel { onShowMessage?("请先选择景区") return } - if projectIsLoading && !refresh { return } + guard !projectIsLoading else { return } if refresh { projectCurrentPage = 1 projectList = [] diff --git a/suixinkan/UI/MaterialManagement/MaterialListViewController.swift b/suixinkan/UI/MaterialManagement/MaterialListViewController.swift index c28b720..5d259a9 100644 --- a/suixinkan/UI/MaterialManagement/MaterialListViewController.swift +++ b/suixinkan/UI/MaterialManagement/MaterialListViewController.swift @@ -564,7 +564,7 @@ private final class MaterialListCell: UITableViewCell { make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8) } statsStack.snp.makeConstraints { make in - make.top.equalTo(statusBadge.snp.bottom).offset(8) + make.top.equalTo(statusBadge.snp.bottom).offset(12) make.leading.equalToSuperview() make.trailing.lessThanOrEqualToSuperview() make.bottom.equalToSuperview() diff --git a/suixinkan/UI/PunchPoint/PunchPointDetailViewController.swift b/suixinkan/UI/PunchPoint/PunchPointDetailViewController.swift index efb15c3..af0cd31 100644 --- a/suixinkan/UI/PunchPoint/PunchPointDetailViewController.swift +++ b/suixinkan/UI/PunchPoint/PunchPointDetailViewController.swift @@ -284,10 +284,10 @@ final class PunchPointDetailViewController: BaseViewController { title.font = .systemFont(ofSize: 14) title.textColor = UIColor(hex: 0x2E3746) title.textAlignment = .left - title.snp.makeConstraints { make in - make.width.equalTo(contentStack.snp.width) - } section.addArrangedSubview(title) + title.snp.makeConstraints { make in + make.width.equalTo(section.snp.width) + } guard let qrCode = qrCode?.nonEmptyTrimmed else { let empty = UILabel() empty.text = "暂无二维码" diff --git a/suixinkan/UI/SampleManagement/SampleDetailViewController.swift b/suixinkan/UI/SampleManagement/SampleDetailViewController.swift index 8357ac2..bb1fa85 100644 --- a/suixinkan/UI/SampleManagement/SampleDetailViewController.swift +++ b/suixinkan/UI/SampleManagement/SampleDetailViewController.swift @@ -9,7 +9,7 @@ import SnapKit import UIKit /// 样片详情页,对齐 Android `SampleDetailScreen`。 -final class SampleDetailViewController: BaseViewController { +final class SampleDetailViewController: SampleBaseViewController { private let viewModel: SampleDetailViewModel private let api: any SampleManagementServing @@ -21,6 +21,7 @@ final class SampleDetailViewController: BaseViewController { private let scenicSection = SampleDetailImageInfoSection(title: "打卡地点") private let projectSection = SampleDetailImageInfoSection(title: "样片绑定套餐") private let reviewSection = SampleDetailReviewSection() + private let bottomSpacer = UIView() /// 初始化样片详情页。 init( @@ -38,14 +39,16 @@ final class SampleDetailViewController: BaseViewController { } override func setupNavigationBar() { - title = "样片详情" + super.setupNavigationBar() + setSampleTitle("样片详情") } override func setupUI() { - view.backgroundColor = UIColor(hex: 0xF4F4F4) + super.setupUI() + view.backgroundColor = SampleManagementUITokens.pageBackground contentStack.axis = .vertical - contentStack.spacing = 12 - scrollView.backgroundColor = UIColor(hex: 0xF4F4F4) + contentStack.spacing = 0 + scrollView.backgroundColor = SampleManagementUITokens.pageBackground view.addSubview(scrollView) scrollView.addSubview(contentStack) @@ -55,11 +58,18 @@ final class SampleDetailViewController: BaseViewController { contentStack.addArrangedSubview(scenicSection) contentStack.addArrangedSubview(projectSection) contentStack.addArrangedSubview(reviewSection) + contentStack.addArrangedSubview(bottomSpacer) + contentStack.setCustomSpacing(12, after: sampleInfoSection) + contentStack.setCustomSpacing(12, after: uploaderSection) + contentStack.setCustomSpacing(12, after: scenicSection) + contentStack.setCustomSpacing(12, after: projectSection) + bottomSpacer.snp.makeConstraints { make in make.height.equalTo(16) } } override func setupConstraints() { scrollView.snp.makeConstraints { make in - make.edges.equalTo(view.safeAreaLayoutGuide) + make.top.equalToSuperview() + make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide) } contentStack.snp.makeConstraints { make in make.edges.equalToSuperview() @@ -96,7 +106,7 @@ final class SampleDetailViewController: BaseViewController { hideLoading() } guard let detail = viewModel.detail else { return } - carouselView.apply(media: detail.mediaList ?? []) + carouselView.apply(media: detail.mediaList ?? [], fallbackType: detail.type ?? 1) sampleInfoSection.apply(detail: detail) uploaderSection.apply(detail: detail) scenicSection.applyScenic(detail: detail) @@ -108,10 +118,15 @@ final class SampleDetailViewController: BaseViewController { /// 样片详情媒体轮播,支持图片自动切换与视频播完切换。 private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate { private let scrollView = UIScrollView() - private let pageControl = UIPageControl() + private let indicatorStack = UIStackView() private let placeholderLabel = UILabel() private var media: [SampleDetailMediaItem] = [] + private var renderedMediaIndices: [Int] = [] + private var currentRenderedPage = 0 + private var fallbackType = 1 + private var pageViews: [UIView] = [] private var players: [Int: AVPlayer] = [:] + private var playerViews: [Int: SamplePlayerLayerView] = [:] private var timer: Timer? override init(frame: CGRect) { @@ -121,22 +136,24 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate { scrollView.showsHorizontalScrollIndicator = false scrollView.delegate = self scrollView.bounces = false - pageControl.currentPageIndicatorTintColor = .white - pageControl.pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.5) + indicatorStack.axis = .horizontal + indicatorStack.spacing = 6 + indicatorStack.alignment = .center placeholderLabel.text = "暂无媒体" placeholderLabel.font = .systemFont(ofSize: 14) placeholderLabel.textColor = AppColor.textSecondary placeholderLabel.textAlignment = .center addSubview(scrollView) - addSubview(pageControl) + addSubview(indicatorStack) addSubview(placeholderLabel) scrollView.snp.makeConstraints { make in make.edges.equalToSuperview() } - pageControl.snp.makeConstraints { make in + indicatorStack.snp.makeConstraints { make in make.centerX.equalToSuperview() - make.bottom.equalToSuperview().inset(8) + make.bottom.equalToSuperview().inset(12) + make.height.equalTo(8) } placeholderLabel.snp.makeConstraints { make in make.center.equalToSuperview() @@ -154,27 +171,35 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate { } /// 应用媒体列表。 - func apply(media: [SampleDetailMediaItem]) { + func apply(media: [SampleDetailMediaItem], fallbackType: Int) { stopTimer() NotificationCenter.default.removeObserver(self) players.values.forEach { $0.pause() } players.removeAll() + playerViews.removeAll() + pageViews.removeAll() self.media = media + self.fallbackType = fallbackType + renderedMediaIndices = media.count > 1 + ? [media.count - 1] + Array(media.indices) + [0] + : Array(media.indices) + currentRenderedPage = media.count > 1 ? 1 : 0 scrollView.subviews.forEach { $0.removeFromSuperview() } - pageControl.numberOfPages = media.count - pageControl.currentPage = 0 - pageControl.isHidden = media.count <= 1 + rebuildIndicators(currentIndex: 0) + indicatorStack.isHidden = media.count <= 1 placeholderLabel.isHidden = !media.isEmpty scrollView.isHidden = media.isEmpty guard !media.isEmpty else { return } - for (index, item) in media.enumerated() { + for (renderedIndex, mediaIndex) in renderedMediaIndices.enumerated() { + let item = media[mediaIndex] let page = UIView() page.backgroundColor = UIColor(hex: 0xF5F5F5) - page.tag = index + page.tag = renderedIndex scrollView.addSubview(page) - if item.type == 2 { - addVideo(urlText: item.displayURL, to: page, index: index) + pageViews.append(page) + if item.resolvedType(fallback: fallbackType) == 2 { + addVideo(urlText: item.displayURL, to: page, index: renderedIndex) } else { addImage(urlText: item.displayURL, to: page) } @@ -198,6 +223,10 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate { updateCurrentPage() } + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + stopTimer() + } + private func addImage(urlText: String, to page: UIView) { let imageView = UIImageView() imageView.contentMode = .scaleAspectFill @@ -207,7 +236,13 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate { make.edges.equalToSuperview() } if let url = URL(string: urlText), !urlText.isEmpty { - imageView.kf.setImage(with: url, placeholder: UIImage(named: "img_square_loading_big")) + imageView.kf.setImage(with: url, placeholder: UIImage(named: "sample_img_loading")) { result in + if case .failure = result { + imageView.image = UIImage(named: "sample_img_error") + } + } + } else { + imageView.image = UIImage(named: "sample_img_error") } } @@ -219,7 +254,8 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate { let player = AVPlayer(url: url) players[index] = player let playerView = SamplePlayerLayerView() - playerView.playerLayer.player = player + playerView.configure(player: player) + playerViews[index] = playerView page.addSubview(playerView) playerView.snp.makeConstraints { make in make.edges.equalToSuperview() @@ -235,32 +271,42 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate { private func layoutPages() { let size = bounds.size guard size.width > 0, size.height > 0 else { return } - for (index, page) in scrollView.subviews.enumerated() { + for (index, page) in pageViews.enumerated() { page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height) } - scrollView.contentSize = CGSize(width: size.width * CGFloat(media.count), height: size.height) - scrollView.contentOffset = CGPoint(x: CGFloat(pageControl.currentPage) * size.width, y: 0) + scrollView.contentSize = CGSize(width: size.width * CGFloat(renderedMediaIndices.count), height: size.height) + scrollView.contentOffset = CGPoint(x: CGFloat(currentRenderedPage) * size.width, y: 0) } private func updateCurrentPage() { - guard bounds.width > 0 else { return } + guard bounds.width > 0, !renderedMediaIndices.isEmpty else { return } let page = Int(round(scrollView.contentOffset.x / bounds.width)) - pageControl.currentPage = max(0, min(page, media.count - 1)) + currentRenderedPage = max(0, min(page, renderedMediaIndices.count - 1)) + if media.count > 1, currentRenderedPage == 0 { + currentRenderedPage = media.count + scrollView.setContentOffset(CGPoint(x: CGFloat(currentRenderedPage) * bounds.width, y: 0), animated: false) + } else if media.count > 1, currentRenderedPage == renderedMediaIndices.count - 1 { + currentRenderedPage = 1 + scrollView.setContentOffset(CGPoint(x: bounds.width, y: 0), animated: false) + } + rebuildIndicators(currentIndex: currentMediaIndex) playCurrentPageIfNeeded() } private func playCurrentPageIfNeeded() { stopTimer() players.forEach { index, player in - if index == pageControl.currentPage { + if index == currentRenderedPage { player.seek(to: .zero) player.play() + playerViews[index]?.setPlaying(true) } else { player.pause() + playerViews[index]?.setPlaying(false) } } guard !media.isEmpty else { return } - if media[pageControl.currentPage].type == 1 { + if media[currentMediaIndex].resolvedType(fallback: fallbackType) == 1 { startImageTimer() } } @@ -278,13 +324,15 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate { } @objc private func videoDidEnd(_ notification: Notification) { + guard let item = notification.object as? AVPlayerItem, + players[currentRenderedPage]?.currentItem === item else { return } advancePage() } private func advancePage() { guard media.count > 1, bounds.width > 0 else { return } - let nextPage = (pageControl.currentPage + 1) % media.count - pageControl.currentPage = nextPage + let nextPage = min(currentRenderedPage + 1, renderedMediaIndices.count - 1) + currentRenderedPage = nextPage scrollView.setContentOffset(CGPoint(x: CGFloat(nextPage) * bounds.width, y: 0), animated: true) if !scrollView.isDragging && !scrollView.isDecelerating { DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in @@ -292,12 +340,92 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate { } } } + + private var currentMediaIndex: Int { + guard renderedMediaIndices.indices.contains(currentRenderedPage) else { return 0 } + return renderedMediaIndices[currentRenderedPage] + } + + private func rebuildIndicators(currentIndex: Int) { + indicatorStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + guard media.count > 1 else { return } + for index in media.indices { + let dot = UIView() + let size: CGFloat = index == currentIndex ? 8 : 6 + dot.backgroundColor = index == currentIndex ? .white : UIColor.white.withAlphaComponent(0.5) + dot.layer.cornerRadius = size / 2 + dot.snp.makeConstraints { make in make.size.equalTo(size) } + indicatorStack.addArrangedSubview(dot) + } + } } -/// AVPlayerLayer 容器。 +/// AVPlayerLayer 容器,提供点击显示且三秒自动隐藏的播放控制。 private final class SamplePlayerLayerView: UIView { + private let controlButton = UIButton(type: .system) + private weak var player: AVPlayer? + private var hideControlWorkItem: DispatchWorkItem? + override class var layerClass: AnyClass { AVPlayerLayer.self } var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer } + + override init(frame: CGRect) { + super.init(frame: frame) + controlButton.tintColor = .white + controlButton.backgroundColor = UIColor.black.withAlphaComponent(0.45) + controlButton.layer.cornerRadius = 22 + controlButton.isHidden = true + controlButton.accessibilityLabel = "播放或暂停视频" + addSubview(controlButton) + controlButton.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(44) + } + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showControls))) + controlButton.addTarget(self, action: #selector(togglePlayback), for: .touchUpInside) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 绑定播放器。 + func configure(player: AVPlayer) { + self.player = player + playerLayer.player = player + updateControlImage() + } + + /// 同步轮播切页后的播放状态。 + func setPlaying(_ playing: Bool) { + if !playing { controlButton.isHidden = true } + updateControlImage() + } + + @objc private func showControls() { + controlButton.isHidden = false + updateControlImage() + hideControlWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in self?.controlButton.isHidden = true } + hideControlWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: workItem) + } + + @objc private func togglePlayback() { + guard let player else { return } + if player.rate == 0 { + player.play() + } else { + player.pause() + } + showControls() + } + + private func updateControlImage() { + let name = player?.rate == 0 ? "play.fill" : "pause.fill" + controlButton.setImage(UIImage(systemName: name), for: .normal) + } } /// 样片详情名称与上架状态区。 @@ -508,11 +636,16 @@ private final class SampleDetailImageInfoSection: UIView { titleLabel.text = detail.scenicName ?? "" firstLineLabel.attributedText = coloredValue(prefix: "无人机: ", value: "\(detail.scenicPlanesNum)架") secondLineLabel.attributedText = coloredValue(prefix: "摄影师: ", value: "\(detail.scenicPhotographerNum)人") + let shouldShowCounts = detail.scenicPlanesNum > 0 || detail.scenicPhotographerNum > 0 + firstLineLabel.isHidden = !shouldShowCounts + secondLineLabel.isHidden = !shouldShowCounts setImage(detail.scenicCoverImg) } func applyProject(detail: SampleDetail) { titleLabel.text = detail.projectName ?? "" + firstLineLabel.isHidden = detail.projectPrice == nil + secondLineLabel.isHidden = detail.projectPriceDeposit == nil if let price = detail.projectPrice { firstLineLabel.attributedText = coloredValue(prefix: "价格: ", value: "\(price)元") } else { @@ -528,9 +661,13 @@ private final class SampleDetailImageInfoSection: UIView { private func setImage(_ urlText: String?) { if let urlText, let url = URL(string: urlText), !urlText.isEmpty { - imageView.kf.setImage(with: url) + imageView.kf.setImage(with: url, placeholder: UIImage(named: "sample_img_loading")) { [weak imageView] result in + if case .failure = result { + imageView?.image = UIImage(named: "sample_img_error") + } + } } else { - imageView.image = nil + imageView.image = UIImage(named: "sample_img_error") } } @@ -586,6 +723,7 @@ private final class SampleDetailReviewSection: UIView { reviewTimeRow.apply(label: "审核时间", value: detail.reviewTime?.nonEmpty ?? "--") statusRow.apply(status: detail.status) noteRow.apply(label: "审核备注", value: detail.reviewNotes?.nonEmpty ?? "--", valueColor: UIColor(hex: 0x4B5563), valueFont: .systemFont(ofSize: 12)) + noteRow.setSeparatorHidden(true) } } @@ -634,6 +772,11 @@ private final class SampleDetailInfoRow: UIView { valueLabel.textColor = valueColor valueLabel.font = valueFont } + + /// 控制行底部分隔线显示。 + func setSeparatorHidden(_ hidden: Bool) { + separator.isHidden = hidden + } } /// 样片详情审核状态行。 diff --git a/suixinkan/UI/SampleManagement/SampleListViewController.swift b/suixinkan/UI/SampleManagement/SampleListViewController.swift index 6f85ac0..25be5b9 100644 --- a/suixinkan/UI/SampleManagement/SampleListViewController.swift +++ b/suixinkan/UI/SampleManagement/SampleListViewController.swift @@ -8,7 +8,7 @@ import SnapKit import UIKit /// 样片相册列表页,对齐 Android `SampleListScreen`。 -final class SampleListViewController: BaseViewController { +final class SampleListViewController: SampleBaseViewController { private let viewModel: SampleListViewModel private let api: any SampleManagementServing @@ -45,11 +45,13 @@ final class SampleListViewController: BaseViewController { } override func setupNavigationBar() { - title = "样片相册列表" + super.setupNavigationBar() + setSampleTitle("样片相册列表") } override func setupUI() { - view.backgroundColor = AppColor.pageBackground + super.setupUI() + view.backgroundColor = SampleManagementUITokens.pageBackground searchContainer.backgroundColor = .white searchBox.backgroundColor = UIColor(hex: 0xF4F4F4) @@ -65,7 +67,7 @@ final class SampleListViewController: BaseViewController { filterContainer.backgroundColor = .white filterButton.backgroundColor = UIColor(hex: 0xF4F4F4) filterButton.layer.cornerRadius = 4 - filterTitleLabel.text = "筛选" + filterTitleLabel.text = SampleFilterStatus.all.title filterTitleLabel.font = .systemFont(ofSize: 14) filterTitleLabel.textColor = UIColor(hex: 0x4B5563) filterChevronView.tintColor = UIColor(hex: 0x4B5563) @@ -80,7 +82,7 @@ final class SampleListViewController: BaseViewController { filterDropdownStack.axis = .vertical filterDropdownStack.spacing = 0 - tableView.backgroundColor = AppColor.pageBackground + tableView.backgroundColor = SampleManagementUITokens.pageBackground tableView.separatorStyle = .none tableView.estimatedRowHeight = 152 tableView.rowHeight = UITableView.automaticDimension @@ -95,10 +97,15 @@ final class SampleListViewController: BaseViewController { withIdentifier: SampleListCell.reuseIdentifier, for: indexPath ) as! SampleListCell - cell.apply(item: item) - cell.onToggle = { [weak self] checked in + cell.apply(item: item, isOperating: self?.viewModel.operatingSampleIDs.contains(item.id) == true) + cell.onToggle = { [weak self, weak cell] checked in guard let self else { return } - Task { await self.viewModel.toggleListing(sampleId: item.id, checked: checked, api: self.api) } + 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) + } + } } return cell } @@ -135,7 +142,7 @@ final class SampleListViewController: BaseViewController { override func setupConstraints() { searchContainer.snp.makeConstraints { make in - make.top.equalTo(view.safeAreaLayoutGuide) + make.top.equalToSuperview() make.leading.trailing.equalToSuperview() make.height.equalTo(68) } @@ -232,7 +239,7 @@ final class SampleListViewController: BaseViewController { @MainActor private func applyViewModel() { - filterTitleLabel.text = viewModel.filterStatus == .all ? "筛选" : viewModel.filterStatus.title + filterTitleLabel.text = viewModel.filterStatus.title filterOptionButtons.forEach { status, button in let selected = status == viewModel.filterStatus button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal) @@ -342,11 +349,13 @@ 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? var onToggle: ((Bool) -> Void)? @@ -360,13 +369,23 @@ private final class SampleListCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } - func apply(item: SampleMaterialItem) { + override func prepareForReuse() { + super.prepareForReuse() + representedSampleID = nil + onToggle = nil + } + + func apply(item: SampleMaterialItem, isOperating: Bool) { isApplying = true + representedSampleID = item.id 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() shareStatView.setCount(item.shareCount) likeStatView.setCount(item.likesCount) collectStatView.setCount(item.collectCount) @@ -381,10 +400,21 @@ 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 = AppColor.pageBackground - contentView.backgroundColor = AppColor.pageBackground + backgroundColor = SampleManagementUITokens.pageBackground + contentView.backgroundColor = SampleManagementUITokens.pageBackground cardView.backgroundColor = .white cardView.layer.cornerRadius = 12 @@ -411,7 +441,10 @@ 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 @@ -425,11 +458,13 @@ 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) cardView.snp.makeConstraints { make in - make.top.bottom.equalToSuperview().inset(8) + make.top.equalToSuperview() + make.bottom.equalToSuperview().inset(16) make.leading.trailing.equalToSuperview().inset(16) } coverImageView.snp.makeConstraints { make in @@ -463,6 +498,9 @@ 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) @@ -473,6 +511,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) } } diff --git a/suixinkan/UI/SampleManagement/SampleManagementUI.swift b/suixinkan/UI/SampleManagement/SampleManagementUI.swift new file mode 100644 index 0000000..8f4f139 --- /dev/null +++ b/suixinkan/UI/SampleManagement/SampleManagementUI.swift @@ -0,0 +1,155 @@ +// +// SampleManagementUI.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 样片管理模块视觉常量,严格对应 Android Compose 页面中的 dp、颜色与圆角。 +enum SampleManagementUITokens { + static let pageBackground = UIColor(hex: 0xF4F4F4) + static let inputBackground = UIColor(hex: 0xF4F4F4) + static let border = UIColor(hex: 0xEEEEEE) + static let placeholder = UIColor(hex: 0xB6BECA) + static let text563 = UIColor(hex: 0x4B5563) + static let statText = UIColor(hex: 0x6B7280) + static let screenInset: CGFloat = 16 + static let sectionSpacing: CGFloat = 12 + static let cardRadius: CGFloat = 12 + static let fieldRadius: CGFloat = 8 +} + +/// 样片管理页面基类,复用“空间设置”的原生导航栏外观与返回行为。 +class SampleBaseViewController: BaseViewController { + override func setupNavigationBar() { + navigationItem.backButtonDisplayMode = .minimal + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(false, animated: animated) + ProfileNavigationStyle.apply(to: navigationController, showsDivider: false) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + ProfileNavigationStyle.restore(navigationController) + } + + /// 设置与“空间设置”一致的原生导航栏标题。 + func setSampleTitle(_ title: String) { + self.title = title + } +} + +/// 锚定在输入框下方的 Android 风格下拉菜单,支持滚动加载更多。 +final class SampleAnchoredDropdownView: UIControl, UIScrollViewDelegate { + /// 下拉菜单选项。 + struct Option { + let title: String + let isSelected: Bool + let isLoadMore: Bool + + /// 创建普通或“加载更多”选项。 + init(title: String, isSelected: Bool = false, isLoadMore: Bool = false) { + self.title = title + self.isSelected = isSelected + self.isLoadMore = isLoadMore + } + } + + private let cardView = UIView() + private let scrollView = UIScrollView() + private let stackView = UIStackView() + private var onSelect: ((Int) -> Void)? + private var onReachedBottom: (() -> Void)? + private var didRequestBottom = false + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + cardView.backgroundColor = .white + cardView.layer.cornerRadius = 4 + cardView.layer.shadowColor = UIColor.black.cgColor + cardView.layer.shadowOpacity = 0.14 + cardView.layer.shadowRadius = 8 + cardView.layer.shadowOffset = CGSize(width: 0, height: 4) + scrollView.delegate = self + stackView.axis = .vertical + addSubview(cardView) + cardView.addSubview(scrollView) + scrollView.addSubview(stackView) + addTarget(self, action: #selector(dismissMenu), for: .touchUpInside) + isHidden = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 在锚点下方展示菜单。 + func show( + anchor: UIView, + options: [Option], + maximumHeight: CGFloat = 200, + onReachedBottom: (() -> Void)? = nil, + onSelect: @escaping (Int) -> Void + ) { + guard !options.isEmpty else { return } + self.onSelect = onSelect + self.onReachedBottom = onReachedBottom + didRequestBottom = false + stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + for (index, option) in options.enumerated() { + let button = UIButton(type: .system) + button.tag = index + button.contentHorizontalAlignment = .leading + button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) + button.setTitle(option.title, for: .normal) + let color = option.isSelected ? AppColor.primary : (option.isLoadMore ? AppColor.textSecondary : .black) + button.setTitleColor(color, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 14, weight: option.isSelected ? .medium : .regular) + button.backgroundColor = .white + button.accessibilityLabel = option.title + button.addTarget(self, action: #selector(optionTapped(_:)), for: .touchUpInside) + button.snp.makeConstraints { make in make.height.equalTo(28) } + stackView.addArrangedSubview(button) + } + + isHidden = false + superview?.bringSubviewToFront(self) + layoutIfNeeded() + let anchorFrame = anchor.convert(anchor.bounds, to: self) + let contentHeight = CGFloat(options.count) * 28 + let menuHeight = min(maximumHeight, contentHeight) + cardView.frame = CGRect(x: anchorFrame.minX, y: anchorFrame.maxY, width: anchorFrame.width, height: menuHeight) + scrollView.frame = cardView.bounds + stackView.frame = CGRect(x: 0, y: 0, width: cardView.bounds.width, height: contentHeight) + scrollView.contentSize = stackView.frame.size + } + + /// 隐藏菜单。 + func hide() { + isHidden = true + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard !didRequestBottom, scrollView.contentSize.height > scrollView.bounds.height else { return } + let remaining = scrollView.contentSize.height - scrollView.contentOffset.y - scrollView.bounds.height + guard remaining <= 10 else { return } + didRequestBottom = true + onReachedBottom?() + } + + @objc private func optionTapped(_ sender: UIButton) { + isHidden = true + onSelect?(sender.tag) + } + + @objc private func dismissMenu() { + isHidden = true + } +} diff --git a/suixinkan/UI/SampleManagement/UploadSampleViewController.swift b/suixinkan/UI/SampleManagement/UploadSampleViewController.swift index 2a03053..3e7020b 100644 --- a/suixinkan/UI/SampleManagement/UploadSampleViewController.swift +++ b/suixinkan/UI/SampleManagement/UploadSampleViewController.swift @@ -10,7 +10,7 @@ import UniformTypeIdentifiers import UIKit /// 样片相册上传页,对齐 Android `UploadSampleScreen`。 -final class UploadSampleViewController: BaseViewController { +final class UploadSampleViewController: SampleBaseViewController { private enum PickTarget { case material case cover @@ -42,6 +42,7 @@ final class UploadSampleViewController: BaseViewController { private let cancelButton = UIButton(type: .system) private let confirmButton = UIButton(type: .system) private let progressOverlay = SampleUploadProgressOverlay() + private let dropdownOverlay = SampleAnchoredDropdownView() private var pickTarget: PickTarget = .material @@ -65,16 +66,18 @@ final class UploadSampleViewController: BaseViewController { } override func setupNavigationBar() { - title = "样片相册" + super.setupNavigationBar() + setSampleTitle("样片相册") } override func setupUI() { - view.backgroundColor = UIColor(hex: 0xF4F4F4) + super.setupUI() + view.backgroundColor = SampleManagementUITokens.pageBackground formCard.backgroundColor = .white formCard.layer.cornerRadius = 12 formCard.clipsToBounds = true formStack.axis = .vertical - formStack.spacing = 16 + formStack.spacing = 0 nameField.placeholder = "请输入相册名称" nameField.font = .systemFont(ofSize: 14) @@ -96,6 +99,12 @@ final class UploadSampleViewController: BaseViewController { [nameSection, mediaTypeSection, materialSection, coverSection, scenicSection, spotSection, projectSection] .forEach(formStack.addArrangedSubview) + formStack.setCustomSpacing(8, after: nameSection) + formStack.setCustomSpacing(8, after: mediaTypeSection) + formStack.setCustomSpacing(16, after: materialSection) + formStack.setCustomSpacing(16, after: coverSection) + formStack.setCustomSpacing(8, after: scenicSection) + formStack.setCustomSpacing(8, after: spotSection) bottomBar.backgroundColor = .white configureBottomButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary) @@ -107,6 +116,7 @@ final class UploadSampleViewController: BaseViewController { view.addSubview(bottomBar) bottomBar.addSubview(cancelButton) bottomBar.addSubview(confirmButton) + view.addSubview(dropdownOverlay) view.addSubview(progressOverlay) progressOverlay.isHidden = true } @@ -128,7 +138,8 @@ final class UploadSampleViewController: BaseViewController { make.trailing.equalToSuperview().inset(15) } scrollView.snp.makeConstraints { make in - make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.top.equalToSuperview() + make.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.bottom.equalTo(bottomBar.snp.top) } formCard.snp.makeConstraints { make in @@ -149,6 +160,9 @@ final class UploadSampleViewController: BaseViewController { progressOverlay.snp.makeConstraints { make in make.edges.equalToSuperview() } + dropdownOverlay.snp.makeConstraints { make in + make.edges.equalToSuperview() + } } override func bindActions() { @@ -169,6 +183,7 @@ final class UploadSampleViewController: BaseViewController { mediaTypeButton.addTarget(self, action: #selector(mediaTypeTapped), for: .touchUpInside) mediaGridView.onAdd = { [weak self] in self?.presentPickerSource(target: .material) } mediaGridView.onDelete = { [weak self] index in self?.viewModel.deleteMaterial(at: index) } + mediaGridView.onPreview = { [weak self] index in self?.presentMediaPreview(startingAt: index) } coverView.onAdd = { [weak self] in self?.presentPickerSource(target: .cover) } coverView.onDelete = { [weak self] in self?.viewModel.deleteCoverImage() } scenicButton.addTarget(self, action: #selector(scenicTapped), for: .touchUpInside) @@ -229,26 +244,25 @@ final class UploadSampleViewController: BaseViewController { } @objc private func mediaTypeTapped() { - let sheet = UIAlertController(title: "素材类型", message: nil, preferredStyle: .actionSheet) - SampleMediaType.allCases.forEach { type in - sheet.addAction(UIAlertAction(title: type.title, style: .default) { [weak self] _ in - self?.viewModel.selectMediaType(type) - }) + let types = SampleMediaType.allCases + dropdownOverlay.show( + anchor: mediaTypeButton, + options: types.map { .init(title: $0.title, isSelected: $0 == viewModel.mediaType) } + ) { [weak self] index in + guard types.indices.contains(index) else { return } + self?.viewModel.selectMediaType(types[index]) } - sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) - present(sheet, animated: true) } @objc private func scenicTapped() { - let sheet = UIAlertController(title: "关联景区", message: nil, preferredStyle: .actionSheet) - viewModel.scenicList.forEach { scenic in - sheet.addAction(UIAlertAction(title: scenic.name, style: .default) { [weak self] _ in - guard let self else { return } - Task { await self.viewModel.selectScenic(scenic, api: self.api) } - }) + let scenics = viewModel.scenicList + dropdownOverlay.show( + anchor: scenicButton, + options: scenics.map { .init(title: $0.name, isSelected: $0.id == viewModel.selectedScenic?.id) } + ) { [weak self] index in + guard let self, scenics.indices.contains(index) else { return } + Task { await self.viewModel.selectScenic(scenics[index], api: self.api) } } - sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) - present(sheet, animated: true) } @objc private func spotTapped() { @@ -256,14 +270,14 @@ final class UploadSampleViewController: BaseViewController { showToast("请先选择关联景区") return } - let sheet = UIAlertController(title: "关联打卡点", message: nil, preferredStyle: .actionSheet) - viewModel.scenicSpotList.forEach { spot in - sheet.addAction(UIAlertAction(title: spot.name, style: .default) { [weak self] _ in - self?.viewModel.selectScenicSpot(spot) - }) + let spots = viewModel.scenicSpotList + dropdownOverlay.show( + anchor: spotButton, + options: spots.map { .init(title: $0.name, isSelected: $0.id == viewModel.selectedScenicSpot?.id) } + ) { [weak self] index in + guard spots.indices.contains(index) else { return } + self?.viewModel.selectScenicSpot(spots[index]) } - sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) - present(sheet, animated: true) } @objc private func projectTapped() { @@ -277,28 +291,36 @@ final class UploadSampleViewController: BaseViewController { await viewModel.loadProjects(refresh: true, api: api) hideLoading() } - presentProjectSheet() + await MainActor.run { self.presentProjectDropdown() } } } - private func presentProjectSheet() { - let sheet = UIAlertController(title: "关联项目", message: nil, preferredStyle: .actionSheet) - viewModel.projectList.forEach { project in - sheet.addAction(UIAlertAction(title: project.name, style: .default) { [weak self] _ in - self?.viewModel.selectProject(project) - }) - } + @MainActor + private func presentProjectDropdown() { + let projects = viewModel.projectList + var options = projects.map { SampleAnchoredDropdownView.Option(title: $0.name, isSelected: $0.id == viewModel.selectedProject?.id) } if viewModel.projectCanLoadMore { - sheet.addAction(UIAlertAction(title: "加载更多...", style: .default) { [weak self] _ in - guard let self else { return } - Task { - await self.viewModel.loadProjects(refresh: false, api: self.api) - self.presentProjectSheet() - } - }) + options.append(.init(title: "加载更多...", isLoadMore: true)) + } + dropdownOverlay.show( + anchor: projectButton, + options: options, + onReachedBottom: { [weak self] in self?.loadMoreProjectsAndRefreshDropdown() } + ) { [weak self] index in + guard let self else { return } + if projects.indices.contains(index) { + self.viewModel.selectProject(projects[index]) + } else if self.viewModel.projectCanLoadMore { + self.loadMoreProjectsAndRefreshDropdown() + } + } + } + + private func loadMoreProjectsAndRefreshDropdown() { + Task { + await viewModel.loadProjects(refresh: false, api: api) + await MainActor.run { self.presentProjectDropdown() } } - sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) - present(sheet, animated: true) } @objc private func cancelTapped() { @@ -329,6 +351,19 @@ final class UploadSampleViewController: BaseViewController { present(sheet, animated: true) } + private func presentMediaPreview(startingAt sourceIndex: Int) { + let completed = viewModel.uploadedMediaList.enumerated().filter { !$0.element.isUploading && !$0.element.ossUrl.isEmpty } + guard let startIndex = completed.firstIndex(where: { $0.offset == sourceIndex }) else { return } + let previewItems = completed.compactMap { entry -> MediaPreviewItem? in + switch viewModel.mediaType { + case .image: return .remoteImage(entry.element.ossUrl) + case .video: return .remoteVideo(entry.element.ossUrl) + } + } + guard !previewItems.isEmpty else { return } + presentMediaPreview(items: previewItems, startIndex: min(startIndex, previewItems.count - 1)) + } + private func presentPhotoPicker() { var configuration = PHPickerConfiguration(photoLibrary: .shared()) if pickTarget == .cover { @@ -424,16 +459,28 @@ extension UploadSampleViewController: PHPickerViewControllerDelegate { guard let self, let url else { return } let data = (try? Data(contentsOf: url)) ?? Data() guard !data.isEmpty else { return } - let fileName = provider.suggestedName.map { "\($0).mp4" } ?? "sample_video_\(Int(Date().timeIntervalSince1970)).mp4" + let fileName = self.resolvedFileName(provider: provider, url: url, fallbackExtension: "mp4") loadedItems[index] = self.makeVideoItem(data: data, fileName: fileName, sourceURL: url) } } else { group.enter() - provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in + let imageType = provider.registeredTypeIdentifiers.first { + UTType($0)?.conforms(to: .image) == true + } ?? UTType.image.identifier + provider.loadFileRepresentation(forTypeIdentifier: imageType) { [weak self] url, _ in defer { group.leave() } - guard let self, let image = object as? UIImage else { return } - let fileName = "sample_image_\(Int(Date().timeIntervalSince1970))_\(index).jpg" - loadedItems[index] = self.makeImageItem(image: image, fileName: fileName) + guard let self, let url, let data = try? Data(contentsOf: url), let image = UIImage(data: data) else { return } + let pathExtension = url.pathExtension + let ext = UTType(imageType)?.preferredFilenameExtension ?? (pathExtension.isEmpty ? "jpg" : pathExtension) + let fileName = self.resolvedFileName(provider: provider, url: url, fallbackExtension: ext) + loadedItems[index] = SampleUploadedMediaItem( + data: data, + thumbnailData: image.jpegData(compressionQuality: 0.75), + fileName: fileName, + size: Int64(data.count), + width: Int(image.size.width * image.scale), + height: Int(image.size.height * image.scale) + ) } } } @@ -442,6 +489,18 @@ extension UploadSampleViewController: PHPickerViewControllerDelegate { self?.uploadPickedItems(loadedItems.compactMap { $0 }) } } + + private func resolvedFileName(provider: NSItemProvider, url: URL, fallbackExtension: String) -> String { + let suggested = provider.suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !suggested.isEmpty { + return URL(fileURLWithPath: suggested).pathExtension.isEmpty + ? "\(suggested).\(fallbackExtension)" + : suggested + } + let urlName = url.lastPathComponent + if !urlName.isEmpty, urlName != "." { return urlName } + return "sample_\(Int(Date().timeIntervalSince1970)).\(fallbackExtension)" + } } extension UploadSampleViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { @@ -544,6 +603,7 @@ private final class SampleUploadGridView: UIView { private var mediaType: SampleMediaType = .image var onAdd: (() -> Void)? var onDelete: ((Int) -> Void)? + var onPreview: ((Int) -> Void)? override init(frame: CGRect) { super.init(frame: frame) @@ -593,6 +653,7 @@ private final class SampleUploadGridView: UIView { if let item { cell.apply(item: item) cell.onDelete = { [weak self] in self?.onDelete?(index) } + cell.onPreview = { [weak self] in self?.onPreview?(index) } } else { cell.applyAdd() cell.onAdd = { [weak self] in self?.onAdd?() } @@ -605,14 +666,17 @@ private final class SampleUploadGridView: UIView { private final class SampleUploadThumbView: UIControl { private let imageView = UIImageView() private let addCircle = UIView() - private let addIcon = UIImageView(image: UIImage(systemName: "plus")) + private let addIcon = UIImageView(image: UIImage(named: "material_ic_add")) private let textLabel = UILabel() - private let deleteButton = UIButton(type: .system) - private let playIcon = UIImageView(image: UIImage(systemName: "play.fill")) + private let deleteButton = SampleExpandedTouchButton(type: .system) + private let playIcon = UIImageView(image: UIImage(named: "material_ic_play")) private let mediaType: SampleMediaType + private let dashedLayer = CAShapeLayer() + private var previewEnabled = false var onAdd: (() -> Void)? var onDelete: (() -> Void)? + var onPreview: (() -> Void)? init(mediaType: SampleMediaType) { self.mediaType = mediaType @@ -628,7 +692,7 @@ private final class SampleUploadThumbView: UIControl { textLabel.font = .systemFont(ofSize: 12) textLabel.textColor = UIColor(hex: 0x4B5563) textLabel.textAlignment = .center - deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal) + deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal) deleteButton.tintColor = .white deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6) deleteButton.layer.cornerRadius = 10 @@ -665,7 +729,12 @@ private final class SampleUploadThumbView: UIControl { make.center.equalToSuperview() make.size.equalTo(32) } - addTarget(self, action: #selector(addTapped), for: .touchUpInside) + dashedLayer.strokeColor = UIColor(hex: 0xE5E7EB).cgColor + dashedLayer.fillColor = UIColor.clear.cgColor + dashedLayer.lineWidth = 1 + dashedLayer.lineDashPattern = [6, 6] + layer.addSublayer(dashedLayer) + addTarget(self, action: #selector(cellTapped), for: .touchUpInside) deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) } @@ -674,11 +743,18 @@ private final class SampleUploadThumbView: UIControl { fatalError("init(coder:) has not been implemented") } + override func layoutSubviews() { + super.layoutSubviews() + dashedLayer.frame = bounds + dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 8).cgPath + } + func applyAdd() { imageView.image = nil backgroundColor = .white - layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor - layer.borderWidth = 1 + layer.borderWidth = 0 + dashedLayer.isHidden = false + previewEnabled = false addCircle.isHidden = false textLabel.isHidden = false textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频" @@ -688,7 +764,8 @@ private final class SampleUploadThumbView: UIControl { } func apply(item: SampleUploadedMediaItem) { - layer.borderWidth = 0 + dashedLayer.isHidden = true + previewEnabled = !item.ossUrl.isEmpty && !item.isUploading backgroundColor = UIColor(hex: 0xF5F5F5) addCircle.isHidden = true deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading @@ -704,9 +781,12 @@ private final class SampleUploadThumbView: UIControl { } } - @objc private func addTapped() { - guard !addCircle.isHidden else { return } - onAdd?() + @objc private func cellTapped() { + if !addCircle.isHidden { + onAdd?() + } else if previewEnabled { + onPreview?() + } } @objc private func deleteTapped() { @@ -718,18 +798,22 @@ private final class SampleUploadThumbView: UIControl { private final class SampleCoverUploadView: UIControl { private let imageView = UIImageView() private let addCircle = UIView() - private let addIcon = UIImageView(image: UIImage(systemName: "plus")) + private let addIcon = UIImageView(image: UIImage(named: "material_ic_add")) private let titleLabel = UILabel() private let hintLabel = UILabel() - private let deleteButton = UIButton(type: .system) + private let deleteButton = SampleExpandedTouchButton(type: .system) + private let dashedLayer = CAShapeLayer() var onAdd: (() -> Void)? var onDelete: (() -> Void)? override init(frame: CGRect) { super.init(frame: frame) layer.cornerRadius = 8 - layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor - layer.borderWidth = 1 + dashedLayer.strokeColor = UIColor(hex: 0xE5E7EB).cgColor + dashedLayer.fillColor = UIColor.clear.cgColor + dashedLayer.lineWidth = 1 + dashedLayer.lineDashPattern = [6, 6] + layer.addSublayer(dashedLayer) clipsToBounds = true imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true @@ -744,7 +828,7 @@ private final class SampleCoverUploadView: UIControl { hintLabel.font = .systemFont(ofSize: 12) hintLabel.textColor = UIColor(hex: 0xB6BECA) hintLabel.textAlignment = .center - deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal) + deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal) deleteButton.tintColor = .white deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6) deleteButton.layer.cornerRadius = 12 @@ -791,13 +875,22 @@ private final class SampleCoverUploadView: UIControl { fatalError("init(coder:) has not been implemented") } + override func layoutSubviews() { + super.layoutSubviews() + dashedLayer.frame = bounds + dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 8).cgPath + } + func apply(item: SampleUploadedMediaItem?) { guard let item else { imageView.image = nil addCircle.isHidden = false titleLabel.isHidden = false + titleLabel.text = "点击上传图片" + titleLabel.numberOfLines = 1 hintLabel.isHidden = false deleteButton.isHidden = true + dashedLayer.isHidden = false return } if item.isUploading { @@ -808,12 +901,14 @@ private final class SampleCoverUploadView: UIControl { titleLabel.numberOfLines = 2 hintLabel.isHidden = true deleteButton.isHidden = true + dashedLayer.isHidden = true } else { imageView.image = UIImage(data: item.data) addCircle.isHidden = true titleLabel.isHidden = true hintLabel.isHidden = true deleteButton.isHidden = item.ossUrl.isEmpty + dashedLayer.isHidden = true } } @@ -881,3 +976,12 @@ private final class SampleUploadProgressOverlay: UIView { percentLabel.text = "\(max(0, min(100, state.progress)))%" } } + +/// 在不改变 Android 视觉尺寸的前提下,将小图标按钮触控区域扩展到至少 44pt。 +private final class SampleExpandedTouchButton: UIButton { + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + let horizontalInset = max(0, (44 - bounds.width) / 2) + let verticalInset = max(0, (44 - bounds.height) / 2) + return bounds.insetBy(dx: -horizontalInset, dy: -verticalInset).contains(point) + } +} diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift index 20f9809..f5652d8 100644 --- a/suixinkanTests/HomeCommonMenuStoreTests.swift +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -69,7 +69,7 @@ final class HomeCommonMenuStoreTests: XCTestCase { func testHomeMenuCatalogUsesSyncedSymbols() { XCTAssertEqual(HomeMenuCatalog.item(for: "space_settings")?.iconName, "person.crop.square.fill") XCTAssertEqual(HomeMenuCatalog.item(for: "wallet")?.iconName, "creditcard.fill") - XCTAssertEqual(HomeMenuCatalog.item(for: "sample_management")?.iconName, "point.3.connected.trianglepath.dotted") + XCTAssertEqual(HomeMenuCatalog.item(for: "sample_management")?.iconName, "sample_ic_home_menu_route") XCTAssertEqual(HomeMenuCatalog.item(for: "/scenic-queue")?.iconName, "person.3.fill") XCTAssertEqual(HomeMenuItem.moreFunctions.iconName, "ellipsis") } diff --git a/suixinkanTests/SampleManagementAPITests.swift b/suixinkanTests/SampleManagementAPITests.swift index 79cd956..9a2374d 100644 --- a/suixinkanTests/SampleManagementAPITests.swift +++ b/suixinkanTests/SampleManagementAPITests.swift @@ -60,6 +60,16 @@ final class SampleManagementAPITests: XCTestCase { XCTAssertEqual(operationQuery?.first { $0.name == "type" }?.value, "2") } + func testDetailMediaTypeZeroFallsBackToAlbumType() async throws { + let detail = envelopeJSON(#"{"id":8,"type":2,"status":1,"listing_status":1,"media_list":[{"id":1,"type":0,"oss_url":"https://cdn/video.mp4"}]}"#) + let session = MockURLSession(responses: [detail]) + let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.detail(id: 8) + + XCTAssertEqual(response.mediaList?.first?.resolvedType(fallback: response.type ?? 1), 2) + } + func testUploadEncodesAndroidBodyKeys() async throws { let session = MockURLSession(responses: [envelopeJSON("{}")]) let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session)) @@ -97,11 +107,45 @@ final class SampleManagementAPITests: XCTestCase { let media = (body?["media_list"] as? [[String: Any]])?.first XCTAssertEqual(media?["original_name"] as? String, "a.jpg") XCTAssertEqual(media?["oss_url"] as? String, "https://cdn/a.jpg") + XCTAssertEqual(media?["size"] as? Int, 1024) let fileSize = media?["file_width_size"] as? [String: Any] XCTAssertEqual(fileSize?["width"] as? Int, 1920) XCTAssertEqual(fileSize?["height"] as? Int, 1080) } + func testUploadEncodesVideoMetadata() async throws { + let session = MockURLSession(responses: [envelopeJSON("{}")]) + let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session)) + let request = SampleUploadMaterialRequest( + name: "视频样片", + mediaType: .video, + mediaList: [ + SampleUploadMediaItem( + originalName: "rotated.mp4", + ossUrl: "https://cdn/rotated.mp4", + size: 4_096, + fileWidthSize: SampleUploadFileSize(width: 1080, height: 1920) + ), + ], + coverUrl: "https://cdn/cover.jpg", + coverSize: "512", + scenicSpotId: 9, + projectId: 7 + ) + + try await api.upload(request) + + let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(session.requests.first?.httpBody)) as? [String: Any] + XCTAssertEqual(body?["type"] as? String, "2") + XCTAssertEqual(body?["media_type"] as? String, "2") + let media = (body?["media_list"] as? [[String: Any]])?.first + XCTAssertEqual(media?["original_name"] as? String, "rotated.mp4") + XCTAssertEqual(media?["size"] as? Int, 4_096) + let fileSize = media?["file_width_size"] as? [String: Any] + XCTAssertEqual(fileSize?["width"] as? Int, 1080) + XCTAssertEqual(fileSize?["height"] as? Int, 1920) + } + func testProjectAndScenicSpotPaths() async throws { let spots = envelopeJSON(#"{"total":1,"list":[]}"#) let projects = envelopeJSON(#"{"total":1,"list":[]}"#) diff --git a/suixinkanTests/SampleManagementViewModelTests.swift b/suixinkanTests/SampleManagementViewModelTests.swift index f5621bf..eae1c39 100644 --- a/suixinkanTests/SampleManagementViewModelTests.swift +++ b/suixinkanTests/SampleManagementViewModelTests.swift @@ -10,6 +10,13 @@ import XCTest @MainActor /// 样片管理 ViewModel 测试。 final class SampleManagementViewModelTests: XCTestCase { + func testAllFilterStatusesMatchAndroidValues() { + XCTAssertEqual(SampleFilterStatus.all.apiStatus, 4) + XCTAssertEqual(SampleFilterStatus.approved.apiStatus, 1) + XCTAssertEqual(SampleFilterStatus.pending.apiStatus, 0) + XCTAssertEqual(SampleFilterStatus.rejected.apiStatus, 2) + } + func testListLoadsRefreshesAndMapsFilterStatus() async { let api = MockSampleAPI() api.listResponses = [ @@ -36,10 +43,12 @@ final class SampleManagementViewModelTests: XCTestCase { viewModel.onShowMessage = { messages.append($0) } await viewModel.loadInitial(api: api) - await viewModel.toggleListing(sampleId: 1, checked: true, api: api) + let resolved = await viewModel.toggleListing(sampleId: 1, checked: true, api: api) XCTAssertEqual(messages.last, "审核通过才可以操作上下架") XCTAssertTrue(api.operationCalls.isEmpty) + XCTAssertFalse(resolved) + XCTAssertFalse(viewModel.isOperating) } func testApprovedSampleToggleUpdatesLocalItem() async { @@ -48,11 +57,134 @@ final class SampleManagementViewModelTests: XCTestCase { let viewModel = SampleListViewModel() await viewModel.loadInitial(api: api) - await viewModel.toggleListing(sampleId: 2, checked: true, api: api) + let resolved = await viewModel.toggleListing(sampleId: 2, checked: true, api: api) XCTAssertEqual(api.operationCalls.first?.id, 2) XCTAssertEqual(api.operationCalls.first?.listingStatus, 1) XCTAssertEqual(viewModel.items.first?.listingStatus, 1) + XCTAssertTrue(resolved) + XCTAssertFalse(viewModel.isOperating) + } + + func testApprovedSampleExposesOperatingStateDuringRequest() async { + let api = MockSampleAPI() + api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 2, status: 1, listingStatus: 0)])] + api.operationDelayNanoseconds = 50_000_000 + let viewModel = SampleListViewModel() + await viewModel.loadInitial(api: api) + + let operation = Task { await viewModel.toggleListing(sampleId: 2, checked: true, api: api) } + await Task.yield() + + XCTAssertTrue(viewModel.operatingSampleIDs.contains(2)) + XCTAssertTrue(viewModel.isOperating) + let resolved = await operation.value + XCTAssertTrue(resolved) + XCTAssertFalse(viewModel.isOperating) + } + + func testListingFailureReturnsAuthoritativePreviousState() async { + let api = MockSampleAPI() + api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 3, status: 1, listingStatus: 0)])] + api.operationError = APIError.emptyData + let viewModel = SampleListViewModel() + + await viewModel.loadInitial(api: api) + let resolved = await viewModel.toggleListing(sampleId: 3, checked: true, api: api) + + XCTAssertFalse(resolved) + XCTAssertEqual(viewModel.items.first?.listingStatus, 0) + XCTAssertFalse(viewModel.isOperating) + } + + func testSearchAndDuplicateLoadingProtection() async { + let api = MockSampleAPI() + api.listDelayNanoseconds = 50_000_000 + api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 1)])] + let viewModel = SampleListViewModel() + + let first = Task { await viewModel.updateSearchText(" 雪山 ", api: api) } + await Task.yield() + await viewModel.loadInitial(api: api) + await first.value + + XCTAssertEqual(api.listCalls.count, 1) + XCTAssertEqual(api.listCalls.first?.keyword, "雪山") + XCTAssertEqual(api.listCalls.first?.pageSize, 10) + } + + func testChangingMediaTypeClearsMaterialsButKeepsCover() async { + let viewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 }) + let uploader = MockSampleUploader(urls: ["https://cdn/a.jpg", "https://cdn/cover.jpg"]) + + await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader) + await viewModel.setCoverImage(makeUploadItem(fileName: "cover.jpg"), uploader: uploader) + viewModel.selectMediaType(.video) + + XCTAssertTrue(viewModel.uploadedMediaList.isEmpty) + XCTAssertEqual(viewModel.coverImage?.ossUrl, "https://cdn/cover.jpg") + XCTAssertEqual(viewModel.mediaType, .video) + } + + func testScenicCascadeClearsSelectionsAndProjectPagination() async { + let firstScenic = ScenicInfo(id: 3, name: "景区A", status: 1, coverImg: nil) + let secondScenic = ScenicInfo(id: 4, name: "景区B", status: 1, coverImg: nil) + let api = MockSampleAPI() + api.projectResponses = [ + SampleProjectListResponse(list: (0 ..< 10).map { SampleProjectItem(id: $0, name: "项目\($0)") }, total: 11), + SampleProjectListResponse(list: [SampleProjectItem(id: 10, name: "项目10")], total: 11), + ] + let viewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 }) + + await viewModel.selectScenic(firstScenic, api: api) + await viewModel.loadProjects(refresh: true, api: api) + await viewModel.loadProjects(refresh: false, api: api) + viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点")) + viewModel.selectProject(SampleProjectItem(id: 10, name: "项目10")) + + XCTAssertEqual(api.projectCalls.map(\.page), [1, 2]) + XCTAssertEqual(viewModel.projectList.count, 11) + + await viewModel.selectScenic(secondScenic, api: api) + + XCTAssertNil(viewModel.selectedScenicSpot) + XCTAssertNil(viewModel.selectedProject) + XCTAssertTrue(viewModel.projectList.isEmpty) + XCTAssertFalse(viewModel.projectCanLoadMore) + } + + func testMaterialCountLimitsFollowMediaType() async { + let imageViewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 }) + await imageViewModel.addLocalMedia( + (0 ..< 10).map { makeUploadItem(fileName: "\($0).jpg") }, + uploader: MockSampleUploader(urls: (0 ..< 9).map { "https://cdn/\($0).jpg" }) + ) + XCTAssertEqual(imageViewModel.uploadedMediaList.count, 9) + + let videoViewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 }) + videoViewModel.selectMediaType(.video) + await videoViewModel.addLocalMedia( + [makeUploadItem(fileName: "a.mp4"), makeUploadItem(fileName: "b.mp4")], + uploader: MockSampleUploader(urls: ["https://cdn/a.mp4"]) + ) + XCTAssertEqual(videoViewModel.uploadedMediaList.count, 1) + } + + func testSequentialUploadContinuesAfterOneFailure() async { + let viewModel = UploadSampleViewModel(currentScenicIdProvider: { 3 }) + let uploader = MockSampleUploader(results: [ + .failure(APIError.emptyData), + .success("https://cdn/b.jpg"), + ]) + + await viewModel.addLocalMedia( + [makeUploadItem(fileName: "a.jpg"), makeUploadItem(fileName: "b.jpg")], + uploader: uploader + ) + + XCTAssertEqual(viewModel.uploadedMediaList[0].ossUrl, "") + XCTAssertEqual(viewModel.uploadedMediaList[1].ossUrl, "https://cdn/b.jpg") + XCTAssertNil(viewModel.uploadDialogState) } func testUploadValidationMessages() async { @@ -81,6 +213,34 @@ final class SampleManagementViewModelTests: XCTestCase { XCTAssertNil(api.lastUploadRequest) } + func testUploadValidationRequiresScenicSpotThenProject() async { + let scenic = ScenicInfo(id: 3, name: "景区", status: 1, coverImg: nil) + let api = MockSampleAPI() + let viewModel = UploadSampleViewModel( + scenicListProvider: { [scenic] }, + currentScenicIdProvider: { 3 } + ) + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + viewModel.updateAlbumName("样片") + await viewModel.addLocalMedia( + [makeUploadItem(fileName: "a.jpg")], + uploader: MockSampleUploader(urls: ["https://cdn/a.jpg"]) + ) + await viewModel.setCoverImage( + makeUploadItem(fileName: "cover.jpg"), + uploader: MockSampleUploader(urls: ["https://cdn/cover.jpg"]) + ) + await viewModel.selectScenic(scenic, api: api) + + await viewModel.uploadSample(api: api) + viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点")) + await viewModel.uploadSample(api: api) + + XCTAssertEqual(Array(messages.suffix(2)), ["请选择关联打卡点", "请选择关联项目"]) + XCTAssertNil(api.lastUploadRequest) + } + func testUploadSuccessBuildsSampleRequest() async { let scenic = ScenicInfo(id: 3, name: "景区", status: 1, coverImg: nil) let api = MockSampleAPI() @@ -119,10 +279,17 @@ private final class MockSampleAPI: SampleManagementServing { var operationCalls: [(id: Int, listingStatus: Int)] = [] var lastUploadRequest: SampleUploadMaterialRequest? var spotResponse = ScenicSpotListResponse(list: [], total: 0) - var projectResponse = SampleProjectListResponse(list: [], total: 0) + var projectResponses: [SampleProjectListResponse] = [] + var operationError: Error? + var listDelayNanoseconds: UInt64 = 0 + var operationDelayNanoseconds: UInt64 = 0 + var projectCalls: [(scenicId: String, page: Int, pageSize: Int)] = [] func list(status: Int, keyword: String?, page: Int, pageSize: Int) async throws -> SampleMaterialListResponse { listCalls.append((status, keyword, page, pageSize)) + if listDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: listDelayNanoseconds) + } guard !listResponses.isEmpty else { return SampleMaterialListResponse() } return listResponses.removeFirst() } @@ -133,6 +300,10 @@ private final class MockSampleAPI: SampleManagementServing { func setListingStatus(id: Int, listingStatus: Int) async throws { operationCalls.append((id, listingStatus)) + if operationDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: operationDelayNanoseconds) + } + if let operationError { throw operationError } } func upload(_ request: SampleUploadMaterialRequest) async throws { @@ -144,16 +315,22 @@ private final class MockSampleAPI: SampleManagementServing { } func projectList(scenicId: String, name: String?, page: Int, pageSize: Int) async throws -> SampleProjectListResponse { - projectResponse + projectCalls.append((scenicId, page, pageSize)) + guard !projectResponses.isEmpty else { return SampleProjectListResponse() } + return projectResponses.removeFirst() } } @MainActor private final class MockSampleUploader: SampleOSSUploading { - private var urls: [String] + private var results: [Result] init(urls: [String]) { - self.urls = urls + results = urls.map(Result.success) + } + + init(results: [Result]) { + self.results = results } func uploadSampleFile( @@ -164,7 +341,8 @@ private final class MockSampleUploader: SampleOSSUploading { onProgress: @escaping (Int) -> Void ) async throws -> String { onProgress(100) - return urls.isEmpty ? "https://cdn/default.jpg" : urls.removeFirst() + guard !results.isEmpty else { return "https://cdn/default.jpg" } + return try results.removeFirst().get() } }