feat: 同步样片管理功能

This commit is contained in:
2026-07-08 13:06:11 +08:00
parent e88cd4a9a4
commit ccd63380cf
22 changed files with 3791 additions and 3 deletions

View File

@ -0,0 +1,682 @@
//
// SampleDetailViewController.swift
// suixinkan
//
import AVFoundation
import Kingfisher
import SnapKit
import UIKit
/// Android `SampleDetailScreen`
final class SampleDetailViewController: BaseViewController {
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()
///
init(
sampleId: Int,
api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI
) {
viewModel = SampleDetailViewModel(sampleId: sampleId)
self.api = api
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "样片详情"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF4F4F4)
contentStack.axis = .vertical
contentStack.spacing = 12
scrollView.backgroundColor = UIColor(hex: 0xF4F4F4)
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
contentStack.addArrangedSubview(carouselView)
contentStack.addArrangedSubview(sampleInfoSection)
contentStack.addArrangedSubview(uploaderSection)
contentStack.addArrangedSubview(scenicSection)
contentStack.addArrangedSubview(projectSection)
contentStack.addArrangedSubview(reviewSection)
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.edges.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 ?? [])
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 pageControl = UIPageControl()
private let placeholderLabel = UILabel()
private var media: [SampleDetailMediaItem] = []
private var players: [Int: AVPlayer] = [:]
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
pageControl.currentPageIndicatorTintColor = .white
pageControl.pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.5)
placeholderLabel.text = "暂无媒体"
placeholderLabel.font = .systemFont(ofSize: 14)
placeholderLabel.textColor = AppColor.textSecondary
placeholderLabel.textAlignment = .center
addSubview(scrollView)
addSubview(pageControl)
addSubview(placeholderLabel)
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
pageControl.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.bottom.equalToSuperview().inset(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]) {
stopTimer()
NotificationCenter.default.removeObserver(self)
players.values.forEach { $0.pause() }
players.removeAll()
self.media = media
scrollView.subviews.forEach { $0.removeFromSuperview() }
pageControl.numberOfPages = media.count
pageControl.currentPage = 0
pageControl.isHidden = media.count <= 1
placeholderLabel.isHidden = !media.isEmpty
scrollView.isHidden = media.isEmpty
guard !media.isEmpty else { return }
for (index, item) in media.enumerated() {
let page = UIView()
page.backgroundColor = UIColor(hex: 0xF5F5F5)
page.tag = index
scrollView.addSubview(page)
if item.type == 2 {
addVideo(urlText: item.displayURL, to: page, index: index)
} 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()
}
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: "img_square_loading_big"))
}
}
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.playerLayer.player = player
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 scrollView.subviews.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)
}
private func updateCurrentPage() {
guard bounds.width > 0 else { return }
let page = Int(round(scrollView.contentOffset.x / bounds.width))
pageControl.currentPage = max(0, min(page, media.count - 1))
playCurrentPageIfNeeded()
}
private func playCurrentPageIfNeeded() {
stopTimer()
players.forEach { index, player in
if index == pageControl.currentPage {
player.seek(to: .zero)
player.play()
} else {
player.pause()
}
}
guard !media.isEmpty else { return }
if media[pageControl.currentPage].type == 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) {
advancePage()
}
private func advancePage() {
guard media.count > 1, bounds.width > 0 else { return }
let nextPage = (pageControl.currentPage + 1) % media.count
pageControl.currentPage = 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()
}
}
}
}
/// AVPlayerLayer
private final class SamplePlayerLayerView: UIView {
override class var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
}
///
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)")
setImage(detail.scenicCoverImg)
}
func applyProject(detail: SampleDetail) {
titleLabel.text = detail.projectName ?? ""
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)
} else {
imageView.image = nil
}
}
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))
}
}
///
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
}
}
///
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 }
}