feat: 更新素材管理和举报风险地图

This commit is contained in:
2026-07-09 12:20:02 +08:00
parent 9b92d81902
commit 42aca73588
42 changed files with 6164 additions and 1369 deletions

View File

@ -0,0 +1,710 @@
//
// MaterialDetailViewController.swift
// suixinkan
//
import AVFoundation
import Kingfisher
import SnapKit
import UIKit
/// Android `MaterialDetailNewScreen`
///
final class MaterialDetailViewController: BaseViewController {
private let viewModel: MaterialDetailViewModel
private let api: any MaterialManagementServing
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let carouselView = MaterialMediaCarouselView()
private let infoSection = MaterialDetailInfoSection()
private let uploaderSection = MaterialDetailUploaderSection()
private let reviewSection = MaterialDetailReviewSection()
private let bottomBar = UIView()
private let deleteButton = UIButton(type: .system)
private let editButton = UIButton(type: .system)
var onDeleted: ((Int) -> Void)?
var onEdited: (() -> Void)?
///
init(
materialId: Int,
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI
) {
viewModel = MaterialDetailViewModel(materialId: materialId)
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)
bottomBar.backgroundColor = .white
configureBottomButton(deleteButton, title: "删除", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary)
configureBottomButton(editButton, title: "编辑", backgroundColor: AppColor.primary, titleColor: .white)
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
contentStack.addArrangedSubview(carouselView)
contentStack.addArrangedSubview(infoSection)
contentStack.addArrangedSubview(uploaderSection)
contentStack.addArrangedSubview(reviewSection)
view.addSubview(bottomBar)
bottomBar.addSubview(deleteButton)
bottomBar.addSubview(editButton)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
deleteButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalToSuperview().offset(16)
make.width.equalTo(100)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
}
editButton.snp.makeConstraints { make in
make.top.height.equalTo(deleteButton)
make.leading.equalTo(deleteButton.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(16)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top)
}
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) }
}
viewModel.onDeleted = { [weak self] id in
Task { @MainActor in
self?.onDeleted?(id)
self?.navigationController?.popViewController(animated: true)
}
}
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
editButton.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.load(api: api) }
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
carouselView.pause()
}
private func configureBottomButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) {
button.setTitle(title, for: .normal)
button.setTitleColor(titleColor, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.backgroundColor = backgroundColor
button.layer.cornerRadius = 8
}
@MainActor
private func applyViewModel() {
if (viewModel.isLoading || viewModel.isDeleting) && viewModel.detail == nil {
showLoading()
} else {
hideLoading()
}
guard let detail = viewModel.detail else { return }
carouselView.apply(media: detail.mediaList ?? [])
infoSection.apply(detail: detail)
uploaderSection.apply(detail: detail)
reviewSection.apply(detail: detail)
}
@objc private func deleteTapped() {
let alert = UIAlertController(title: "确认删除", message: "确定删除该素材吗?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
guard let self else { return }
Task {
self.showLoading()
await self.viewModel.delete(api: self.api)
self.hideLoading()
}
})
present(alert, animated: true)
}
@objc private func editTapped() {
let controller = MaterialFormViewController(mode: .edit(id: viewModel.materialId))
controller.onSubmitSuccess = { [weak self] in
guard let self else { return }
self.onEdited?()
Task { await self.viewModel.load(api: self.api) }
}
navigationController?.pushViewController(controller, animated: true)
}
}
///
///
private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
private let scrollView = UIScrollView()
private let pageControl = UIPageControl()
private let placeholderLabel = UILabel()
private var media: [MaterialDetailMediaItem] = []
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: [MaterialDetailMediaItem]) {
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)
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)
}
}
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 = MaterialPlayerLayerView()
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)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.playCurrentPageIfNeeded()
}
}
}
/// AVPlayerLayer
/// AVPlayerLayer
private final class MaterialPlayerLayerView: UIView {
override class var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
}
///
///
private final class MaterialDetailInfoSection: UIView {
private let titleLabel = UILabel()
private let listingBadge = UILabel()
private let tagStack = UIStackView()
private let statsStack = UIStackView()
private let downloadStat = MaterialDetailStatView(systemIconName: "arrow.down.circle")
private let likeStat = MaterialDetailStatView(iconName: "sample_ic_like_count")
private let collectStat = MaterialDetailStatView(iconName: "sample_ic_collect_count")
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
tagStack.axis = .horizontal
tagStack.spacing = 8
tagStack.alignment = .leading
statsStack.axis = .horizontal
statsStack.spacing = 16
[downloadStat, likeStat, collectStat].forEach(statsStack.addArrangedSubview)
addSubview(titleLabel)
addSubview(listingBadge)
addSubview(tagStack)
addSubview(statsStack)
titleLabel.snp.makeConstraints { make in
make.top.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)
}
tagStack.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
}
statsStack.snp.makeConstraints { make in
make.top.equalTo(tagStack.snp.bottom).offset(12)
make.leading.equalToSuperview().offset(16)
make.trailing.lessThanOrEqualToSuperview().inset(16)
make.bottom.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(detail: MaterialDetail) {
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)
tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
(detail.materialTag ?? []).forEach { tagStack.addArrangedSubview(makeTag($0.name)) }
downloadStat.setCount(detail.downloadCount)
likeStat.setCount(detail.likesCount)
collectStat.setCount(detail.collectCount)
}
private func makeTag(_ title: String) -> UILabel {
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: 12)
label.textColor = AppColor.primary
label.textAlignment = .center
label.backgroundColor = UIColor(hex: 0xEFF6FF)
label.layer.cornerRadius = 4
label.clipsToBounds = true
label.snp.makeConstraints { make in
make.height.equalTo(22)
make.width.greaterThanOrEqualTo(40)
}
return label
}
}
///
///
private final class MaterialDetailUploaderSection: 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: 16, weight: .bold)
titleLabel.textColor = AppColor.textPrimary
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: MaterialDetail) {
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 MaterialDetailReviewSection: UIView {
private let titleLabel = UILabel()
private let stack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
titleLabel.text = "审核信息"
titleLabel.font = .systemFont(ofSize: 14, weight: .bold)
titleLabel.textColor = .black
stack.axis = .vertical
stack.spacing = 0
addSubview(titleLabel)
addSubview(stack)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(16)
}
stack.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(detail: MaterialDetail) {
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
stack.addArrangedSubview(MaterialInfoRow(label: "审核人", value: detail.reviewer ?? "--"))
stack.addArrangedSubview(separator())
stack.addArrangedSubview(MaterialInfoRow(label: "审核时间", value: detail.reviewTime ?? "--"))
stack.addArrangedSubview(separator())
stack.addArrangedSubview(MaterialStatusRow(status: detail.status))
stack.addArrangedSubview(separator())
stack.addArrangedSubview(MaterialInfoRow(label: "审核备注", value: detail.reviewNotes ?? "--", valueColor: UIColor(hex: 0x4B5563), valueFont: .systemFont(ofSize: 12)))
}
private func separator() -> UIView {
let view = UIView()
view.backgroundColor = UIColor(hex: 0xEEEEEE)
view.snp.makeConstraints { make in
make.height.equalTo(1)
}
return view
}
}
///
///
private final class MaterialDetailStatView: UIView {
private let iconView = UIImageView()
private let countLabel = UILabel()
init(iconName: String? = nil, systemIconName: String? = nil) {
super.init(frame: .zero)
if let iconName {
iconView.image = UIImage(named: iconName)
} else if let systemIconName {
iconView.image = UIImage(systemName: systemIconName)
}
iconView.tintColor = UIColor(hex: 0x6B7280)
iconView.contentMode = .scaleAspectFit
countLabel.font = .systemFont(ofSize: 14)
countLabel.textColor = UIColor(hex: 0x6B7280)
addSubview(iconView)
addSubview(countLabel)
iconView.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.size.equalTo(16)
}
countLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(4)
make.trailing.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setCount(_ count: Int) {
countLabel.text = MaterialDisplayFormatter.formatCount(count)
}
}
///
///
private final class MaterialInfoRow: UIView {
private let label = UILabel()
private let valueLabel = UILabel()
init(label: String, value: String, valueColor: UIColor = .black, valueFont: UIFont = .systemFont(ofSize: 14, weight: .medium)) {
super.init(frame: .zero)
self.label.text = label
self.label.font = .systemFont(ofSize: 14)
self.label.textColor = UIColor(hex: 0x2E3746)
valueLabel.text = value
valueLabel.font = valueFont
valueLabel.textColor = valueColor
valueLabel.textAlignment = .right
valueLabel.numberOfLines = 1
addSubview(self.label)
addSubview(valueLabel)
snp.makeConstraints { make in
make.height.equalTo(44)
}
self.label.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
}
valueLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(self.label.snp.trailing).offset(12)
make.trailing.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
///
private final class MaterialStatusRow: UIView {
private let label = UILabel()
private let badge = MaterialStatusBadgeView()
init(status: Int) {
super.init(frame: .zero)
label.text = "审核状态"
label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0x2E3746)
badge.apply(status: status)
addSubview(label)
addSubview(badge)
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)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,929 @@
//
// MaterialFormViewController.swift
// suixinkan
//
import AVFoundation
import Kingfisher
import PhotosUI
import SnapKit
import UniformTypeIdentifiers
import UIKit
/// / Android `UploadMaterialScreen` `EditMaterialScreen`
///
final class MaterialFormViewController: BaseViewController {
private enum PickTarget {
case material
case cover
}
private let viewModel: MaterialFormViewModel
private let api: any MaterialManagementServing
private let uploader: any MaterialOSSUploading
private let scrollView = UIScrollView()
private let formCard = UIView()
private let formStack = UIStackView()
private let nameField = UITextField()
private let nameSection = MaterialFormFieldView(title: "素材名称")
private let spotButton = MaterialSelectButton()
private let spotSection = MaterialFormFieldView(title: "关联打卡点")
private let mediaTypeButton = MaterialSelectButton()
private let mediaTypeSection = MaterialFormFieldView(title: "素材类型")
private let mediaGridView = MaterialUploadGridView()
private let materialSection = MaterialFormFieldView(title: "上传素材")
private let coverView = MaterialCoverUploadView()
private let coverSection = MaterialFormFieldView(title: "上传封面")
private let tagView = MaterialTagEditorView()
private let tagSection = MaterialFormFieldView(title: "标签")
private let bottomBar = UIView()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private let progressOverlay = MaterialUploadProgressOverlay()
private var pickTarget: PickTarget = .material
var onSubmitSuccess: (() -> Void)?
///
init(
mode: MaterialFormViewModel.Mode,
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI,
uploader: any MaterialOSSUploading = NetworkServices.shared.ossUploadService
) {
viewModel = MaterialFormViewModel(mode: mode)
self.api = api
self.uploader = uploader
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
switch viewModel.mode {
case .create: title = "上传素材"
case .edit: title = "编辑素材"
}
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF4F4F4)
formCard.backgroundColor = .white
formCard.layer.cornerRadius = 12
formCard.clipsToBounds = true
formStack.axis = .vertical
formStack.spacing = 16
nameField.placeholder = "请输入素材名称"
nameField.font = .systemFont(ofSize: 14)
nameField.textColor = .black
nameField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
nameField.layer.borderWidth = 1
nameField.layer.cornerRadius = 8
nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44))
nameField.leftViewMode = .always
nameField.clearButtonMode = .whileEditing
nameSection.setContent(nameField)
spotSection.setContent(spotButton)
mediaTypeSection.setContent(mediaTypeButton)
materialSection.setContent(mediaGridView)
coverSection.setContent(coverView)
tagSection.setContent(tagView)
[nameSection, spotSection, mediaTypeSection, materialSection, coverSection, tagSection].forEach(formStack.addArrangedSubview)
bottomBar.backgroundColor = .white
configureBottomButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary)
configureBottomButton(confirmButton, title: "确认", backgroundColor: AppColor.primary, titleColor: .white)
view.addSubview(scrollView)
scrollView.addSubview(formCard)
formCard.addSubview(formStack)
view.addSubview(bottomBar)
bottomBar.addSubview(cancelButton)
bottomBar.addSubview(confirmButton)
view.addSubview(progressOverlay)
progressOverlay.isHidden = true
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
cancelButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalToSuperview().offset(15)
make.width.equalTo(100)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
}
confirmButton.snp.makeConstraints { make in
make.top.height.equalTo(cancelButton)
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(15)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top)
}
formCard.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
formStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
nameField.snp.makeConstraints { make in
make.height.equalTo(44)
}
[spotButton, mediaTypeButton].forEach { button in
button.snp.makeConstraints { make in
make.height.equalTo(44)
}
}
progressOverlay.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
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) }
}
viewModel.onSubmitSuccess = { [weak self] in
Task { @MainActor in
self?.onSubmitSuccess?()
self?.navigationController?.popViewController(animated: true)
}
}
nameField.addTarget(self, action: #selector(nameChanged), for: .editingChanged)
spotButton.addTarget(self, action: #selector(spotTapped), for: .touchUpInside)
mediaTypeButton.addTarget(self, action: #selector(mediaTypeTapped), for: .touchUpInside)
mediaGridView.onAdd = { [weak self] in self?.presentPickerSource(target: .material) }
mediaGridView.onDelete = { [weak self] index in self?.viewModel.deleteMaterial(at: index) }
coverView.onAdd = { [weak self] in self?.presentPickerSource(target: .cover) }
coverView.onDelete = { [weak self] in self?.viewModel.deleteCoverImage() }
tagView.onTextChanged = { [weak self] text in self?.viewModel.updateTagInput(text) }
tagView.onAdd = { [weak self] in
guard let self else { return }
Task { await self.viewModel.addTag(api: self.api) }
}
tagView.onDelete = { [weak self] tag in self?.viewModel.deleteTag(tag) }
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
applyViewModel()
}
override func viewDidLoad() {
super.viewDidLoad()
Task {
showLoading()
await viewModel.loadInitial(api: api)
hideLoading()
}
}
@MainActor
private func applyViewModel() {
if nameField.text != viewModel.materialName {
nameField.text = viewModel.materialName
}
spotButton.setTitle(viewModel.selectedScenicSpot?.name, placeholder: "请选择打卡点")
mediaTypeButton.setTitle(viewModel.mediaType.title, placeholder: "")
mediaGridView.apply(items: viewModel.uploadedMediaList, mediaType: viewModel.mediaType)
coverView.apply(item: viewModel.coverImage)
tagView.apply(tags: viewModel.tagList, input: viewModel.tagInput)
confirmButton.alpha = viewModel.isSubmitting ? 0.6 : 1
confirmButton.isEnabled = !viewModel.isSubmitting
if let dialog = viewModel.uploadDialogState {
progressOverlay.apply(state: dialog)
progressOverlay.isHidden = false
} else {
progressOverlay.isHidden = true
}
}
private func configureBottomButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) {
button.setTitle(title, for: .normal)
button.setTitleColor(titleColor, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.backgroundColor = backgroundColor
button.layer.cornerRadius = 8
}
@objc private func nameChanged() {
viewModel.updateMaterialName(nameField.text ?? "")
}
@objc private func spotTapped() {
let sheet = UIAlertController(title: "关联打卡点", message: nil, preferredStyle: .actionSheet)
viewModel.scenicSpotList.forEach { spot in
sheet.addAction(UIAlertAction(title: spot.name, style: .default) { [weak self] _ in
self?.viewModel.selectScenicSpot(spot)
})
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func mediaTypeTapped() {
let sheet = UIAlertController(title: "素材类型", message: nil, preferredStyle: .actionSheet)
MaterialMediaType.allCases.forEach { type in
sheet.addAction(UIAlertAction(title: type.title, style: .default) { [weak self] _ in
self?.viewModel.selectMediaType(type)
})
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func cancelTapped() {
navigationController?.popViewController(animated: true)
}
@objc private func confirmTapped() {
Task {
showLoading()
await viewModel.submit(api: api)
hideLoading()
}
}
private func presentPickerSource(target: PickTarget) {
pickTarget = target
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
if UIImagePickerController.isSourceTypeAvailable(.camera) {
let cameraTitle = viewModel.mediaType == .video && target == .material ? "拍摄视频" : "拍照"
sheet.addAction(UIAlertAction(title: cameraTitle, style: .default) { [weak self] _ in
self?.presentCamera()
})
}
sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in
self?.presentPhotoPicker()
})
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
private func presentPhotoPicker() {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
if pickTarget == .cover {
configuration.filter = .images
configuration.selectionLimit = 1
} else {
configuration.filter = viewModel.mediaType == .image ? .images : .videos
configuration.selectionLimit = viewModel.mediaType == .video
? 1
: max(1, viewModel.mediaType.maxCount - viewModel.uploadedMediaList.count)
}
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
}
private func presentCamera() {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = self
if pickTarget == .material, viewModel.mediaType == .video {
picker.mediaTypes = [UTType.movie.identifier]
picker.cameraCaptureMode = .video
} else {
picker.mediaTypes = [UTType.image.identifier]
picker.cameraCaptureMode = .photo
}
present(picker, animated: true)
}
private func uploadPickedItems(_ items: [MaterialUploadedMediaItem]) {
guard !items.isEmpty else { return }
Task {
switch pickTarget {
case .material:
await viewModel.addLocalMedia(items, uploader: uploader)
case .cover:
await viewModel.setCoverImage(items[0], uploader: uploader)
}
}
}
private func makeImageItem(image: UIImage, fileName: String) -> MaterialUploadedMediaItem? {
guard let data = image.jpegData(compressionQuality: 0.9) else { return nil }
return MaterialUploadedMediaItem(
data: data,
fileName: fileName,
width: Int(image.size.width * image.scale),
height: Int(image.size.height * image.scale)
)
}
private func makeVideoItem(data: Data, fileName: String, sourceURL: URL) -> MaterialUploadedMediaItem {
let size = videoDisplaySize(url: sourceURL)
return MaterialUploadedMediaItem(
data: data,
thumbnailData: videoThumbnailData(url: sourceURL),
fileName: fileName,
width: Int(size.width),
height: Int(size.height)
)
}
private func videoDisplaySize(url: URL) -> CGSize {
let asset = AVAsset(url: url)
guard let track = asset.tracks(withMediaType: .video).first else { return .zero }
let transformed = track.naturalSize.applying(track.preferredTransform)
return CGSize(width: abs(transformed.width), height: abs(transformed.height))
}
private func videoThumbnailData(url: URL) -> Data? {
let generator = AVAssetImageGenerator(asset: AVAsset(url: url))
generator.appliesPreferredTrackTransform = true
guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return nil }
return UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75)
}
}
extension MaterialFormViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard !results.isEmpty else { return }
var loadedItems = [MaterialUploadedMediaItem?](repeating: nil, count: results.count)
let group = DispatchGroup()
for (index, result) in results.enumerated() {
let provider = result.itemProvider
if pickTarget == .material, viewModel.mediaType == .video {
group.enter()
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, _ in
defer { group.leave() }
guard let self, let url else { return }
let data = (try? Data(contentsOf: url)) ?? Data()
guard !data.isEmpty else { return }
let fileName = provider.suggestedName.map { "\($0).mp4" } ?? "material_video_\(Int(Date().timeIntervalSince1970)).mp4"
loadedItems[index] = self.makeVideoItem(data: data, fileName: fileName, sourceURL: url)
}
} else {
group.enter()
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
defer { group.leave() }
guard let self, let image = object as? UIImage else { return }
let fileName = "material_image_\(Int(Date().timeIntervalSince1970))_\(index).jpg"
loadedItems[index] = self.makeImageItem(image: image, fileName: fileName)
}
}
}
group.notify(queue: .main) { [weak self] in
self?.uploadPickedItems(loadedItems.compactMap { $0 })
}
}
}
extension MaterialFormViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
picker.dismiss(animated: true)
if pickTarget == .material, viewModel.mediaType == .video,
let url = info[.mediaURL] as? URL,
let data = try? Data(contentsOf: url) {
uploadPickedItems([makeVideoItem(data: data, fileName: url.lastPathComponent, sourceURL: url)])
return
}
if let image = info[.originalImage] as? UIImage,
let item = makeImageItem(image: image, fileName: "material_image_\(Int(Date().timeIntervalSince1970)).jpg") {
uploadPickedItems([item])
}
}
}
///
///
private final class MaterialFormFieldView: UIView {
private let titleLabel = UILabel()
private let contentHolder = UIView()
init(title: String) {
super.init(frame: .zero)
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = .black
addSubview(titleLabel)
addSubview(contentHolder)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
contentHolder.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setContent(_ view: UIView) {
contentHolder.subviews.forEach { $0.removeFromSuperview() }
contentHolder.addSubview(view)
view.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
}
///
///
private final class MaterialSelectButton: UIControl {
private let titleLabel = UILabel()
private let chevronView = UIImageView(image: UIImage(systemName: "chevron.down"))
override init(frame: CGRect) {
super.init(frame: frame)
layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
layer.borderWidth = 1
layer.cornerRadius = 8
titleLabel.font = .systemFont(ofSize: 14)
chevronView.tintColor = .black
addSubview(titleLabel)
addSubview(chevronView)
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8)
}
chevronView.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
make.size.equalTo(20)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setTitle(_ title: String?, placeholder: String) {
let text = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
titleLabel.text = text.isEmpty ? placeholder : text
titleLabel.textColor = text.isEmpty ? UIColor(hex: 0x4B5563) : .black
}
}
///
///
private final class MaterialUploadGridView: UIView {
private let stack = UIStackView()
var onAdd: (() -> Void)?
var onDelete: ((Int) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
stack.axis = .vertical
stack.spacing = 8
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(items: [MaterialUploadedMediaItem], mediaType: MaterialMediaType) {
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
let showAdd = items.count < mediaType.maxCount
let cells = items.map(Optional.some) + (showAdd ? [nil] : [])
let rows = Int(ceil(Double(cells.count) / 3.0))
guard rows > 0 else { return }
for row in 0 ..< rows {
let rowStack = UIStackView()
rowStack.axis = .horizontal
rowStack.spacing = 8
rowStack.distribution = .fillEqually
stack.addArrangedSubview(rowStack)
for column in 0 ..< 3 {
let index = row * 3 + column
if index < cells.count {
rowStack.addArrangedSubview(makeCell(item: cells[index], index: index, mediaType: mediaType))
} else {
rowStack.addArrangedSubview(UIView())
}
}
}
}
private func makeCell(item: MaterialUploadedMediaItem?, index: Int, mediaType: MaterialMediaType) -> UIView {
let cell = MaterialUploadThumbView(mediaType: mediaType)
cell.snp.makeConstraints { make in
make.height.equalTo(cell.snp.width).dividedBy(1.3)
}
if let item {
cell.apply(item: item)
cell.onDelete = { [weak self] in self?.onDelete?(index) }
} else {
cell.applyAdd()
cell.onAdd = { [weak self] in self?.onAdd?() }
}
return cell
}
}
///
///
private final class MaterialUploadThumbView: UIControl {
private let imageView = UIImageView()
private let addCircle = UIView()
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
private let textLabel = UILabel()
private let deleteButton = UIButton(type: .system)
private let playIcon = UIImageView(image: UIImage(systemName: "play.fill"))
private let mediaType: MaterialMediaType
var onAdd: (() -> Void)?
var onDelete: (() -> Void)?
init(mediaType: MaterialMediaType) {
self.mediaType = mediaType
super.init(frame: .zero)
layer.cornerRadius = 8
clipsToBounds = true
backgroundColor = .white
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
addCircle.backgroundColor = AppColor.primary
addCircle.layer.cornerRadius = 14
addIcon.tintColor = .white
textLabel.font = .systemFont(ofSize: 12)
textLabel.textColor = UIColor(hex: 0x4B5563)
textLabel.textAlignment = .center
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
deleteButton.tintColor = .white
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
deleteButton.layer.cornerRadius = 10
playIcon.tintColor = .white
playIcon.isHidden = true
addSubview(imageView)
addSubview(addCircle)
addCircle.addSubview(addIcon)
addSubview(textLabel)
addSubview(deleteButton)
addSubview(playIcon)
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
addCircle.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-10)
make.size.equalTo(28)
}
addIcon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(20)
}
textLabel.snp.makeConstraints { make in
make.top.equalTo(addCircle.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview().inset(4)
}
deleteButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(4)
make.size.equalTo(20)
}
playIcon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(32)
}
addTarget(self, action: #selector(addTapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func applyAdd() {
imageView.image = nil
imageView.kf.cancelDownloadTask()
backgroundColor = .white
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
layer.borderWidth = 1
addCircle.isHidden = false
textLabel.isHidden = false
textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频"
textLabel.numberOfLines = 1
deleteButton.isHidden = true
playIcon.isHidden = true
}
func apply(item: MaterialUploadedMediaItem) {
layer.borderWidth = 0
backgroundColor = UIColor(hex: 0xF5F5F5)
addCircle.isHidden = true
deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading
playIcon.isHidden = mediaType != .video || item.ossUrl.isEmpty || item.isUploading
if item.isUploading {
imageView.image = nil
textLabel.isHidden = false
textLabel.text = "上传中...\n\(item.uploadProgress)%"
textLabel.numberOfLines = 2
} else {
textLabel.isHidden = true
if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
imageView.kf.setImage(with: url)
} else {
imageView.image = UIImage(data: item.thumbnailData ?? item.data)
}
}
}
@objc private func addTapped() {
guard !addCircle.isHidden else { return }
onAdd?()
}
@objc private func deleteTapped() {
onDelete?()
}
}
///
///
private final class MaterialCoverUploadView: UIControl {
private let imageView = UIImageView()
private let addCircle = UIView()
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
private let titleLabel = UILabel()
private let hintLabel = UILabel()
private let deleteButton = UIButton(type: .system)
var onAdd: (() -> Void)?
var onDelete: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 8
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
layer.borderWidth = 1
clipsToBounds = true
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
addCircle.backgroundColor = AppColor.primary
addCircle.layer.cornerRadius = 16
addIcon.tintColor = .white
titleLabel.text = "点击上传图片"
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x4B5563)
titleLabel.textAlignment = .center
hintLabel.text = "建议尺寸: 1920x1080px, 大小不超过5MB"
hintLabel.font = .systemFont(ofSize: 12)
hintLabel.textColor = UIColor(hex: 0xB6BECA)
hintLabel.textAlignment = .center
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
deleteButton.tintColor = .white
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
deleteButton.layer.cornerRadius = 12
addSubview(imageView)
addSubview(addCircle)
addCircle.addSubview(addIcon)
addSubview(titleLabel)
addSubview(hintLabel)
addSubview(deleteButton)
snp.makeConstraints { make in
make.height.equalTo(snp.width).dividedBy(2.59)
}
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
addCircle.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-22)
make.size.equalTo(32)
}
addIcon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(24)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(addCircle.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(12)
}
hintLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(12)
}
deleteButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(8)
make.size.equalTo(24)
}
addTarget(self, action: #selector(addTapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(item: MaterialUploadedMediaItem?) {
guard let item else {
imageView.image = nil
imageView.kf.cancelDownloadTask()
addCircle.isHidden = false
titleLabel.isHidden = false
titleLabel.text = "点击上传图片"
titleLabel.numberOfLines = 1
hintLabel.isHidden = false
deleteButton.isHidden = true
return
}
if item.isUploading {
imageView.image = nil
addCircle.isHidden = true
titleLabel.isHidden = false
titleLabel.text = "上传中...\n\(item.uploadProgress)%"
titleLabel.numberOfLines = 2
hintLabel.isHidden = true
deleteButton.isHidden = true
} else {
if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
imageView.kf.setImage(with: url)
} else {
imageView.image = UIImage(data: item.data)
}
addCircle.isHidden = true
titleLabel.isHidden = true
hintLabel.isHidden = true
deleteButton.isHidden = item.ossUrl.isEmpty
}
}
@objc private func addTapped() {
guard addCircle.isHidden == false else { return }
onAdd?()
}
@objc private func deleteTapped() {
onDelete?()
}
}
///
///
private final class MaterialTagEditorView: UIView {
private let stack = UIStackView()
private let tagsStack = UIStackView()
private let inputRow = UIStackView()
private let textField = UITextField()
private let addButton = UIButton(type: .system)
private var tags: [MaterialTagItem] = []
var onTextChanged: ((String) -> Void)?
var onAdd: (() -> Void)?
var onDelete: ((MaterialTagItem) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
stack.axis = .vertical
stack.spacing = 8
tagsStack.axis = .horizontal
tagsStack.spacing = 8
tagsStack.alignment = .leading
inputRow.axis = .horizontal
inputRow.spacing = 8
textField.placeholder = "请输入标签"
textField.font = .systemFont(ofSize: 14)
textField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
textField.layer.borderWidth = 1
textField.layer.cornerRadius = 8
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44))
textField.leftViewMode = .always
addButton.setTitle("添加", for: .normal)
addButton.setTitleColor(.white, for: .normal)
addButton.backgroundColor = AppColor.primary
addButton.layer.cornerRadius = 8
addSubview(stack)
stack.addArrangedSubview(tagsStack)
stack.addArrangedSubview(inputRow)
inputRow.addArrangedSubview(textField)
inputRow.addArrangedSubview(addButton)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
textField.snp.makeConstraints { make in
make.height.equalTo(44)
}
addButton.snp.makeConstraints { make in
make.width.equalTo(72)
make.height.equalTo(44)
}
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(tags: [MaterialTagItem], input: String) {
self.tags = tags
if textField.text != input {
textField.text = input
}
tagsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
tags.forEach { tag in
let button = UIButton(type: .system)
button.setTitle("\(tag.name) ×", for: .normal)
button.setTitleColor(AppColor.primary, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 12)
button.backgroundColor = UIColor(hex: 0xEFF6FF)
button.layer.cornerRadius = 4
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
button.snp.makeConstraints { make in
make.height.equalTo(28)
}
button.addAction(UIAction { [weak self] _ in
self?.onDelete?(tag)
}, for: .touchUpInside)
tagsStack.addArrangedSubview(button)
}
tagsStack.isHidden = tags.isEmpty
}
@objc private func textChanged() {
onTextChanged?(textField.text ?? "")
}
@objc private func addTapped() {
onAdd?()
}
}
///
///
private final class MaterialUploadProgressOverlay: UIView {
private let card = UIView()
private let titleLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .default)
private let percentLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.black.withAlphaComponent(0.2)
card.backgroundColor = .white
card.layer.cornerRadius = 12
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.textColor = AppColor.textPrimary
progressView.progressTintColor = AppColor.primary
progressView.trackTintColor = UIColor(hex: 0xF3F4F6)
percentLabel.font = .systemFont(ofSize: 14)
percentLabel.textColor = AppColor.textSecondary
percentLabel.textAlignment = .center
addSubview(card)
card.addSubview(titleLabel)
card.addSubview(progressView)
card.addSubview(percentLabel)
card.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(44)
make.height.equalTo(card.snp.width).dividedBy(1.7)
}
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(24)
}
progressView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(32)
make.leading.trailing.equalToSuperview().inset(24)
make.height.equalTo(6)
}
percentLabel.snp.makeConstraints { make in
make.top.equalTo(progressView.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(24)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(state: MaterialUploadDialogState) {
titleLabel.text = state.title
let progress = Float(max(0, min(100, state.progress))) / 100
progressView.setProgress(progress, animated: true)
percentLabel.text = "\(max(0, min(100, state.progress)))%"
}
}

View File

@ -0,0 +1,654 @@
//
// MaterialListViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `MaterialListScreen`
///
final class MaterialListViewController: BaseViewController {
private let viewModel: MaterialListViewModel
private let api: any MaterialManagementServing
private let searchContainer = UIView()
private let searchBox = UIView()
private let searchIconView = UIImageView(image: UIImage(systemName: "magnifyingglass"))
private let searchTextField = UITextField()
private let filterContainer = UIView()
private let filterButton = UIButton(type: .system)
private let filterTitleLabel = UILabel()
private let filterChevronView = UIImageView(image: UIImage(systemName: "chevron.down"))
private let filterDropdownView = UIView()
private let filterDropdownStack = UIStackView()
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyLabel = UILabel()
private let bottomBar = UIView()
private let addButton = UIButton(type: .system)
private var dataSource: UITableViewDiffableDataSource<Int, MaterialListRow>!
private var filterOptionButtons: [(MaterialFilterStatus, UIButton)] = []
private var needsRefreshOnAppear = false
///
init(
viewModel: MaterialListViewModel = MaterialListViewModel(),
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI
) {
self.viewModel = viewModel
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 = AppColor.pageBackground
searchContainer.backgroundColor = .white
searchBox.backgroundColor = UIColor(hex: 0xF4F4F4)
searchBox.layer.cornerRadius = 4
searchIconView.tintColor = AppColor.textSecondary
searchIconView.contentMode = .scaleAspectFit
searchTextField.placeholder = "搜索素材名称"
searchTextField.font = .systemFont(ofSize: 13)
searchTextField.textColor = AppColor.textPrimary
searchTextField.clearButtonMode = .whileEditing
searchTextField.returnKeyType = .search
filterContainer.backgroundColor = .white
filterButton.backgroundColor = UIColor(hex: 0xF4F4F4)
filterButton.layer.cornerRadius = 4
filterTitleLabel.text = "筛选"
filterTitleLabel.font = .systemFont(ofSize: 14)
filterTitleLabel.textColor = UIColor(hex: 0x4B5563)
filterChevronView.tintColor = UIColor(hex: 0x4B5563)
filterChevronView.contentMode = .scaleAspectFit
filterDropdownView.backgroundColor = .white
filterDropdownView.layer.cornerRadius = 4
filterDropdownView.layer.shadowColor = UIColor.black.cgColor
filterDropdownView.layer.shadowOpacity = 0.12
filterDropdownView.layer.shadowRadius = 10
filterDropdownView.layer.shadowOffset = CGSize(width: 0, height: 4)
filterDropdownView.isHidden = true
filterDropdownStack.axis = .vertical
tableView.backgroundColor = AppColor.pageBackground
tableView.separatorStyle = .none
tableView.estimatedRowHeight = 148
tableView.rowHeight = UITableView.automaticDimension
tableView.delegate = self
tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 16, right: 0)
tableView.refreshControl = UIRefreshControl()
tableView.register(MaterialStatsCell.self, forCellReuseIdentifier: MaterialStatsCell.reuseIdentifier)
tableView.register(MaterialListCell.self, forCellReuseIdentifier: MaterialListCell.reuseIdentifier)
dataSource = UITableViewDiffableDataSource<Int, MaterialListRow>(tableView: tableView) {
[weak self] tableView, indexPath, row in
switch row {
case .stats(let info):
let cell = tableView.dequeueReusableCell(
withIdentifier: MaterialStatsCell.reuseIdentifier,
for: indexPath
) as! MaterialStatsCell
cell.apply(info: info)
return cell
case .item(let item):
let cell = tableView.dequeueReusableCell(
withIdentifier: MaterialListCell.reuseIdentifier,
for: indexPath
) as! MaterialListCell
cell.apply(item: item)
cell.onToggle = { [weak self] checked in
guard let self else { return }
Task { await self.viewModel.toggleListing(materialId: item.id, checked: checked, api: self.api) }
}
return cell
}
}
emptyLabel.text = "暂无素材"
emptyLabel.font = .systemFont(ofSize: 16)
emptyLabel.textColor = AppColor.textSecondary
emptyLabel.textAlignment = .center
emptyLabel.isHidden = true
bottomBar.backgroundColor = .white
addButton.setTitle("添加", for: .normal)
addButton.setTitleColor(.white, for: .normal)
addButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
addButton.backgroundColor = AppColor.primary
addButton.layer.cornerRadius = 8
view.addSubview(searchContainer)
searchContainer.addSubview(searchBox)
searchBox.addSubview(searchIconView)
searchBox.addSubview(searchTextField)
view.addSubview(filterContainer)
filterContainer.addSubview(filterButton)
filterButton.addSubview(filterTitleLabel)
filterButton.addSubview(filterChevronView)
view.addSubview(tableView)
view.addSubview(emptyLabel)
view.addSubview(bottomBar)
bottomBar.addSubview(addButton)
view.addSubview(filterDropdownView)
filterDropdownView.addSubview(filterDropdownStack)
configureFilterDropdown()
}
override func setupConstraints() {
searchContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.height.equalTo(68)
}
searchBox.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.centerY.equalToSuperview()
make.height.equalTo(36)
}
searchIconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.size.equalTo(15)
}
searchTextField.snp.makeConstraints { make in
make.leading.equalTo(searchIconView.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(12)
make.top.bottom.equalToSuperview()
}
filterContainer.snp.makeConstraints { make in
make.top.equalTo(searchContainer.snp.bottom).offset(1)
make.leading.trailing.equalToSuperview()
make.height.equalTo(68)
}
filterButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.centerY.equalToSuperview()
make.height.equalTo(36)
}
filterTitleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
}
filterChevronView.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(14)
make.centerY.equalToSuperview()
make.size.equalTo(18)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
addButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(15)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(filterContainer.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
filterDropdownView.snp.makeConstraints { make in
make.top.equalTo(filterButton.snp.bottom)
make.leading.trailing.equalTo(filterButton)
}
filterDropdownStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
emptyLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalTo(tableView)
make.leading.trailing.equalToSuperview().inset(24)
}
}
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) }
}
searchTextField.addTarget(self, action: #selector(searchTextChanged), for: .editingChanged)
searchTextField.delegate = self
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.loadInitial(api: api) }
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if needsRefreshOnAppear {
needsRefreshOnAppear = false
Task { await viewModel.refresh(api: api) }
}
}
@MainActor
private func applyViewModel() {
filterTitleLabel.text = viewModel.filterStatus == .all ? "筛选" : viewModel.filterStatus.title
filterOptionButtons.forEach { status, button in
let selected = status == viewModel.filterStatus
button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .medium : .regular)
}
if searchTextField.text != viewModel.searchText {
searchTextField.text = viewModel.searchText
}
var snapshot = NSDiffableDataSourceSnapshot<Int, MaterialListRow>()
snapshot.appendSections([0])
snapshot.appendItems([.stats(viewModel.orderInfo)] + viewModel.items.map(MaterialListRow.item))
dataSource.apply(snapshot, animatingDifferences: true)
emptyLabel.isHidden = !viewModel.items.isEmpty || viewModel.isLoading || viewModel.isRefreshing
viewModel.isLoading && viewModel.items.isEmpty ? showLoading() : hideLoading()
if !viewModel.isRefreshing {
tableView.refreshControl?.endRefreshing()
}
}
@objc private func searchTextChanged() {
let text = searchTextField.text ?? ""
Task { await viewModel.updateSearchText(text, api: api) }
}
@objc private func filterTapped() {
filterDropdownView.isHidden.toggle()
view.bringSubviewToFront(filterDropdownView)
UIView.animate(withDuration: 0.2) {
self.filterChevronView.transform = self.filterDropdownView.isHidden
? .identity
: CGAffineTransform(rotationAngle: .pi)
}
}
@objc private func addTapped() {
let controller = MaterialFormViewController(mode: .create)
controller.onSubmitSuccess = { [weak self] in
self?.needsRefreshOnAppear = true
}
navigationController?.pushViewController(controller, animated: true)
}
@objc private func refreshPulled() {
hideFilterDropdown()
Task { await viewModel.refresh(api: api) }
}
private func configureFilterDropdown() {
MaterialFilterStatus.allCases.forEach { status in
let button = UIButton(type: .system)
button.contentHorizontalAlignment = .leading
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
button.setTitle(status.title, for: .normal)
button.backgroundColor = .white
button.snp.makeConstraints { make in
make.height.equalTo(48)
}
button.addAction(UIAction { [weak self] _ in
guard let self else { return }
self.hideFilterDropdown()
Task { await self.viewModel.selectFilter(status, api: self.api) }
}, for: .touchUpInside)
filterDropdownStack.addArrangedSubview(button)
filterOptionButtons.append((status, button))
}
}
private func hideFilterDropdown() {
guard !filterDropdownView.isHidden else { return }
filterDropdownView.isHidden = true
UIView.animate(withDuration: 0.2) {
self.filterChevronView.transform = .identity
}
}
}
extension MaterialListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
hideFilterDropdown()
tableView.deselectRow(at: indexPath, animated: true)
guard case .item(let item) = dataSource.itemIdentifier(for: indexPath) else { return }
let controller = MaterialDetailViewController(materialId: item.id)
controller.onDeleted = { [weak self] deletedId in
self?.viewModel.removeMaterial(id: deletedId)
}
controller.onEdited = { [weak self] in
self?.needsRefreshOnAppear = true
}
navigationController?.pushViewController(controller, animated: true)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard case .item = dataSource.itemIdentifier(for: indexPath) else { return }
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: indexPath.row - 1, api: api) }
}
}
extension MaterialListViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
}
/// Diffable
private enum MaterialListRow: Hashable {
case stats(MaterialOrderInfo)
case item(MaterialItem)
}
///
///
private final class MaterialStatsCell: UITableViewCell {
static let reuseIdentifier = "MaterialStatsCell"
private let cardView = UIView()
private let stack = UIStackView()
private let totalView = MaterialStatisticView(title: "订单总数量", color: UIColor(hex: 0x7F00FF), background: UIColor(hex: 0xEBD8FF))
private let priceView = MaterialStatisticView(title: "客单价", color: UIColor(hex: 0x22C55E), background: UIColor(hex: 0xF0FDF4))
private let refundView = MaterialStatisticView(title: "退款金额", color: UIColor(hex: 0xFF7B00), background: UIColor(hex: 0xFFF0E2))
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = AppColor.pageBackground
contentView.backgroundColor = AppColor.pageBackground
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
stack.axis = .horizontal
stack.spacing = 12
stack.distribution = .fillEqually
contentView.addSubview(cardView)
cardView.addSubview(stack)
[totalView, priceView, refundView].forEach(stack.addArrangedSubview)
cardView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(info: MaterialOrderInfo) {
totalView.setValue(MaterialDisplayFormatter.formatNumber(info.totalNum))
priceView.setValue("+\(info.avgOrderAmount)")
refundView.setValue(info.refundTotal)
}
}
///
///
private final class MaterialStatisticView: UIView {
private let titleLabel = UILabel()
private let valueLabel = UILabel()
init(title: String, color: UIColor, background: UIColor) {
super.init(frame: .zero)
self.backgroundColor = background
layer.cornerRadius = 12
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = .black
titleLabel.textAlignment = .center
valueLabel.font = .systemFont(ofSize: 18, weight: .bold)
valueLabel.textColor = color
valueLabel.textAlignment = .center
valueLabel.adjustsFontSizeToFitWidth = true
valueLabel.minimumScaleFactor = 0.7
addSubview(titleLabel)
addSubview(valueLabel)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(8)
}
valueLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(10)
make.leading.trailing.bottom.equalToSuperview().inset(8)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setValue(_ value: String) {
valueLabel.text = value
}
}
/// cell Android `MaterialItemView`
///
private final class MaterialListCell: UITableViewCell {
static let reuseIdentifier = "MaterialListCell"
private let cardView = UIView()
private let coverImageView = UIImageView()
private let placeholderLabel = UILabel()
private let titleLabel = UILabel()
private let timeLabel = UILabel()
private let statusBadge = MaterialStatusBadgeView()
private let listingSwitch = UISwitch()
private let statsStack = UIStackView()
private let downloadStatView = MaterialStatView(systemIconName: "arrow.down.circle")
private let likeStatView = MaterialStatView(iconName: "sample_ic_like_count")
private let collectStatView = MaterialStatView(iconName: "sample_ic_collect_count")
private var isApplying = false
var onToggle: ((Bool) -> Void)?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(item: MaterialItem) {
isApplying = true
titleLabel.text = item.name
timeLabel.text = item.createdAt
statusBadge.apply(status: item.status)
listingSwitch.setOn(item.listingStatus == 1, animated: false)
downloadStatView.setCount(item.downloadCount)
likeStatView.setCount(item.likesCount)
collectStatView.setCount(item.collectCount)
if let url = URL(string: item.coverUrl), !item.coverUrl.isEmpty {
placeholderLabel.isHidden = true
coverImageView.kf.setImage(with: url)
} else {
coverImageView.image = nil
placeholderLabel.isHidden = false
}
isApplying = false
}
private func setupUI() {
selectionStyle = .none
backgroundColor = AppColor.pageBackground
contentView.backgroundColor = AppColor.pageBackground
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
cardView.clipsToBounds = true
coverImageView.backgroundColor = UIColor(hex: 0xF5F5F5)
coverImageView.contentMode = .scaleAspectFill
coverImageView.clipsToBounds = true
coverImageView.layer.cornerRadius = 8
placeholderLabel.text = "暂无图片"
placeholderLabel.font = .systemFont(ofSize: 12)
placeholderLabel.textColor = AppColor.textSecondary
placeholderLabel.textAlignment = .center
titleLabel.font = .systemFont(ofSize: 16, weight: .bold)
titleLabel.textColor = .black
titleLabel.numberOfLines = 1
timeLabel.font = .systemFont(ofSize: 12)
timeLabel.textColor = UIColor(hex: 0x4B5563)
timeLabel.numberOfLines = 1
listingSwitch.onTintColor = AppColor.primary
listingSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged)
statsStack.axis = .horizontal
statsStack.spacing = 16
statsStack.alignment = .center
contentView.addSubview(cardView)
cardView.addSubview(coverImageView)
coverImageView.addSubview(placeholderLabel)
cardView.addSubview(titleLabel)
cardView.addSubview(timeLabel)
cardView.addSubview(statusBadge)
cardView.addSubview(listingSwitch)
cardView.addSubview(statsStack)
[downloadStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview)
cardView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
coverImageView.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(12)
make.width.equalTo(109)
make.height.equalTo(100)
}
placeholderLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(8)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalTo(coverImageView.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(12)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.leading.trailing.equalTo(titleLabel)
}
statusBadge.snp.makeConstraints { make in
make.top.equalTo(timeLabel.snp.bottom).offset(8)
make.leading.equalTo(titleLabel)
make.height.equalTo(24)
}
listingSwitch.snp.makeConstraints { make in
make.centerY.equalTo(statusBadge)
make.trailing.equalToSuperview().inset(12)
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
}
statsStack.snp.makeConstraints { make in
make.top.equalTo(statusBadge.snp.bottom).offset(8)
make.leading.equalTo(titleLabel)
make.trailing.lessThanOrEqualToSuperview().inset(12)
make.bottom.lessThanOrEqualToSuperview().inset(12)
}
}
@objc private func switchChanged() {
guard !isApplying else { return }
onToggle?(listingSwitch.isOn)
}
}
/// badge
///
final class MaterialStatusBadgeView: UIView {
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 4
addSubview(label)
label.font = .systemFont(ofSize: 12, weight: .medium)
label.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(6)
make.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(status: Int) {
let color: UIColor
switch status {
case 1: color = UIColor(hex: 0x22C55E)
case 0: color = UIColor(hex: 0xFF7B00)
case 2: color = UIColor(hex: 0xEF4444)
default: color = AppColor.textSecondary
}
label.text = MaterialDisplayFormatter.reviewStatusText(status)
label.textColor = color
backgroundColor = color.withAlphaComponent(0.1)
}
}
///
///
private final class MaterialStatView: UIView {
private let iconView = UIImageView()
private let countLabel = UILabel()
init(iconName: String? = nil, systemIconName: String? = nil) {
super.init(frame: .zero)
if let iconName {
iconView.image = UIImage(named: iconName)
} else if let systemIconName {
iconView.image = UIImage(systemName: systemIconName)
}
iconView.tintColor = UIColor(hex: 0x6B7280)
iconView.contentMode = .scaleAspectFit
countLabel.font = .systemFont(ofSize: 14)
countLabel.textColor = UIColor(hex: 0x6B7280)
addSubview(iconView)
addSubview(countLabel)
iconView.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.size.equalTo(16)
}
countLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(4)
make.trailing.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setCount(_ count: Int) {
countLabel.text = MaterialDisplayFormatter.formatCount(count)
}
}