// // 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 = 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() { 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 } }