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()
}
}