feat: 同步样片管理功能
This commit is contained in:
@ -126,7 +126,7 @@ final class HomeMenuCell: UICollectionViewCell {
|
||||
}
|
||||
|
||||
func apply(menu: HomeMenuItem) {
|
||||
iconView.image = UIImage(systemName: menu.iconName)
|
||||
iconView.image = UIImage(named: menu.iconName) ?? UIImage(systemName: menu.iconName)
|
||||
titleLabel.text = menu.title
|
||||
}
|
||||
|
||||
|
||||
682
suixinkan/UI/SampleManagement/SampleDetailViewController.swift
Normal file
682
suixinkan/UI/SampleManagement/SampleDetailViewController.swift
Normal file
@ -0,0 +1,682 @@
|
||||
//
|
||||
// SampleDetailViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 样片详情页,对齐 Android `SampleDetailScreen`。
|
||||
final class SampleDetailViewController: BaseViewController {
|
||||
private let viewModel: SampleDetailViewModel
|
||||
private let api: any SampleManagementServing
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let carouselView = SampleMediaCarouselView()
|
||||
private let sampleInfoSection = SampleDetailSampleInfoSection()
|
||||
private let uploaderSection = SampleDetailUploaderSection()
|
||||
private let scenicSection = SampleDetailImageInfoSection(title: "打卡地点")
|
||||
private let projectSection = SampleDetailImageInfoSection(title: "样片绑定套餐")
|
||||
private let reviewSection = SampleDetailReviewSection()
|
||||
|
||||
/// 初始化样片详情页。
|
||||
init(
|
||||
sampleId: Int,
|
||||
api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI
|
||||
) {
|
||||
viewModel = SampleDetailViewModel(sampleId: sampleId)
|
||||
self.api = api
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "样片详情"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 12
|
||||
scrollView.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
contentStack.addArrangedSubview(carouselView)
|
||||
contentStack.addArrangedSubview(sampleInfoSection)
|
||||
contentStack.addArrangedSubview(uploaderSection)
|
||||
contentStack.addArrangedSubview(scenicSection)
|
||||
contentStack.addArrangedSubview(projectSection)
|
||||
contentStack.addArrangedSubview(reviewSection)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
make.width.equalTo(scrollView)
|
||||
}
|
||||
carouselView.snp.makeConstraints { make in
|
||||
make.height.equalTo(carouselView.snp.width).multipliedBy(9.0 / 16.0)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { await viewModel.load(api: api) }
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
carouselView.pause()
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
if viewModel.isLoading && viewModel.detail == nil {
|
||||
showLoading()
|
||||
} else {
|
||||
hideLoading()
|
||||
}
|
||||
guard let detail = viewModel.detail else { return }
|
||||
carouselView.apply(media: detail.mediaList ?? [])
|
||||
sampleInfoSection.apply(detail: detail)
|
||||
uploaderSection.apply(detail: detail)
|
||||
scenicSection.applyScenic(detail: detail)
|
||||
projectSection.applyProject(detail: detail)
|
||||
reviewSection.apply(detail: detail)
|
||||
}
|
||||
}
|
||||
|
||||
/// 样片详情媒体轮播,支持图片自动切换与视频播完切换。
|
||||
private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
private let scrollView = UIScrollView()
|
||||
private let pageControl = UIPageControl()
|
||||
private let placeholderLabel = UILabel()
|
||||
private var media: [SampleDetailMediaItem] = []
|
||||
private var players: [Int: AVPlayer] = [:]
|
||||
private var timer: Timer?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
scrollView.isPagingEnabled = true
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
scrollView.delegate = self
|
||||
scrollView.bounces = false
|
||||
pageControl.currentPageIndicatorTintColor = .white
|
||||
pageControl.pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.5)
|
||||
placeholderLabel.text = "暂无媒体"
|
||||
placeholderLabel.font = .systemFont(ofSize: 14)
|
||||
placeholderLabel.textColor = AppColor.textSecondary
|
||||
placeholderLabel.textAlignment = .center
|
||||
|
||||
addSubview(scrollView)
|
||||
addSubview(pageControl)
|
||||
addSubview(placeholderLabel)
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
pageControl.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.bottom.equalToSuperview().inset(8)
|
||||
}
|
||||
placeholderLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
layoutPages()
|
||||
}
|
||||
|
||||
/// 应用媒体列表。
|
||||
func apply(media: [SampleDetailMediaItem]) {
|
||||
stopTimer()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
players.values.forEach { $0.pause() }
|
||||
players.removeAll()
|
||||
self.media = media
|
||||
scrollView.subviews.forEach { $0.removeFromSuperview() }
|
||||
pageControl.numberOfPages = media.count
|
||||
pageControl.currentPage = 0
|
||||
pageControl.isHidden = media.count <= 1
|
||||
placeholderLabel.isHidden = !media.isEmpty
|
||||
scrollView.isHidden = media.isEmpty
|
||||
guard !media.isEmpty else { return }
|
||||
|
||||
for (index, item) in media.enumerated() {
|
||||
let page = UIView()
|
||||
page.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
page.tag = index
|
||||
scrollView.addSubview(page)
|
||||
if item.type == 2 {
|
||||
addVideo(urlText: item.displayURL, to: page, index: index)
|
||||
} else {
|
||||
addImage(urlText: item.displayURL, to: page)
|
||||
}
|
||||
}
|
||||
setNeedsLayout()
|
||||
layoutIfNeeded()
|
||||
playCurrentPageIfNeeded()
|
||||
}
|
||||
|
||||
/// 暂停当前轮播。
|
||||
func pause() {
|
||||
stopTimer()
|
||||
players.values.forEach { $0.pause() }
|
||||
}
|
||||
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
updateCurrentPage()
|
||||
}
|
||||
|
||||
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||
updateCurrentPage()
|
||||
}
|
||||
|
||||
private func addImage(urlText: String, to page: UIView) {
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
page.addSubview(imageView)
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
if let url = URL(string: urlText), !urlText.isEmpty {
|
||||
imageView.kf.setImage(with: url, placeholder: UIImage(named: "img_square_loading_big"))
|
||||
}
|
||||
}
|
||||
|
||||
private func addVideo(urlText: String, to page: UIView, index: Int) {
|
||||
guard let url = URL(string: urlText), !urlText.isEmpty else {
|
||||
addImage(urlText: "", to: page)
|
||||
return
|
||||
}
|
||||
let player = AVPlayer(url: url)
|
||||
players[index] = player
|
||||
let playerView = SamplePlayerLayerView()
|
||||
playerView.playerLayer.player = player
|
||||
page.addSubview(playerView)
|
||||
playerView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(videoDidEnd(_:)),
|
||||
name: .AVPlayerItemDidPlayToEndTime,
|
||||
object: player.currentItem
|
||||
)
|
||||
}
|
||||
|
||||
private func layoutPages() {
|
||||
let size = bounds.size
|
||||
guard size.width > 0, size.height > 0 else { return }
|
||||
for (index, page) in scrollView.subviews.enumerated() {
|
||||
page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height)
|
||||
}
|
||||
scrollView.contentSize = CGSize(width: size.width * CGFloat(media.count), height: size.height)
|
||||
scrollView.contentOffset = CGPoint(x: CGFloat(pageControl.currentPage) * size.width, y: 0)
|
||||
}
|
||||
|
||||
private func updateCurrentPage() {
|
||||
guard bounds.width > 0 else { return }
|
||||
let page = Int(round(scrollView.contentOffset.x / bounds.width))
|
||||
pageControl.currentPage = max(0, min(page, media.count - 1))
|
||||
playCurrentPageIfNeeded()
|
||||
}
|
||||
|
||||
private func playCurrentPageIfNeeded() {
|
||||
stopTimer()
|
||||
players.forEach { index, player in
|
||||
if index == pageControl.currentPage {
|
||||
player.seek(to: .zero)
|
||||
player.play()
|
||||
} else {
|
||||
player.pause()
|
||||
}
|
||||
}
|
||||
guard !media.isEmpty else { return }
|
||||
if media[pageControl.currentPage].type == 1 {
|
||||
startImageTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private func startImageTimer() {
|
||||
guard media.count > 1 else { return }
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in
|
||||
self?.advancePage()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
@objc private func videoDidEnd(_ notification: Notification) {
|
||||
advancePage()
|
||||
}
|
||||
|
||||
private func advancePage() {
|
||||
guard media.count > 1, bounds.width > 0 else { return }
|
||||
let nextPage = (pageControl.currentPage + 1) % media.count
|
||||
pageControl.currentPage = nextPage
|
||||
scrollView.setContentOffset(CGPoint(x: CGFloat(nextPage) * bounds.width, y: 0), animated: true)
|
||||
if !scrollView.isDragging && !scrollView.isDecelerating {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||
self?.playCurrentPageIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AVPlayerLayer 容器。
|
||||
private final class SamplePlayerLayerView: UIView {
|
||||
override class var layerClass: AnyClass { AVPlayerLayer.self }
|
||||
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
||||
}
|
||||
|
||||
/// 样片详情名称与上架状态区。
|
||||
private final class SampleDetailSampleInfoSection: UIView {
|
||||
private let titleLabel = UILabel()
|
||||
private let listingBadge = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
|
||||
titleLabel.textColor = UIColor(hex: 0x2E3746)
|
||||
titleLabel.numberOfLines = 0
|
||||
listingBadge.font = .systemFont(ofSize: 12)
|
||||
listingBadge.layer.cornerRadius = 4
|
||||
listingBadge.clipsToBounds = true
|
||||
listingBadge.textAlignment = .center
|
||||
addSubview(titleLabel)
|
||||
addSubview(listingBadge)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.bottom.equalToSuperview().inset(16)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.trailing.lessThanOrEqualTo(listingBadge.snp.leading).offset(-12)
|
||||
}
|
||||
listingBadge.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
make.centerY.equalTo(titleLabel)
|
||||
make.height.equalTo(24)
|
||||
make.width.greaterThanOrEqualTo(54)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(detail: SampleDetail) {
|
||||
titleLabel.text = detail.name ?? ""
|
||||
let listed = detail.listingStatus == 1
|
||||
listingBadge.text = listed ? "已上架" : "未上架"
|
||||
let color = listed ? UIColor(hex: 0x22C55E) : AppColor.textSecondary
|
||||
listingBadge.textColor = color
|
||||
listingBadge.backgroundColor = color.withAlphaComponent(0.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// 样片上传人信息区。
|
||||
private final class SampleDetailUploaderSection: UIView {
|
||||
private let titleLabel = UILabel()
|
||||
private let avatarView = UIImageView()
|
||||
private let nameLabel = UILabel()
|
||||
private let tagStack = UIStackView()
|
||||
private let descriptionLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
titleLabel.text = "上传人"
|
||||
titleLabel.font = .systemFont(ofSize: 14, weight: .bold)
|
||||
titleLabel.textColor = .black
|
||||
avatarView.contentMode = .scaleAspectFill
|
||||
avatarView.clipsToBounds = true
|
||||
avatarView.layer.cornerRadius = 24
|
||||
avatarView.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
nameLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
nameLabel.textColor = UIColor(hex: 0x002255)
|
||||
descriptionLabel.font = .systemFont(ofSize: 14)
|
||||
descriptionLabel.textColor = UIColor(hex: 0x7B8EAA)
|
||||
descriptionLabel.numberOfLines = 0
|
||||
tagStack.axis = .horizontal
|
||||
tagStack.spacing = 8
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(avatarView)
|
||||
addSubview(nameLabel)
|
||||
addSubview(tagStack)
|
||||
addSubview(descriptionLabel)
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
avatarView.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(12)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.size.equalTo(48)
|
||||
make.bottom.lessThanOrEqualToSuperview().inset(16)
|
||||
}
|
||||
nameLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(avatarView)
|
||||
make.leading.equalTo(avatarView.snp.trailing).offset(12)
|
||||
}
|
||||
tagStack.snp.makeConstraints { make in
|
||||
make.leading.equalTo(nameLabel.snp.trailing).offset(8)
|
||||
make.centerY.equalTo(nameLabel)
|
||||
make.trailing.lessThanOrEqualToSuperview().inset(16)
|
||||
}
|
||||
descriptionLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(nameLabel.snp.bottom).offset(4)
|
||||
make.leading.equalTo(nameLabel)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalToSuperview().inset(16)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(detail: SampleDetail) {
|
||||
nameLabel.text = detail.uploaderName ?? ""
|
||||
descriptionLabel.text = detail.uploaderDescription ?? ""
|
||||
tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
if detail.platformCertification {
|
||||
tagStack.addArrangedSubview(makeTag("平台认证"))
|
||||
}
|
||||
if detail.scenicCertification {
|
||||
tagStack.addArrangedSubview(makeTag("景区认证"))
|
||||
}
|
||||
if let urlText = detail.uploaderAvatar, let url = URL(string: urlText), !urlText.isEmpty {
|
||||
avatarView.kf.setImage(with: url, placeholder: UIImage(systemName: "person.crop.circle.fill"))
|
||||
} else {
|
||||
avatarView.image = UIImage(systemName: "person.crop.circle.fill")
|
||||
avatarView.tintColor = AppColor.textTertiary
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTag(_ title: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.textColor = .white
|
||||
label.font = .systemFont(ofSize: 8)
|
||||
label.textAlignment = .center
|
||||
label.backgroundColor = AppColor.primary
|
||||
label.layer.cornerRadius = 4
|
||||
label.clipsToBounds = true
|
||||
label.snp.makeConstraints { make in
|
||||
make.height.equalTo(16)
|
||||
make.width.greaterThanOrEqualTo(44)
|
||||
}
|
||||
return label
|
||||
}
|
||||
}
|
||||
|
||||
/// 样片详情图片信息区,复用打卡地点与绑定套餐布局。
|
||||
private final class SampleDetailImageInfoSection: UIView {
|
||||
private let sectionTitleLabel = UILabel()
|
||||
private let imageView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let firstLineLabel = UILabel()
|
||||
private let secondLineLabel = UILabel()
|
||||
|
||||
init(title: String) {
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = .white
|
||||
sectionTitleLabel.text = title
|
||||
sectionTitleLabel.font = .systemFont(ofSize: 14, weight: .bold)
|
||||
sectionTitleLabel.textColor = .black
|
||||
imageView.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
imageView.layer.cornerRadius = 12
|
||||
titleLabel.font = .systemFont(ofSize: title == "打卡地点" ? 14 : 16, weight: .bold)
|
||||
titleLabel.textColor = .black
|
||||
[firstLineLabel, secondLineLabel].forEach { label in
|
||||
label.font = .systemFont(ofSize: 12)
|
||||
label.textColor = UIColor(hex: 0x4B5563)
|
||||
label.numberOfLines = 1
|
||||
}
|
||||
|
||||
addSubview(sectionTitleLabel)
|
||||
addSubview(imageView)
|
||||
addSubview(titleLabel)
|
||||
addSubview(firstLineLabel)
|
||||
addSubview(secondLineLabel)
|
||||
sectionTitleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.top.equalTo(sectionTitleLabel.snp.bottom).offset(12)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.height.equalTo(100)
|
||||
make.width.equalTo(179)
|
||||
make.bottom.equalToSuperview().inset(16)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(imageView).offset(2)
|
||||
make.leading.equalTo(imageView.snp.trailing).offset(12)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
firstLineLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalTo(titleLabel)
|
||||
}
|
||||
secondLineLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(firstLineLabel.snp.bottom).offset(7)
|
||||
make.leading.trailing.equalTo(titleLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func applyScenic(detail: SampleDetail) {
|
||||
titleLabel.text = detail.scenicName ?? ""
|
||||
firstLineLabel.attributedText = coloredValue(prefix: "无人机: ", value: "\(detail.scenicPlanesNum)架")
|
||||
secondLineLabel.attributedText = coloredValue(prefix: "摄影师: ", value: "\(detail.scenicPhotographerNum)人")
|
||||
setImage(detail.scenicCoverImg)
|
||||
}
|
||||
|
||||
func applyProject(detail: SampleDetail) {
|
||||
titleLabel.text = detail.projectName ?? ""
|
||||
if let price = detail.projectPrice {
|
||||
firstLineLabel.attributedText = coloredValue(prefix: "价格: ", value: "\(price)元")
|
||||
} else {
|
||||
firstLineLabel.text = ""
|
||||
}
|
||||
if let deposit = detail.projectPriceDeposit {
|
||||
secondLineLabel.attributedText = coloredValue(prefix: "定金: ", value: "\(deposit)元")
|
||||
} else {
|
||||
secondLineLabel.text = ""
|
||||
}
|
||||
setImage(detail.projectCover)
|
||||
}
|
||||
|
||||
private func setImage(_ urlText: String?) {
|
||||
if let urlText, let url = URL(string: urlText), !urlText.isEmpty {
|
||||
imageView.kf.setImage(with: url)
|
||||
} else {
|
||||
imageView.image = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func coloredValue(prefix: String, value: String) -> NSAttributedString {
|
||||
let text = NSMutableAttributedString(
|
||||
string: prefix,
|
||||
attributes: [.foregroundColor: UIColor(hex: 0x4B5563), .font: UIFont.systemFont(ofSize: 12)]
|
||||
)
|
||||
text.append(
|
||||
NSAttributedString(
|
||||
string: value,
|
||||
attributes: [.foregroundColor: AppColor.primary, .font: UIFont.systemFont(ofSize: 12)]
|
||||
)
|
||||
)
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
/// 样片审核信息区。
|
||||
private final class SampleDetailReviewSection: UIView {
|
||||
private let stack = UIStackView()
|
||||
private let titleLabel = UILabel()
|
||||
private let reviewerRow = SampleDetailInfoRow()
|
||||
private let reviewTimeRow = SampleDetailInfoRow()
|
||||
private let statusRow = SampleDetailStatusRow()
|
||||
private let noteRow = SampleDetailInfoRow()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 0
|
||||
titleLabel.text = "审核信息"
|
||||
titleLabel.font = .systemFont(ofSize: 14, weight: .bold)
|
||||
titleLabel.textColor = .black
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
}
|
||||
stack.addArrangedSubview(titleLabel)
|
||||
[reviewerRow, reviewTimeRow, statusRow, noteRow].forEach { row in
|
||||
stack.addArrangedSubview(row)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(detail: SampleDetail) {
|
||||
reviewerRow.apply(label: "审核人", value: detail.reviewer?.nonEmpty ?? "--")
|
||||
reviewTimeRow.apply(label: "审核时间", value: detail.reviewTime?.nonEmpty ?? "--")
|
||||
statusRow.apply(status: detail.status)
|
||||
noteRow.apply(label: "审核备注", value: detail.reviewNotes?.nonEmpty ?? "--", valueColor: UIColor(hex: 0x4B5563), valueFont: .systemFont(ofSize: 12))
|
||||
}
|
||||
}
|
||||
|
||||
/// 样片详情普通信息行。
|
||||
private final class SampleDetailInfoRow: UIView {
|
||||
private let label = UILabel()
|
||||
private let valueLabel = UILabel()
|
||||
private let separator = UIView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
label.font = .systemFont(ofSize: 14)
|
||||
label.textColor = UIColor(hex: 0x2E3746)
|
||||
valueLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
valueLabel.textColor = .black
|
||||
valueLabel.textAlignment = .right
|
||||
valueLabel.numberOfLines = 1
|
||||
separator.backgroundColor = UIColor(hex: 0xEEEEEE)
|
||||
addSubview(label)
|
||||
addSubview(valueLabel)
|
||||
addSubview(separator)
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
label.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview()
|
||||
}
|
||||
valueLabel.snp.makeConstraints { make in
|
||||
make.trailing.centerY.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualTo(label.snp.trailing).offset(12)
|
||||
}
|
||||
separator.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.height.equalTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(label: String, value: String, valueColor: UIColor = .black, valueFont: UIFont = .systemFont(ofSize: 14, weight: .medium)) {
|
||||
self.label.text = label
|
||||
valueLabel.text = value
|
||||
valueLabel.textColor = valueColor
|
||||
valueLabel.font = valueFont
|
||||
}
|
||||
}
|
||||
|
||||
/// 样片详情审核状态行。
|
||||
private final class SampleDetailStatusRow: UIView {
|
||||
private let label = UILabel()
|
||||
private let badge = SampleStatusBadgeView()
|
||||
private let separator = UIView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
label.text = "审核状态"
|
||||
label.font = .systemFont(ofSize: 14)
|
||||
label.textColor = UIColor(hex: 0x2E3746)
|
||||
separator.backgroundColor = UIColor(hex: 0xEEEEEE)
|
||||
addSubview(label)
|
||||
addSubview(badge)
|
||||
addSubview(separator)
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
label.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview()
|
||||
}
|
||||
badge.snp.makeConstraints { make in
|
||||
make.trailing.centerY.equalToSuperview()
|
||||
make.height.equalTo(24)
|
||||
}
|
||||
separator.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.height.equalTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(status: Int) {
|
||||
badge.apply(status: status)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
547
suixinkan/UI/SampleManagement/SampleListViewController.swift
Normal file
547
suixinkan/UI/SampleManagement/SampleListViewController.swift
Normal file
@ -0,0 +1,547 @@
|
||||
//
|
||||
// SampleListViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 样片相册列表页,对齐 Android `SampleListScreen`。
|
||||
final class SampleListViewController: BaseViewController {
|
||||
private let viewModel: SampleListViewModel
|
||||
private let api: any SampleManagementServing
|
||||
|
||||
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, SampleMaterialItem>!
|
||||
private var filterOptionButtons: [(SampleFilterStatus, UIButton)] = []
|
||||
|
||||
/// 初始化样片列表页。
|
||||
init(
|
||||
viewModel: SampleListViewModel = SampleListViewModel(),
|
||||
api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI
|
||||
) {
|
||||
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
|
||||
filterDropdownStack.spacing = 0
|
||||
|
||||
tableView.backgroundColor = AppColor.pageBackground
|
||||
tableView.separatorStyle = .none
|
||||
tableView.estimatedRowHeight = 152
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.delegate = self
|
||||
tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 16, right: 0)
|
||||
tableView.register(SampleListCell.self, forCellReuseIdentifier: SampleListCell.reuseIdentifier)
|
||||
tableView.refreshControl = UIRefreshControl()
|
||||
|
||||
dataSource = UITableViewDiffableDataSource<Int, SampleMaterialItem>(tableView: tableView) {
|
||||
[weak self] tableView, indexPath, item in
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: SampleListCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! SampleListCell
|
||||
cell.apply(item: item)
|
||||
cell.onToggle = { [weak self] checked in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.toggleListing(sampleId: 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) }
|
||||
}
|
||||
}
|
||||
|
||||
private var needsRefreshOnAppear = false
|
||||
|
||||
@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, SampleMaterialItem>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems(viewModel.items)
|
||||
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 = UploadSampleViewController()
|
||||
controller.onUploadSuccess = { [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() {
|
||||
SampleFilterStatus.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 SampleListViewController: UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
hideFilterDropdown()
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
navigationController?.pushViewController(SampleDetailViewController(sampleId: item.id), animated: true)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: indexPath.row, api: api) }
|
||||
}
|
||||
}
|
||||
|
||||
extension SampleListViewController: UITextFieldDelegate {
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
textField.resignFirstResponder()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// 样片列表卡片 cell,对齐 Android `SampleItemView`。
|
||||
private final class SampleListCell: UITableViewCell {
|
||||
static let reuseIdentifier = "SampleListCell"
|
||||
|
||||
private let cardView = UIView()
|
||||
private let coverImageView = UIImageView()
|
||||
private let placeholderLabel = UILabel()
|
||||
private let titleLabel = UILabel()
|
||||
private let projectLabel = UILabel()
|
||||
private let scenicSpotLabel = UILabel()
|
||||
private let statusBadge = SampleStatusBadgeView()
|
||||
private let listingSwitch = UISwitch()
|
||||
private let statsStack = UIStackView()
|
||||
private let shareStatView = SampleStatView(iconName: "sample_ic_share_count")
|
||||
private let likeStatView = SampleStatView(iconName: "sample_ic_like_count")
|
||||
private let collectStatView = SampleStatView(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: SampleMaterialItem) {
|
||||
isApplying = true
|
||||
titleLabel.text = item.name
|
||||
projectLabel.text = "项目: \(item.projectName)"
|
||||
scenicSpotLabel.text = "打卡地: \(item.scenicSpotName)"
|
||||
statusBadge.apply(status: item.status)
|
||||
listingSwitch.setOn(item.listingStatus == 1, animated: false)
|
||||
shareStatView.setCount(item.shareCount)
|
||||
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
|
||||
|
||||
[projectLabel, scenicSpotLabel].forEach { label in
|
||||
label.font = .systemFont(ofSize: 12)
|
||||
label.textColor = UIColor(hex: 0x4B5563)
|
||||
label.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(projectLabel)
|
||||
cardView.addSubview(scenicSpotLabel)
|
||||
cardView.addSubview(statusBadge)
|
||||
cardView.addSubview(listingSwitch)
|
||||
cardView.addSubview(statsStack)
|
||||
[shareStatView, 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.size.equalTo(128)
|
||||
}
|
||||
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)
|
||||
}
|
||||
projectLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||
make.leading.trailing.equalTo(titleLabel)
|
||||
}
|
||||
scenicSpotLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(projectLabel.snp.bottom).offset(6)
|
||||
make.leading.trailing.equalTo(titleLabel)
|
||||
}
|
||||
statusBadge.snp.makeConstraints { make in
|
||||
make.top.equalTo(scenicSpotLabel.snp.bottom).offset(10)
|
||||
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(10)
|
||||
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 SampleStatusBadgeView: 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 = SampleDisplayFormatter.reviewStatusText(status)
|
||||
label.textColor = color
|
||||
backgroundColor = color.withAlphaComponent(0.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// 样片统计图标与数字。
|
||||
private final class SampleStatView: UIView {
|
||||
private let iconView = UIImageView()
|
||||
private let countLabel = UILabel()
|
||||
|
||||
init(iconName: String) {
|
||||
super.init(frame: .zero)
|
||||
iconView.image = UIImage(named: iconName)
|
||||
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 = SampleDisplayFormatter.formatCount(count)
|
||||
}
|
||||
}
|
||||
883
suixinkan/UI/SampleManagement/UploadSampleViewController.swift
Normal file
883
suixinkan/UI/SampleManagement/UploadSampleViewController.swift
Normal file
@ -0,0 +1,883 @@
|
||||
//
|
||||
// UploadSampleViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
/// 样片相册上传页,对齐 Android `UploadSampleScreen`。
|
||||
final class UploadSampleViewController: BaseViewController {
|
||||
private enum PickTarget {
|
||||
case material
|
||||
case cover
|
||||
}
|
||||
|
||||
private let viewModel: UploadSampleViewModel
|
||||
private let api: any SampleManagementServing
|
||||
private let uploader: any SampleOSSUploading
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let formCard = UIView()
|
||||
private let formStack = UIStackView()
|
||||
private let nameField = UITextField()
|
||||
private let nameSection = SampleFormFieldView(title: "相册名称")
|
||||
private let mediaTypeButton = SampleSelectButton()
|
||||
private let mediaTypeSection = SampleFormFieldView(title: "素材类型")
|
||||
private let mediaGridView = SampleUploadGridView()
|
||||
private let materialSection = SampleFormFieldView(title: "上传素材")
|
||||
private let materialHintLabel = UILabel()
|
||||
private let coverView = SampleCoverUploadView()
|
||||
private let coverSection = SampleFormFieldView(title: "上传封面")
|
||||
private let scenicButton = SampleSelectButton()
|
||||
private let scenicSection = SampleFormFieldView(title: "关联景区")
|
||||
private let spotButton = SampleSelectButton()
|
||||
private let spotSection = SampleFormFieldView(title: "关联打卡点")
|
||||
private let projectButton = SampleSelectButton()
|
||||
private let projectSection = SampleFormFieldView(title: "关联项目")
|
||||
private let bottomBar = UIView()
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
private let progressOverlay = SampleUploadProgressOverlay()
|
||||
|
||||
private var pickTarget: PickTarget = .material
|
||||
|
||||
var onUploadSuccess: (() -> Void)?
|
||||
|
||||
/// 初始化样片上传页。
|
||||
init(
|
||||
viewModel: UploadSampleViewModel = UploadSampleViewModel(),
|
||||
api: any SampleManagementServing = NetworkServices.shared.sampleManagementAPI,
|
||||
uploader: any SampleOSSUploading = NetworkServices.shared.ossUploadService
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
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() {
|
||||
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)
|
||||
|
||||
mediaTypeSection.setContent(mediaTypeButton)
|
||||
materialSection.setContent(makeMaterialContent())
|
||||
coverSection.setContent(coverView)
|
||||
scenicSection.setContent(scenicButton)
|
||||
spotSection.setContent(spotButton)
|
||||
projectSection.setContent(projectButton)
|
||||
|
||||
[nameSection, mediaTypeSection, materialSection, coverSection, scenicSection, spotSection, projectSection]
|
||||
.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)
|
||||
}
|
||||
[mediaTypeButton, scenicButton, spotButton, projectButton].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.onUploadSuccess = { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.onUploadSuccess?()
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
nameField.addTarget(self, action: #selector(nameChanged), for: .editingChanged)
|
||||
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() }
|
||||
scenicButton.addTarget(self, action: #selector(scenicTapped), for: .touchUpInside)
|
||||
spotButton.addTarget(self, action: #selector(spotTapped), for: .touchUpInside)
|
||||
projectButton.addTarget(self, action: #selector(projectTapped), for: .touchUpInside)
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
private func makeMaterialContent() -> UIView {
|
||||
let container = UIView()
|
||||
materialHintLabel.text = "建议尺寸: 1920x1080px, 大小不超过20MB"
|
||||
materialHintLabel.font = .systemFont(ofSize: 12)
|
||||
materialHintLabel.textColor = UIColor(hex: 0xB6BECA)
|
||||
container.addSubview(mediaGridView)
|
||||
container.addSubview(materialHintLabel)
|
||||
mediaGridView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
}
|
||||
materialHintLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(mediaGridView.snp.bottom).offset(8)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
return container
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
if nameField.text != viewModel.albumName {
|
||||
nameField.text = viewModel.albumName
|
||||
}
|
||||
mediaTypeButton.setTitle(viewModel.mediaType.title, placeholder: "")
|
||||
scenicButton.setTitle(viewModel.selectedScenic?.name, placeholder: "请选择景区")
|
||||
spotButton.setTitle(viewModel.selectedScenicSpot?.name, placeholder: "请选择关联打卡点")
|
||||
projectButton.setTitle(viewModel.selectedProject?.name, placeholder: "请选择关联项目")
|
||||
mediaGridView.apply(items: viewModel.uploadedMediaList, mediaType: viewModel.mediaType)
|
||||
coverView.apply(item: viewModel.coverImage)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func nameChanged() {
|
||||
viewModel.updateAlbumName(nameField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func mediaTypeTapped() {
|
||||
let sheet = UIAlertController(title: "素材类型", message: nil, preferredStyle: .actionSheet)
|
||||
SampleMediaType.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 scenicTapped() {
|
||||
let sheet = UIAlertController(title: "关联景区", message: nil, preferredStyle: .actionSheet)
|
||||
viewModel.scenicList.forEach { scenic in
|
||||
sheet.addAction(UIAlertAction(title: scenic.name, style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.selectScenic(scenic, api: self.api) }
|
||||
})
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
@objc private func spotTapped() {
|
||||
guard viewModel.selectedScenic != nil else {
|
||||
showToast("请先选择关联景区")
|
||||
return
|
||||
}
|
||||
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 projectTapped() {
|
||||
guard viewModel.selectedScenic != nil else {
|
||||
showToast("请先选择景区")
|
||||
return
|
||||
}
|
||||
Task {
|
||||
if viewModel.projectList.isEmpty {
|
||||
showLoading()
|
||||
await viewModel.loadProjects(refresh: true, api: api)
|
||||
hideLoading()
|
||||
}
|
||||
presentProjectSheet()
|
||||
}
|
||||
}
|
||||
|
||||
private func presentProjectSheet() {
|
||||
let sheet = UIAlertController(title: "关联项目", message: nil, preferredStyle: .actionSheet)
|
||||
viewModel.projectList.forEach { project in
|
||||
sheet.addAction(UIAlertAction(title: project.name, style: .default) { [weak self] _ in
|
||||
self?.viewModel.selectProject(project)
|
||||
})
|
||||
}
|
||||
if viewModel.projectCanLoadMore {
|
||||
sheet.addAction(UIAlertAction(title: "加载更多...", style: .default) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task {
|
||||
await self.viewModel.loadProjects(refresh: false, api: self.api)
|
||||
self.presentProjectSheet()
|
||||
}
|
||||
})
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
Task {
|
||||
showLoading()
|
||||
defer { hideLoading() }
|
||||
await viewModel.uploadSample(api: api)
|
||||
}
|
||||
}
|
||||
|
||||
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: [SampleUploadedMediaItem]) {
|
||||
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) -> SampleUploadedMediaItem? {
|
||||
guard let data = image.jpegData(compressionQuality: 0.9) else { return nil }
|
||||
return SampleUploadedMediaItem(
|
||||
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) -> SampleUploadedMediaItem {
|
||||
let size = videoDisplaySize(url: sourceURL)
|
||||
return SampleUploadedMediaItem(
|
||||
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 UploadSampleViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard !results.isEmpty else { return }
|
||||
|
||||
var loadedItems = [SampleUploadedMediaItem?](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" } ?? "sample_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 = "sample_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 UploadSampleViewController: 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: "sample_image_\(Int(Date().timeIntervalSince1970)).jpg") {
|
||||
uploadPickedItems([item])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 样片上传表单字段容器。
|
||||
private final class SampleFormFieldView: 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 SampleSelectButton: 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 SampleUploadGridView: UIView {
|
||||
private let stack = UIStackView()
|
||||
private var items: [SampleUploadedMediaItem] = []
|
||||
private var mediaType: SampleMediaType = .image
|
||||
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: [SampleUploadedMediaItem], mediaType: SampleMediaType) {
|
||||
self.items = items
|
||||
self.mediaType = mediaType
|
||||
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))
|
||||
} else {
|
||||
rowStack.addArrangedSubview(UIView())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeCell(item: SampleUploadedMediaItem?, index: Int) -> UIView {
|
||||
let cell = SampleUploadThumbView(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 SampleUploadThumbView: 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: SampleMediaType
|
||||
|
||||
var onAdd: (() -> Void)?
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
init(mediaType: SampleMediaType) {
|
||||
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
|
||||
backgroundColor = .white
|
||||
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
|
||||
layer.borderWidth = 1
|
||||
addCircle.isHidden = false
|
||||
textLabel.isHidden = false
|
||||
textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频"
|
||||
deleteButton.isHidden = true
|
||||
playIcon.isHidden = true
|
||||
isUserInteractionEnabled = true
|
||||
}
|
||||
|
||||
func apply(item: SampleUploadedMediaItem) {
|
||||
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
|
||||
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 SampleCoverUploadView: 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: SampleUploadedMediaItem?) {
|
||||
guard let item else {
|
||||
imageView.image = nil
|
||||
addCircle.isHidden = false
|
||||
titleLabel.isHidden = false
|
||||
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 {
|
||||
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 SampleUploadProgressOverlay: 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: SampleUploadDialogState) {
|
||||
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)))%"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user