重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
826 lines
30 KiB
Swift
826 lines
30 KiB
Swift
//
|
|
// SampleDetailViewController.swift
|
|
// suixinkan
|
|
//
|
|
|
|
import AVFoundation
|
|
import Kingfisher
|
|
import SnapKit
|
|
import UIKit
|
|
|
|
/// 样片详情页,对齐 Android `SampleDetailScreen`。
|
|
final class SampleDetailViewController: SampleBaseViewController {
|
|
private let viewModel: SampleDetailViewModel
|
|
private let api: any SampleManagementServing
|
|
|
|
private let scrollView = UIScrollView()
|
|
private let contentStack = UIStackView()
|
|
private let carouselView = SampleMediaCarouselView()
|
|
private let sampleInfoSection = SampleDetailSampleInfoSection()
|
|
private let uploaderSection = SampleDetailUploaderSection()
|
|
private let scenicSection = SampleDetailImageInfoSection(title: "打卡地点")
|
|
private let projectSection = SampleDetailImageInfoSection(title: "样片绑定套餐")
|
|
private let reviewSection = SampleDetailReviewSection()
|
|
private let bottomSpacer = UIView()
|
|
|
|
/// 初始化样片详情页。
|
|
init(
|
|
sampleId: Int,
|
|
api: (any SampleManagementServing)? = nil
|
|
) {
|
|
viewModel = SampleDetailViewModel(sampleId: sampleId)
|
|
self.api = api ?? NetworkServices.shared.sampleManagementAPI
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func setupNavigationBar() {
|
|
super.setupNavigationBar()
|
|
setSampleTitle("样片详情")
|
|
}
|
|
|
|
override func setupUI() {
|
|
super.setupUI()
|
|
view.backgroundColor = SampleManagementUITokens.pageBackground
|
|
contentStack.axis = .vertical
|
|
contentStack.spacing = 0
|
|
scrollView.backgroundColor = SampleManagementUITokens.pageBackground
|
|
|
|
view.addSubview(scrollView)
|
|
scrollView.addSubview(contentStack)
|
|
contentStack.addArrangedSubview(carouselView)
|
|
contentStack.addArrangedSubview(sampleInfoSection)
|
|
contentStack.addArrangedSubview(uploaderSection)
|
|
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.top.equalTo(view.safeAreaLayoutGuide)
|
|
make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
|
|
}
|
|
contentStack.snp.makeConstraints { make in
|
|
make.edges.equalToSuperview()
|
|
make.width.equalTo(scrollView)
|
|
}
|
|
carouselView.snp.makeConstraints { make in
|
|
make.height.equalTo(carouselView.snp.width).multipliedBy(9.0 / 16.0)
|
|
}
|
|
}
|
|
|
|
override func bindActions() {
|
|
viewModel.onStateChange = { [weak self] in
|
|
Task { @MainActor in self?.applyViewModel() }
|
|
}
|
|
viewModel.onShowMessage = { [weak self] message in
|
|
Task { @MainActor in self?.showToast(message) }
|
|
}
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
Task { await viewModel.load(api: api) }
|
|
}
|
|
|
|
override func viewWillDisappear(_ animated: Bool) {
|
|
super.viewWillDisappear(animated)
|
|
carouselView.pause()
|
|
}
|
|
|
|
private func applyViewModel() {
|
|
if viewModel.isLoading && viewModel.detail == nil {
|
|
showLoading()
|
|
} else {
|
|
hideLoading()
|
|
}
|
|
guard let detail = viewModel.detail else { return }
|
|
carouselView.apply(media: detail.mediaList ?? [], fallbackType: detail.type ?? 1)
|
|
sampleInfoSection.apply(detail: detail)
|
|
uploaderSection.apply(detail: detail)
|
|
scenicSection.applyScenic(detail: detail)
|
|
projectSection.applyProject(detail: detail)
|
|
reviewSection.apply(detail: detail)
|
|
}
|
|
}
|
|
|
|
/// 样片详情媒体轮播,支持图片自动切换与视频播完切换。
|
|
private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
|
private let scrollView = UIScrollView()
|
|
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) {
|
|
super.init(frame: frame)
|
|
backgroundColor = UIColor(hex: 0xF5F5F5)
|
|
scrollView.isPagingEnabled = true
|
|
scrollView.showsHorizontalScrollIndicator = false
|
|
scrollView.delegate = self
|
|
scrollView.bounces = false
|
|
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(indicatorStack)
|
|
addSubview(placeholderLabel)
|
|
scrollView.snp.makeConstraints { make in
|
|
make.edges.equalToSuperview()
|
|
}
|
|
indicatorStack.snp.makeConstraints { make in
|
|
make.centerX.equalToSuperview()
|
|
make.bottom.equalToSuperview().inset(12)
|
|
make.height.equalTo(8)
|
|
}
|
|
placeholderLabel.snp.makeConstraints { make in
|
|
make.center.equalToSuperview()
|
|
}
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
layoutPages()
|
|
}
|
|
|
|
/// 应用媒体列表。
|
|
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() }
|
|
rebuildIndicators(currentIndex: 0)
|
|
indicatorStack.isHidden = media.count <= 1
|
|
placeholderLabel.isHidden = !media.isEmpty
|
|
scrollView.isHidden = media.isEmpty
|
|
guard !media.isEmpty else { return }
|
|
|
|
for (renderedIndex, mediaIndex) in renderedMediaIndices.enumerated() {
|
|
let item = media[mediaIndex]
|
|
let page = UIView()
|
|
page.backgroundColor = UIColor(hex: 0xF5F5F5)
|
|
page.tag = renderedIndex
|
|
scrollView.addSubview(page)
|
|
pageViews.append(page)
|
|
if item.resolvedType(fallback: fallbackType) == 2 {
|
|
addVideo(urlText: item.displayURL, to: page, index: renderedIndex)
|
|
} else {
|
|
addImage(urlText: item.displayURL, to: page)
|
|
}
|
|
}
|
|
setNeedsLayout()
|
|
layoutIfNeeded()
|
|
playCurrentPageIfNeeded()
|
|
}
|
|
|
|
/// 暂停当前轮播。
|
|
func pause() {
|
|
stopTimer()
|
|
players.values.forEach { $0.pause() }
|
|
}
|
|
|
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
updateCurrentPage()
|
|
}
|
|
|
|
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
|
updateCurrentPage()
|
|
}
|
|
|
|
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
|
stopTimer()
|
|
}
|
|
|
|
private func addImage(urlText: String, to page: UIView) {
|
|
let imageView = UIImageView()
|
|
imageView.contentMode = .scaleAspectFill
|
|
imageView.clipsToBounds = true
|
|
page.addSubview(imageView)
|
|
imageView.snp.makeConstraints { make in
|
|
make.edges.equalToSuperview()
|
|
}
|
|
if let url = URL(string: urlText), !urlText.isEmpty {
|
|
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")
|
|
}
|
|
}
|
|
|
|
private func addVideo(urlText: String, to page: UIView, index: Int) {
|
|
guard let url = URL(string: urlText), !urlText.isEmpty else {
|
|
addImage(urlText: "", to: page)
|
|
return
|
|
}
|
|
let player = AVPlayer(url: url)
|
|
players[index] = player
|
|
let playerView = SamplePlayerLayerView()
|
|
playerView.configure(player: player)
|
|
playerViews[index] = playerView
|
|
page.addSubview(playerView)
|
|
playerView.snp.makeConstraints { make in
|
|
make.edges.equalToSuperview()
|
|
}
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(videoDidEnd(_:)),
|
|
name: .AVPlayerItemDidPlayToEndTime,
|
|
object: player.currentItem
|
|
)
|
|
}
|
|
|
|
private func layoutPages() {
|
|
let size = bounds.size
|
|
guard size.width > 0, size.height > 0 else { return }
|
|
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(renderedMediaIndices.count), height: size.height)
|
|
scrollView.contentOffset = CGPoint(x: CGFloat(currentRenderedPage) * size.width, y: 0)
|
|
}
|
|
|
|
private func updateCurrentPage() {
|
|
guard bounds.width > 0, !renderedMediaIndices.isEmpty else { return }
|
|
let page = Int(round(scrollView.contentOffset.x / bounds.width))
|
|
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 == 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[currentMediaIndex].resolvedType(fallback: fallbackType) == 1 {
|
|
startImageTimer()
|
|
}
|
|
}
|
|
|
|
private func startImageTimer() {
|
|
guard media.count > 1 else { return }
|
|
timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in
|
|
self?.advancePage()
|
|
}
|
|
}
|
|
|
|
private func stopTimer() {
|
|
timer?.invalidate()
|
|
timer = nil
|
|
}
|
|
|
|
@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 = 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
|
|
self?.playCurrentPageIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
|
|
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 容器,提供点击显示且三秒自动隐藏的播放控制。
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// 样片详情名称与上架状态区。
|
|
private final class SampleDetailSampleInfoSection: UIView {
|
|
private let titleLabel = UILabel()
|
|
private let listingBadge = UILabel()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
backgroundColor = .white
|
|
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
|
|
titleLabel.textColor = UIColor(hex: 0x2E3746)
|
|
titleLabel.numberOfLines = 0
|
|
listingBadge.font = .systemFont(ofSize: 12)
|
|
listingBadge.layer.cornerRadius = 4
|
|
listingBadge.clipsToBounds = true
|
|
listingBadge.textAlignment = .center
|
|
addSubview(titleLabel)
|
|
addSubview(listingBadge)
|
|
titleLabel.snp.makeConstraints { make in
|
|
make.top.bottom.equalToSuperview().inset(16)
|
|
make.leading.equalToSuperview().offset(16)
|
|
make.trailing.lessThanOrEqualTo(listingBadge.snp.leading).offset(-12)
|
|
}
|
|
listingBadge.snp.makeConstraints { make in
|
|
make.trailing.equalToSuperview().inset(16)
|
|
make.centerY.equalTo(titleLabel)
|
|
make.height.equalTo(24)
|
|
make.width.greaterThanOrEqualTo(54)
|
|
}
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func apply(detail: SampleDetail) {
|
|
titleLabel.text = detail.name ?? ""
|
|
let listed = detail.listingStatus == 1
|
|
listingBadge.text = listed ? "已上架" : "未上架"
|
|
let color = listed ? UIColor(hex: 0x22C55E) : AppColor.textSecondary
|
|
listingBadge.textColor = color
|
|
listingBadge.backgroundColor = color.withAlphaComponent(0.1)
|
|
}
|
|
}
|
|
|
|
/// 样片上传人信息区。
|
|
private final class SampleDetailUploaderSection: UIView {
|
|
private let titleLabel = UILabel()
|
|
private let avatarView = UIImageView()
|
|
private let nameLabel = UILabel()
|
|
private let tagStack = UIStackView()
|
|
private let descriptionLabel = UILabel()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
backgroundColor = .white
|
|
titleLabel.text = "上传人"
|
|
titleLabel.font = .systemFont(ofSize: 14, weight: .bold)
|
|
titleLabel.textColor = .black
|
|
avatarView.contentMode = .scaleAspectFill
|
|
avatarView.clipsToBounds = true
|
|
avatarView.layer.cornerRadius = 24
|
|
avatarView.backgroundColor = UIColor(hex: 0xF5F5F5)
|
|
nameLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
|
nameLabel.textColor = UIColor(hex: 0x002255)
|
|
descriptionLabel.font = .systemFont(ofSize: 14)
|
|
descriptionLabel.textColor = UIColor(hex: 0x7B8EAA)
|
|
descriptionLabel.numberOfLines = 0
|
|
tagStack.axis = .horizontal
|
|
tagStack.spacing = 8
|
|
|
|
addSubview(titleLabel)
|
|
addSubview(avatarView)
|
|
addSubview(nameLabel)
|
|
addSubview(tagStack)
|
|
addSubview(descriptionLabel)
|
|
|
|
titleLabel.snp.makeConstraints { make in
|
|
make.top.leading.trailing.equalToSuperview().inset(16)
|
|
}
|
|
avatarView.snp.makeConstraints { make in
|
|
make.top.equalTo(titleLabel.snp.bottom).offset(12)
|
|
make.leading.equalToSuperview().offset(16)
|
|
make.size.equalTo(48)
|
|
make.bottom.lessThanOrEqualToSuperview().inset(16)
|
|
}
|
|
nameLabel.snp.makeConstraints { make in
|
|
make.top.equalTo(avatarView)
|
|
make.leading.equalTo(avatarView.snp.trailing).offset(12)
|
|
}
|
|
tagStack.snp.makeConstraints { make in
|
|
make.leading.equalTo(nameLabel.snp.trailing).offset(8)
|
|
make.centerY.equalTo(nameLabel)
|
|
make.trailing.lessThanOrEqualToSuperview().inset(16)
|
|
}
|
|
descriptionLabel.snp.makeConstraints { make in
|
|
make.top.equalTo(nameLabel.snp.bottom).offset(4)
|
|
make.leading.equalTo(nameLabel)
|
|
make.trailing.equalToSuperview().inset(16)
|
|
make.bottom.equalToSuperview().inset(16)
|
|
}
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func apply(detail: SampleDetail) {
|
|
nameLabel.text = detail.uploaderName ?? ""
|
|
descriptionLabel.text = detail.uploaderDescription ?? ""
|
|
tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
|
if detail.platformCertification {
|
|
tagStack.addArrangedSubview(makeTag("平台认证"))
|
|
}
|
|
if detail.scenicCertification {
|
|
tagStack.addArrangedSubview(makeTag("景区认证"))
|
|
}
|
|
if let urlText = detail.uploaderAvatar, let url = URL(string: urlText), !urlText.isEmpty {
|
|
avatarView.kf.setImage(with: url, placeholder: UIImage(systemName: "person.crop.circle.fill"))
|
|
} else {
|
|
avatarView.image = UIImage(systemName: "person.crop.circle.fill")
|
|
avatarView.tintColor = AppColor.textTertiary
|
|
}
|
|
}
|
|
|
|
private func makeTag(_ title: String) -> UILabel {
|
|
let label = UILabel()
|
|
label.text = title
|
|
label.textColor = .white
|
|
label.font = .systemFont(ofSize: 8)
|
|
label.textAlignment = .center
|
|
label.backgroundColor = AppColor.primary
|
|
label.layer.cornerRadius = 4
|
|
label.clipsToBounds = true
|
|
label.snp.makeConstraints { make in
|
|
make.height.equalTo(16)
|
|
make.width.greaterThanOrEqualTo(44)
|
|
}
|
|
return label
|
|
}
|
|
}
|
|
|
|
/// 样片详情图片信息区,复用打卡地点与绑定套餐布局。
|
|
private final class SampleDetailImageInfoSection: UIView {
|
|
private let sectionTitleLabel = UILabel()
|
|
private let imageView = UIImageView()
|
|
private let titleLabel = UILabel()
|
|
private let firstLineLabel = UILabel()
|
|
private let secondLineLabel = UILabel()
|
|
|
|
init(title: String) {
|
|
super.init(frame: .zero)
|
|
backgroundColor = .white
|
|
sectionTitleLabel.text = title
|
|
sectionTitleLabel.font = .systemFont(ofSize: 14, weight: .bold)
|
|
sectionTitleLabel.textColor = .black
|
|
imageView.backgroundColor = UIColor(hex: 0xF5F5F5)
|
|
imageView.contentMode = .scaleAspectFill
|
|
imageView.clipsToBounds = true
|
|
imageView.layer.cornerRadius = 12
|
|
titleLabel.font = .systemFont(ofSize: title == "打卡地点" ? 14 : 16, weight: .bold)
|
|
titleLabel.textColor = .black
|
|
[firstLineLabel, secondLineLabel].forEach { label in
|
|
label.font = .systemFont(ofSize: 12)
|
|
label.textColor = UIColor(hex: 0x4B5563)
|
|
label.numberOfLines = 1
|
|
}
|
|
|
|
addSubview(sectionTitleLabel)
|
|
addSubview(imageView)
|
|
addSubview(titleLabel)
|
|
addSubview(firstLineLabel)
|
|
addSubview(secondLineLabel)
|
|
sectionTitleLabel.snp.makeConstraints { make in
|
|
make.top.leading.trailing.equalToSuperview().inset(16)
|
|
}
|
|
imageView.snp.makeConstraints { make in
|
|
make.top.equalTo(sectionTitleLabel.snp.bottom).offset(12)
|
|
make.leading.equalToSuperview().offset(16)
|
|
make.height.equalTo(100)
|
|
make.width.equalTo(179)
|
|
make.bottom.equalToSuperview().inset(16)
|
|
}
|
|
titleLabel.snp.makeConstraints { make in
|
|
make.top.equalTo(imageView).offset(2)
|
|
make.leading.equalTo(imageView.snp.trailing).offset(12)
|
|
make.trailing.equalToSuperview().inset(16)
|
|
}
|
|
firstLineLabel.snp.makeConstraints { make in
|
|
make.top.equalTo(titleLabel.snp.bottom).offset(12)
|
|
make.leading.trailing.equalTo(titleLabel)
|
|
}
|
|
secondLineLabel.snp.makeConstraints { make in
|
|
make.top.equalTo(firstLineLabel.snp.bottom).offset(7)
|
|
make.leading.trailing.equalTo(titleLabel)
|
|
}
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func applyScenic(detail: SampleDetail) {
|
|
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 {
|
|
firstLineLabel.text = ""
|
|
}
|
|
if let deposit = detail.projectPriceDeposit {
|
|
secondLineLabel.attributedText = coloredValue(prefix: "定金: ", value: "\(deposit)元")
|
|
} else {
|
|
secondLineLabel.text = ""
|
|
}
|
|
setImage(detail.projectCover)
|
|
}
|
|
|
|
private func setImage(_ urlText: String?) {
|
|
if let urlText, let url = URL(string: urlText), !urlText.isEmpty {
|
|
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 = UIImage(named: "sample_img_error")
|
|
}
|
|
}
|
|
|
|
private func coloredValue(prefix: String, value: String) -> NSAttributedString {
|
|
let text = NSMutableAttributedString(
|
|
string: prefix,
|
|
attributes: [.foregroundColor: UIColor(hex: 0x4B5563), .font: UIFont.systemFont(ofSize: 12)]
|
|
)
|
|
text.append(
|
|
NSAttributedString(
|
|
string: value,
|
|
attributes: [.foregroundColor: AppColor.primary, .font: UIFont.systemFont(ofSize: 12)]
|
|
)
|
|
)
|
|
return text
|
|
}
|
|
}
|
|
|
|
/// 样片审核信息区。
|
|
private final class SampleDetailReviewSection: UIView {
|
|
private let stack = UIStackView()
|
|
private let titleLabel = UILabel()
|
|
private let reviewerRow = SampleDetailInfoRow()
|
|
private let reviewTimeRow = SampleDetailInfoRow()
|
|
private let statusRow = SampleDetailStatusRow()
|
|
private let noteRow = SampleDetailInfoRow()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
backgroundColor = .white
|
|
stack.axis = .vertical
|
|
stack.spacing = 0
|
|
titleLabel.text = "审核信息"
|
|
titleLabel.font = .systemFont(ofSize: 14, weight: .bold)
|
|
titleLabel.textColor = .black
|
|
addSubview(stack)
|
|
stack.snp.makeConstraints { make in
|
|
make.edges.equalToSuperview().inset(16)
|
|
}
|
|
stack.addArrangedSubview(titleLabel)
|
|
[reviewerRow, reviewTimeRow, statusRow, noteRow].forEach { row in
|
|
stack.addArrangedSubview(row)
|
|
}
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func apply(detail: SampleDetail) {
|
|
reviewerRow.apply(label: "审核人", value: detail.reviewer?.nonEmpty ?? "--")
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// 样片详情普通信息行。
|
|
private final class SampleDetailInfoRow: UIView {
|
|
private let label = UILabel()
|
|
private let valueLabel = UILabel()
|
|
private let separator = UIView()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
label.font = .systemFont(ofSize: 14)
|
|
label.textColor = UIColor(hex: 0x2E3746)
|
|
valueLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
|
valueLabel.textColor = .black
|
|
valueLabel.textAlignment = .right
|
|
valueLabel.numberOfLines = 1
|
|
separator.backgroundColor = UIColor(hex: 0xEEEEEE)
|
|
addSubview(label)
|
|
addSubview(valueLabel)
|
|
addSubview(separator)
|
|
snp.makeConstraints { make in
|
|
make.height.equalTo(44)
|
|
}
|
|
label.snp.makeConstraints { make in
|
|
make.leading.centerY.equalToSuperview()
|
|
}
|
|
valueLabel.snp.makeConstraints { make in
|
|
make.trailing.centerY.equalToSuperview()
|
|
make.leading.greaterThanOrEqualTo(label.snp.trailing).offset(12)
|
|
}
|
|
separator.snp.makeConstraints { make in
|
|
make.leading.trailing.bottom.equalToSuperview()
|
|
make.height.equalTo(1)
|
|
}
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func apply(label: String, value: String, valueColor: UIColor = .black, valueFont: UIFont = .systemFont(ofSize: 14, weight: .medium)) {
|
|
self.label.text = label
|
|
valueLabel.text = value
|
|
valueLabel.textColor = valueColor
|
|
valueLabel.font = valueFont
|
|
}
|
|
|
|
/// 控制行底部分隔线显示。
|
|
func setSeparatorHidden(_ hidden: Bool) {
|
|
separator.isHidden = hidden
|
|
}
|
|
}
|
|
|
|
/// 样片详情审核状态行。
|
|
private final class SampleDetailStatusRow: UIView {
|
|
private let label = UILabel()
|
|
private let badge = SampleStatusBadgeView()
|
|
private let separator = UIView()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
label.text = "审核状态"
|
|
label.font = .systemFont(ofSize: 14)
|
|
label.textColor = UIColor(hex: 0x2E3746)
|
|
separator.backgroundColor = UIColor(hex: 0xEEEEEE)
|
|
addSubview(label)
|
|
addSubview(badge)
|
|
addSubview(separator)
|
|
snp.makeConstraints { make in
|
|
make.height.equalTo(44)
|
|
}
|
|
label.snp.makeConstraints { make in
|
|
make.leading.centerY.equalToSuperview()
|
|
}
|
|
badge.snp.makeConstraints { make in
|
|
make.trailing.centerY.equalToSuperview()
|
|
make.height.equalTo(24)
|
|
}
|
|
separator.snp.makeConstraints { make in
|
|
make.leading.trailing.bottom.equalToSuperview()
|
|
make.height.equalTo(1)
|
|
}
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func apply(status: Int) {
|
|
badge.apply(status: status)
|
|
}
|
|
}
|
|
|
|
private extension String {
|
|
var nonEmpty: String? { isEmpty ? nil : self }
|
|
}
|