完善样片管理列表、详情与上传页 UI 交互。
对齐 Android 样片模块布局与加载状态,补充共享 UI 组件与图片资源,并增强列表解析容错与单元测试覆盖。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// 样片详情审核状态行。
|
||||
|
||||
Reference in New Issue
Block a user