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