完善样片管理列表、详情与上传页 UI 交互。

对齐 Android 样片模块布局与加载状态,补充共享 UI 组件与图片资源,并增强列表解析容错与单元测试覆盖。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 15:00:18 +08:00
parent cbf2db649e
commit afcd412f11
16 changed files with 862 additions and 134 deletions

View File

@ -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
}
}
///