完善样片管理列表、详情与上传页 UI 交互。
对齐 Android 样片模块布局与加载状态,补充共享 UI 组件与图片资源,并增强列表解析容错与单元测试覆盖。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -9,7 +9,7 @@ import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 样片详情页,对齐 Android `SampleDetailScreen`。
|
||||
final class SampleDetailViewController: BaseViewController {
|
||||
final class SampleDetailViewController: SampleBaseViewController {
|
||||
private let viewModel: SampleDetailViewModel
|
||||
private let api: any SampleManagementServing
|
||||
|
||||
@ -21,6 +21,7 @@ final class SampleDetailViewController: BaseViewController {
|
||||
private let scenicSection = SampleDetailImageInfoSection(title: "打卡地点")
|
||||
private let projectSection = SampleDetailImageInfoSection(title: "样片绑定套餐")
|
||||
private let reviewSection = SampleDetailReviewSection()
|
||||
private let bottomSpacer = UIView()
|
||||
|
||||
/// 初始化样片详情页。
|
||||
init(
|
||||
@ -38,14 +39,16 @@ final class SampleDetailViewController: BaseViewController {
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "样片详情"
|
||||
super.setupNavigationBar()
|
||||
setSampleTitle("样片详情")
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
super.setupUI()
|
||||
view.backgroundColor = SampleManagementUITokens.pageBackground
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 12
|
||||
scrollView.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
contentStack.spacing = 0
|
||||
scrollView.backgroundColor = SampleManagementUITokens.pageBackground
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
@ -55,11 +58,18 @@ final class SampleDetailViewController: BaseViewController {
|
||||
contentStack.addArrangedSubview(scenicSection)
|
||||
contentStack.addArrangedSubview(projectSection)
|
||||
contentStack.addArrangedSubview(reviewSection)
|
||||
contentStack.addArrangedSubview(bottomSpacer)
|
||||
contentStack.setCustomSpacing(12, after: sampleInfoSection)
|
||||
contentStack.setCustomSpacing(12, after: uploaderSection)
|
||||
contentStack.setCustomSpacing(12, after: scenicSection)
|
||||
contentStack.setCustomSpacing(12, after: projectSection)
|
||||
bottomSpacer.snp.makeConstraints { make in make.height.equalTo(16) }
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
make.top.equalToSuperview()
|
||||
make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
@ -96,7 +106,7 @@ final class SampleDetailViewController: BaseViewController {
|
||||
hideLoading()
|
||||
}
|
||||
guard let detail = viewModel.detail else { return }
|
||||
carouselView.apply(media: detail.mediaList ?? [])
|
||||
carouselView.apply(media: detail.mediaList ?? [], fallbackType: detail.type ?? 1)
|
||||
sampleInfoSection.apply(detail: detail)
|
||||
uploaderSection.apply(detail: detail)
|
||||
scenicSection.applyScenic(detail: detail)
|
||||
@ -108,10 +118,15 @@ final class SampleDetailViewController: BaseViewController {
|
||||
/// 样片详情媒体轮播,支持图片自动切换与视频播完切换。
|
||||
private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
private let scrollView = UIScrollView()
|
||||
private let pageControl = UIPageControl()
|
||||
private let indicatorStack = UIStackView()
|
||||
private let placeholderLabel = UILabel()
|
||||
private var media: [SampleDetailMediaItem] = []
|
||||
private var renderedMediaIndices: [Int] = []
|
||||
private var currentRenderedPage = 0
|
||||
private var fallbackType = 1
|
||||
private var pageViews: [UIView] = []
|
||||
private var players: [Int: AVPlayer] = [:]
|
||||
private var playerViews: [Int: SamplePlayerLayerView] = [:]
|
||||
private var timer: Timer?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
@ -121,22 +136,24 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
scrollView.delegate = self
|
||||
scrollView.bounces = false
|
||||
pageControl.currentPageIndicatorTintColor = .white
|
||||
pageControl.pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.5)
|
||||
indicatorStack.axis = .horizontal
|
||||
indicatorStack.spacing = 6
|
||||
indicatorStack.alignment = .center
|
||||
placeholderLabel.text = "暂无媒体"
|
||||
placeholderLabel.font = .systemFont(ofSize: 14)
|
||||
placeholderLabel.textColor = AppColor.textSecondary
|
||||
placeholderLabel.textAlignment = .center
|
||||
|
||||
addSubview(scrollView)
|
||||
addSubview(pageControl)
|
||||
addSubview(indicatorStack)
|
||||
addSubview(placeholderLabel)
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
pageControl.snp.makeConstraints { make in
|
||||
indicatorStack.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.bottom.equalToSuperview().inset(8)
|
||||
make.bottom.equalToSuperview().inset(12)
|
||||
make.height.equalTo(8)
|
||||
}
|
||||
placeholderLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
@ -154,27 +171,35 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
}
|
||||
|
||||
/// 应用媒体列表。
|
||||
func apply(media: [SampleDetailMediaItem]) {
|
||||
func apply(media: [SampleDetailMediaItem], fallbackType: Int) {
|
||||
stopTimer()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
players.values.forEach { $0.pause() }
|
||||
players.removeAll()
|
||||
playerViews.removeAll()
|
||||
pageViews.removeAll()
|
||||
self.media = media
|
||||
self.fallbackType = fallbackType
|
||||
renderedMediaIndices = media.count > 1
|
||||
? [media.count - 1] + Array(media.indices) + [0]
|
||||
: Array(media.indices)
|
||||
currentRenderedPage = media.count > 1 ? 1 : 0
|
||||
scrollView.subviews.forEach { $0.removeFromSuperview() }
|
||||
pageControl.numberOfPages = media.count
|
||||
pageControl.currentPage = 0
|
||||
pageControl.isHidden = media.count <= 1
|
||||
rebuildIndicators(currentIndex: 0)
|
||||
indicatorStack.isHidden = media.count <= 1
|
||||
placeholderLabel.isHidden = !media.isEmpty
|
||||
scrollView.isHidden = media.isEmpty
|
||||
guard !media.isEmpty else { return }
|
||||
|
||||
for (index, item) in media.enumerated() {
|
||||
for (renderedIndex, mediaIndex) in renderedMediaIndices.enumerated() {
|
||||
let item = media[mediaIndex]
|
||||
let page = UIView()
|
||||
page.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
page.tag = index
|
||||
page.tag = renderedIndex
|
||||
scrollView.addSubview(page)
|
||||
if item.type == 2 {
|
||||
addVideo(urlText: item.displayURL, to: page, index: index)
|
||||
pageViews.append(page)
|
||||
if item.resolvedType(fallback: fallbackType) == 2 {
|
||||
addVideo(urlText: item.displayURL, to: page, index: renderedIndex)
|
||||
} else {
|
||||
addImage(urlText: item.displayURL, to: page)
|
||||
}
|
||||
@ -198,6 +223,10 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
updateCurrentPage()
|
||||
}
|
||||
|
||||
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||
stopTimer()
|
||||
}
|
||||
|
||||
private func addImage(urlText: String, to page: UIView) {
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
@ -207,7 +236,13 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
if let url = URL(string: urlText), !urlText.isEmpty {
|
||||
imageView.kf.setImage(with: url, placeholder: UIImage(named: "img_square_loading_big"))
|
||||
imageView.kf.setImage(with: url, placeholder: UIImage(named: "sample_img_loading")) { result in
|
||||
if case .failure = result {
|
||||
imageView.image = UIImage(named: "sample_img_error")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
imageView.image = UIImage(named: "sample_img_error")
|
||||
}
|
||||
}
|
||||
|
||||
@ -219,7 +254,8 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
let player = AVPlayer(url: url)
|
||||
players[index] = player
|
||||
let playerView = SamplePlayerLayerView()
|
||||
playerView.playerLayer.player = player
|
||||
playerView.configure(player: player)
|
||||
playerViews[index] = playerView
|
||||
page.addSubview(playerView)
|
||||
playerView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
@ -235,32 +271,42 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
private func layoutPages() {
|
||||
let size = bounds.size
|
||||
guard size.width > 0, size.height > 0 else { return }
|
||||
for (index, page) in scrollView.subviews.enumerated() {
|
||||
for (index, page) in pageViews.enumerated() {
|
||||
page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height)
|
||||
}
|
||||
scrollView.contentSize = CGSize(width: size.width * CGFloat(media.count), height: size.height)
|
||||
scrollView.contentOffset = CGPoint(x: CGFloat(pageControl.currentPage) * size.width, y: 0)
|
||||
scrollView.contentSize = CGSize(width: size.width * CGFloat(renderedMediaIndices.count), height: size.height)
|
||||
scrollView.contentOffset = CGPoint(x: CGFloat(currentRenderedPage) * size.width, y: 0)
|
||||
}
|
||||
|
||||
private func updateCurrentPage() {
|
||||
guard bounds.width > 0 else { return }
|
||||
guard bounds.width > 0, !renderedMediaIndices.isEmpty else { return }
|
||||
let page = Int(round(scrollView.contentOffset.x / bounds.width))
|
||||
pageControl.currentPage = max(0, min(page, media.count - 1))
|
||||
currentRenderedPage = max(0, min(page, renderedMediaIndices.count - 1))
|
||||
if media.count > 1, currentRenderedPage == 0 {
|
||||
currentRenderedPage = media.count
|
||||
scrollView.setContentOffset(CGPoint(x: CGFloat(currentRenderedPage) * bounds.width, y: 0), animated: false)
|
||||
} else if media.count > 1, currentRenderedPage == renderedMediaIndices.count - 1 {
|
||||
currentRenderedPage = 1
|
||||
scrollView.setContentOffset(CGPoint(x: bounds.width, y: 0), animated: false)
|
||||
}
|
||||
rebuildIndicators(currentIndex: currentMediaIndex)
|
||||
playCurrentPageIfNeeded()
|
||||
}
|
||||
|
||||
private func playCurrentPageIfNeeded() {
|
||||
stopTimer()
|
||||
players.forEach { index, player in
|
||||
if index == pageControl.currentPage {
|
||||
if index == currentRenderedPage {
|
||||
player.seek(to: .zero)
|
||||
player.play()
|
||||
playerViews[index]?.setPlaying(true)
|
||||
} else {
|
||||
player.pause()
|
||||
playerViews[index]?.setPlaying(false)
|
||||
}
|
||||
}
|
||||
guard !media.isEmpty else { return }
|
||||
if media[pageControl.currentPage].type == 1 {
|
||||
if media[currentMediaIndex].resolvedType(fallback: fallbackType) == 1 {
|
||||
startImageTimer()
|
||||
}
|
||||
}
|
||||
@ -278,13 +324,15 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
}
|
||||
|
||||
@objc private func videoDidEnd(_ notification: Notification) {
|
||||
guard let item = notification.object as? AVPlayerItem,
|
||||
players[currentRenderedPage]?.currentItem === item else { return }
|
||||
advancePage()
|
||||
}
|
||||
|
||||
private func advancePage() {
|
||||
guard media.count > 1, bounds.width > 0 else { return }
|
||||
let nextPage = (pageControl.currentPage + 1) % media.count
|
||||
pageControl.currentPage = nextPage
|
||||
let nextPage = min(currentRenderedPage + 1, renderedMediaIndices.count - 1)
|
||||
currentRenderedPage = nextPage
|
||||
scrollView.setContentOffset(CGPoint(x: CGFloat(nextPage) * bounds.width, y: 0), animated: true)
|
||||
if !scrollView.isDragging && !scrollView.isDecelerating {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||
@ -292,12 +340,92 @@ private final class SampleMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var currentMediaIndex: Int {
|
||||
guard renderedMediaIndices.indices.contains(currentRenderedPage) else { return 0 }
|
||||
return renderedMediaIndices[currentRenderedPage]
|
||||
}
|
||||
|
||||
private func rebuildIndicators(currentIndex: Int) {
|
||||
indicatorStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
guard media.count > 1 else { return }
|
||||
for index in media.indices {
|
||||
let dot = UIView()
|
||||
let size: CGFloat = index == currentIndex ? 8 : 6
|
||||
dot.backgroundColor = index == currentIndex ? .white : UIColor.white.withAlphaComponent(0.5)
|
||||
dot.layer.cornerRadius = size / 2
|
||||
dot.snp.makeConstraints { make in make.size.equalTo(size) }
|
||||
indicatorStack.addArrangedSubview(dot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AVPlayerLayer 容器。
|
||||
/// AVPlayerLayer 容器,提供点击显示且三秒自动隐藏的播放控制。
|
||||
private final class SamplePlayerLayerView: UIView {
|
||||
private let controlButton = UIButton(type: .system)
|
||||
private weak var player: AVPlayer?
|
||||
private var hideControlWorkItem: DispatchWorkItem?
|
||||
|
||||
override class var layerClass: AnyClass { AVPlayerLayer.self }
|
||||
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
controlButton.tintColor = .white
|
||||
controlButton.backgroundColor = UIColor.black.withAlphaComponent(0.45)
|
||||
controlButton.layer.cornerRadius = 22
|
||||
controlButton.isHidden = true
|
||||
controlButton.accessibilityLabel = "播放或暂停视频"
|
||||
addSubview(controlButton)
|
||||
controlButton.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(44)
|
||||
}
|
||||
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showControls)))
|
||||
controlButton.addTarget(self, action: #selector(togglePlayback), for: .touchUpInside)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 绑定播放器。
|
||||
func configure(player: AVPlayer) {
|
||||
self.player = player
|
||||
playerLayer.player = player
|
||||
updateControlImage()
|
||||
}
|
||||
|
||||
/// 同步轮播切页后的播放状态。
|
||||
func setPlaying(_ playing: Bool) {
|
||||
if !playing { controlButton.isHidden = true }
|
||||
updateControlImage()
|
||||
}
|
||||
|
||||
@objc private func showControls() {
|
||||
controlButton.isHidden = false
|
||||
updateControlImage()
|
||||
hideControlWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in self?.controlButton.isHidden = true }
|
||||
hideControlWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: workItem)
|
||||
}
|
||||
|
||||
@objc private func togglePlayback() {
|
||||
guard let player else { return }
|
||||
if player.rate == 0 {
|
||||
player.play()
|
||||
} else {
|
||||
player.pause()
|
||||
}
|
||||
showControls()
|
||||
}
|
||||
|
||||
private func updateControlImage() {
|
||||
let name = player?.rate == 0 ? "play.fill" : "pause.fill"
|
||||
controlButton.setImage(UIImage(systemName: name), for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
/// 样片详情名称与上架状态区。
|
||||
@ -508,11 +636,16 @@ private final class SampleDetailImageInfoSection: UIView {
|
||||
titleLabel.text = detail.scenicName ?? ""
|
||||
firstLineLabel.attributedText = coloredValue(prefix: "无人机: ", value: "\(detail.scenicPlanesNum)架")
|
||||
secondLineLabel.attributedText = coloredValue(prefix: "摄影师: ", value: "\(detail.scenicPhotographerNum)人")
|
||||
let shouldShowCounts = detail.scenicPlanesNum > 0 || detail.scenicPhotographerNum > 0
|
||||
firstLineLabel.isHidden = !shouldShowCounts
|
||||
secondLineLabel.isHidden = !shouldShowCounts
|
||||
setImage(detail.scenicCoverImg)
|
||||
}
|
||||
|
||||
func applyProject(detail: SampleDetail) {
|
||||
titleLabel.text = detail.projectName ?? ""
|
||||
firstLineLabel.isHidden = detail.projectPrice == nil
|
||||
secondLineLabel.isHidden = detail.projectPriceDeposit == nil
|
||||
if let price = detail.projectPrice {
|
||||
firstLineLabel.attributedText = coloredValue(prefix: "价格: ", value: "\(price)元")
|
||||
} else {
|
||||
@ -528,9 +661,13 @@ private final class SampleDetailImageInfoSection: UIView {
|
||||
|
||||
private func setImage(_ urlText: String?) {
|
||||
if let urlText, let url = URL(string: urlText), !urlText.isEmpty {
|
||||
imageView.kf.setImage(with: url)
|
||||
imageView.kf.setImage(with: url, placeholder: UIImage(named: "sample_img_loading")) { [weak imageView] result in
|
||||
if case .failure = result {
|
||||
imageView?.image = UIImage(named: "sample_img_error")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
imageView.image = nil
|
||||
imageView.image = UIImage(named: "sample_img_error")
|
||||
}
|
||||
}
|
||||
|
||||
@ -586,6 +723,7 @@ private final class SampleDetailReviewSection: UIView {
|
||||
reviewTimeRow.apply(label: "审核时间", value: detail.reviewTime?.nonEmpty ?? "--")
|
||||
statusRow.apply(status: detail.status)
|
||||
noteRow.apply(label: "审核备注", value: detail.reviewNotes?.nonEmpty ?? "--", valueColor: UIColor(hex: 0x4B5563), valueFont: .systemFont(ofSize: 12))
|
||||
noteRow.setSeparatorHidden(true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -634,6 +772,11 @@ private final class SampleDetailInfoRow: UIView {
|
||||
valueLabel.textColor = valueColor
|
||||
valueLabel.font = valueFont
|
||||
}
|
||||
|
||||
/// 控制行底部分隔线显示。
|
||||
func setSeparatorHidden(_ hidden: Bool) {
|
||||
separator.isHidden = hidden
|
||||
}
|
||||
}
|
||||
|
||||
/// 样片详情审核状态行。
|
||||
|
||||
@ -8,7 +8,7 @@ import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 样片相册列表页,对齐 Android `SampleListScreen`。
|
||||
final class SampleListViewController: BaseViewController {
|
||||
final class SampleListViewController: SampleBaseViewController {
|
||||
private let viewModel: SampleListViewModel
|
||||
private let api: any SampleManagementServing
|
||||
|
||||
@ -45,11 +45,13 @@ final class SampleListViewController: BaseViewController {
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "样片相册列表"
|
||||
super.setupNavigationBar()
|
||||
setSampleTitle("样片相册列表")
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
super.setupUI()
|
||||
view.backgroundColor = SampleManagementUITokens.pageBackground
|
||||
|
||||
searchContainer.backgroundColor = .white
|
||||
searchBox.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
@ -65,7 +67,7 @@ final class SampleListViewController: BaseViewController {
|
||||
filterContainer.backgroundColor = .white
|
||||
filterButton.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
filterButton.layer.cornerRadius = 4
|
||||
filterTitleLabel.text = "筛选"
|
||||
filterTitleLabel.text = SampleFilterStatus.all.title
|
||||
filterTitleLabel.font = .systemFont(ofSize: 14)
|
||||
filterTitleLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
filterChevronView.tintColor = UIColor(hex: 0x4B5563)
|
||||
@ -80,7 +82,7 @@ final class SampleListViewController: BaseViewController {
|
||||
filterDropdownStack.axis = .vertical
|
||||
filterDropdownStack.spacing = 0
|
||||
|
||||
tableView.backgroundColor = AppColor.pageBackground
|
||||
tableView.backgroundColor = SampleManagementUITokens.pageBackground
|
||||
tableView.separatorStyle = .none
|
||||
tableView.estimatedRowHeight = 152
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
@ -95,10 +97,15 @@ final class SampleListViewController: BaseViewController {
|
||||
withIdentifier: SampleListCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! SampleListCell
|
||||
cell.apply(item: item)
|
||||
cell.onToggle = { [weak self] checked in
|
||||
cell.apply(item: item, isOperating: self?.viewModel.operatingSampleIDs.contains(item.id) == true)
|
||||
cell.onToggle = { [weak self, weak cell] checked in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.toggleListing(sampleId: item.id, checked: checked, api: self.api) }
|
||||
Task {
|
||||
let resolved = await self.viewModel.toggleListing(sampleId: item.id, checked: checked, api: self.api)
|
||||
await MainActor.run {
|
||||
cell?.resolveToggle(sampleId: item.id, isOn: resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cell
|
||||
}
|
||||
@ -135,7 +142,7 @@ final class SampleListViewController: BaseViewController {
|
||||
|
||||
override func setupConstraints() {
|
||||
searchContainer.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||
make.top.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(68)
|
||||
}
|
||||
@ -232,7 +239,7 @@ final class SampleListViewController: BaseViewController {
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
filterTitleLabel.text = viewModel.filterStatus == .all ? "筛选" : viewModel.filterStatus.title
|
||||
filterTitleLabel.text = viewModel.filterStatus.title
|
||||
filterOptionButtons.forEach { status, button in
|
||||
let selected = status == viewModel.filterStatus
|
||||
button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal)
|
||||
@ -342,11 +349,13 @@ private final class SampleListCell: UITableViewCell {
|
||||
private let scenicSpotLabel = UILabel()
|
||||
private let statusBadge = SampleStatusBadgeView()
|
||||
private let listingSwitch = UISwitch()
|
||||
private let listingActivityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
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
|
||||
private var representedSampleID: Int?
|
||||
|
||||
var onToggle: ((Bool) -> Void)?
|
||||
|
||||
@ -360,13 +369,23 @@ private final class SampleListCell: UITableViewCell {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(item: SampleMaterialItem) {
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
representedSampleID = nil
|
||||
onToggle = nil
|
||||
}
|
||||
|
||||
func apply(item: SampleMaterialItem, isOperating: Bool) {
|
||||
isApplying = true
|
||||
representedSampleID = item.id
|
||||
titleLabel.text = item.name
|
||||
projectLabel.text = "项目: \(item.projectName)"
|
||||
scenicSpotLabel.text = "打卡地: \(item.scenicSpotName)"
|
||||
statusBadge.apply(status: item.status)
|
||||
listingSwitch.setOn(item.listingStatus == 1, animated: false)
|
||||
listingSwitch.isEnabled = item.status == 1 && !isOperating
|
||||
listingSwitch.isHidden = isOperating
|
||||
isOperating ? listingActivityIndicator.startAnimating() : listingActivityIndicator.stopAnimating()
|
||||
shareStatView.setCount(item.shareCount)
|
||||
likeStatView.setCount(item.likesCount)
|
||||
collectStatView.setCount(item.collectCount)
|
||||
@ -381,10 +400,21 @@ private final class SampleListCell: UITableViewCell {
|
||||
isApplying = false
|
||||
}
|
||||
|
||||
/// 请求结束后恢复服务端确认的开关状态,避免审核拒绝或接口失败时停留在错误位置。
|
||||
func resolveToggle(sampleId: Int, isOn: Bool) {
|
||||
guard representedSampleID == sampleId else { return }
|
||||
isApplying = true
|
||||
listingSwitch.setOn(isOn, animated: true)
|
||||
listingSwitch.isEnabled = true
|
||||
listingSwitch.isHidden = false
|
||||
listingActivityIndicator.stopAnimating()
|
||||
isApplying = false
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
selectionStyle = .none
|
||||
backgroundColor = AppColor.pageBackground
|
||||
contentView.backgroundColor = AppColor.pageBackground
|
||||
backgroundColor = SampleManagementUITokens.pageBackground
|
||||
contentView.backgroundColor = SampleManagementUITokens.pageBackground
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 12
|
||||
@ -411,7 +441,10 @@ private final class SampleListCell: UITableViewCell {
|
||||
}
|
||||
|
||||
listingSwitch.onTintColor = AppColor.primary
|
||||
listingSwitch.accessibilityLabel = "样片上下架"
|
||||
listingSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged)
|
||||
listingActivityIndicator.color = AppColor.primary
|
||||
listingActivityIndicator.hidesWhenStopped = true
|
||||
|
||||
statsStack.axis = .horizontal
|
||||
statsStack.spacing = 16
|
||||
@ -425,11 +458,13 @@ private final class SampleListCell: UITableViewCell {
|
||||
cardView.addSubview(scenicSpotLabel)
|
||||
cardView.addSubview(statusBadge)
|
||||
cardView.addSubview(listingSwitch)
|
||||
cardView.addSubview(listingActivityIndicator)
|
||||
cardView.addSubview(statsStack)
|
||||
[shareStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview)
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.top.bottom.equalToSuperview().inset(8)
|
||||
make.top.equalToSuperview()
|
||||
make.bottom.equalToSuperview().inset(16)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
coverImageView.snp.makeConstraints { make in
|
||||
@ -463,6 +498,9 @@ private final class SampleListCell: UITableViewCell {
|
||||
make.trailing.equalToSuperview().inset(12)
|
||||
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
|
||||
}
|
||||
listingActivityIndicator.snp.makeConstraints { make in
|
||||
make.center.equalTo(listingSwitch)
|
||||
}
|
||||
statsStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(statusBadge.snp.bottom).offset(10)
|
||||
make.leading.equalTo(titleLabel)
|
||||
@ -473,6 +511,9 @@ private final class SampleListCell: UITableViewCell {
|
||||
|
||||
@objc private func switchChanged() {
|
||||
guard !isApplying else { return }
|
||||
listingSwitch.isEnabled = false
|
||||
listingSwitch.isHidden = true
|
||||
listingActivityIndicator.startAnimating()
|
||||
onToggle?(listingSwitch.isOn)
|
||||
}
|
||||
}
|
||||
|
||||
155
suixinkan/UI/SampleManagement/SampleManagementUI.swift
Normal file
155
suixinkan/UI/SampleManagement/SampleManagementUI.swift
Normal file
@ -0,0 +1,155 @@
|
||||
//
|
||||
// SampleManagementUI.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 样片管理模块视觉常量,严格对应 Android Compose 页面中的 dp、颜色与圆角。
|
||||
enum SampleManagementUITokens {
|
||||
static let pageBackground = UIColor(hex: 0xF4F4F4)
|
||||
static let inputBackground = UIColor(hex: 0xF4F4F4)
|
||||
static let border = UIColor(hex: 0xEEEEEE)
|
||||
static let placeholder = UIColor(hex: 0xB6BECA)
|
||||
static let text563 = UIColor(hex: 0x4B5563)
|
||||
static let statText = UIColor(hex: 0x6B7280)
|
||||
static let screenInset: CGFloat = 16
|
||||
static let sectionSpacing: CGFloat = 12
|
||||
static let cardRadius: CGFloat = 12
|
||||
static let fieldRadius: CGFloat = 8
|
||||
}
|
||||
|
||||
/// 样片管理页面基类,复用“空间设置”的原生导航栏外观与返回行为。
|
||||
class SampleBaseViewController: BaseViewController {
|
||||
override func setupNavigationBar() {
|
||||
navigationItem.backButtonDisplayMode = .minimal
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
navigationController?.setNavigationBarHidden(false, animated: animated)
|
||||
ProfileNavigationStyle.apply(to: navigationController, showsDivider: false)
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
ProfileNavigationStyle.restore(navigationController)
|
||||
}
|
||||
|
||||
/// 设置与“空间设置”一致的原生导航栏标题。
|
||||
func setSampleTitle(_ title: String) {
|
||||
self.title = title
|
||||
}
|
||||
}
|
||||
|
||||
/// 锚定在输入框下方的 Android 风格下拉菜单,支持滚动加载更多。
|
||||
final class SampleAnchoredDropdownView: UIControl, UIScrollViewDelegate {
|
||||
/// 下拉菜单选项。
|
||||
struct Option {
|
||||
let title: String
|
||||
let isSelected: Bool
|
||||
let isLoadMore: Bool
|
||||
|
||||
/// 创建普通或“加载更多”选项。
|
||||
init(title: String, isSelected: Bool = false, isLoadMore: Bool = false) {
|
||||
self.title = title
|
||||
self.isSelected = isSelected
|
||||
self.isLoadMore = isLoadMore
|
||||
}
|
||||
}
|
||||
|
||||
private let cardView = UIView()
|
||||
private let scrollView = UIScrollView()
|
||||
private let stackView = UIStackView()
|
||||
private var onSelect: ((Int) -> Void)?
|
||||
private var onReachedBottom: (() -> Void)?
|
||||
private var didRequestBottom = false
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 4
|
||||
cardView.layer.shadowColor = UIColor.black.cgColor
|
||||
cardView.layer.shadowOpacity = 0.14
|
||||
cardView.layer.shadowRadius = 8
|
||||
cardView.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||
scrollView.delegate = self
|
||||
stackView.axis = .vertical
|
||||
addSubview(cardView)
|
||||
cardView.addSubview(scrollView)
|
||||
scrollView.addSubview(stackView)
|
||||
addTarget(self, action: #selector(dismissMenu), for: .touchUpInside)
|
||||
isHidden = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 在锚点下方展示菜单。
|
||||
func show(
|
||||
anchor: UIView,
|
||||
options: [Option],
|
||||
maximumHeight: CGFloat = 200,
|
||||
onReachedBottom: (() -> Void)? = nil,
|
||||
onSelect: @escaping (Int) -> Void
|
||||
) {
|
||||
guard !options.isEmpty else { return }
|
||||
self.onSelect = onSelect
|
||||
self.onReachedBottom = onReachedBottom
|
||||
didRequestBottom = false
|
||||
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
|
||||
for (index, option) in options.enumerated() {
|
||||
let button = UIButton(type: .system)
|
||||
button.tag = index
|
||||
button.contentHorizontalAlignment = .leading
|
||||
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
||||
button.setTitle(option.title, for: .normal)
|
||||
let color = option.isSelected ? AppColor.primary : (option.isLoadMore ? AppColor.textSecondary : .black)
|
||||
button.setTitleColor(color, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 14, weight: option.isSelected ? .medium : .regular)
|
||||
button.backgroundColor = .white
|
||||
button.accessibilityLabel = option.title
|
||||
button.addTarget(self, action: #selector(optionTapped(_:)), for: .touchUpInside)
|
||||
button.snp.makeConstraints { make in make.height.equalTo(28) }
|
||||
stackView.addArrangedSubview(button)
|
||||
}
|
||||
|
||||
isHidden = false
|
||||
superview?.bringSubviewToFront(self)
|
||||
layoutIfNeeded()
|
||||
let anchorFrame = anchor.convert(anchor.bounds, to: self)
|
||||
let contentHeight = CGFloat(options.count) * 28
|
||||
let menuHeight = min(maximumHeight, contentHeight)
|
||||
cardView.frame = CGRect(x: anchorFrame.minX, y: anchorFrame.maxY, width: anchorFrame.width, height: menuHeight)
|
||||
scrollView.frame = cardView.bounds
|
||||
stackView.frame = CGRect(x: 0, y: 0, width: cardView.bounds.width, height: contentHeight)
|
||||
scrollView.contentSize = stackView.frame.size
|
||||
}
|
||||
|
||||
/// 隐藏菜单。
|
||||
func hide() {
|
||||
isHidden = true
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard !didRequestBottom, scrollView.contentSize.height > scrollView.bounds.height else { return }
|
||||
let remaining = scrollView.contentSize.height - scrollView.contentOffset.y - scrollView.bounds.height
|
||||
guard remaining <= 10 else { return }
|
||||
didRequestBottom = true
|
||||
onReachedBottom?()
|
||||
}
|
||||
|
||||
@objc private func optionTapped(_ sender: UIButton) {
|
||||
isHidden = true
|
||||
onSelect?(sender.tag)
|
||||
}
|
||||
|
||||
@objc private func dismissMenu() {
|
||||
isHidden = true
|
||||
}
|
||||
}
|
||||
@ -10,7 +10,7 @@ import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
/// 样片相册上传页,对齐 Android `UploadSampleScreen`。
|
||||
final class UploadSampleViewController: BaseViewController {
|
||||
final class UploadSampleViewController: SampleBaseViewController {
|
||||
private enum PickTarget {
|
||||
case material
|
||||
case cover
|
||||
@ -42,6 +42,7 @@ final class UploadSampleViewController: BaseViewController {
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
private let progressOverlay = SampleUploadProgressOverlay()
|
||||
private let dropdownOverlay = SampleAnchoredDropdownView()
|
||||
|
||||
private var pickTarget: PickTarget = .material
|
||||
|
||||
@ -65,16 +66,18 @@ final class UploadSampleViewController: BaseViewController {
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "样片相册"
|
||||
super.setupNavigationBar()
|
||||
setSampleTitle("样片相册")
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
super.setupUI()
|
||||
view.backgroundColor = SampleManagementUITokens.pageBackground
|
||||
formCard.backgroundColor = .white
|
||||
formCard.layer.cornerRadius = 12
|
||||
formCard.clipsToBounds = true
|
||||
formStack.axis = .vertical
|
||||
formStack.spacing = 16
|
||||
formStack.spacing = 0
|
||||
|
||||
nameField.placeholder = "请输入相册名称"
|
||||
nameField.font = .systemFont(ofSize: 14)
|
||||
@ -96,6 +99,12 @@ final class UploadSampleViewController: BaseViewController {
|
||||
|
||||
[nameSection, mediaTypeSection, materialSection, coverSection, scenicSection, spotSection, projectSection]
|
||||
.forEach(formStack.addArrangedSubview)
|
||||
formStack.setCustomSpacing(8, after: nameSection)
|
||||
formStack.setCustomSpacing(8, after: mediaTypeSection)
|
||||
formStack.setCustomSpacing(16, after: materialSection)
|
||||
formStack.setCustomSpacing(16, after: coverSection)
|
||||
formStack.setCustomSpacing(8, after: scenicSection)
|
||||
formStack.setCustomSpacing(8, after: spotSection)
|
||||
|
||||
bottomBar.backgroundColor = .white
|
||||
configureBottomButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary)
|
||||
@ -107,6 +116,7 @@ final class UploadSampleViewController: BaseViewController {
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(cancelButton)
|
||||
bottomBar.addSubview(confirmButton)
|
||||
view.addSubview(dropdownOverlay)
|
||||
view.addSubview(progressOverlay)
|
||||
progressOverlay.isHidden = true
|
||||
}
|
||||
@ -128,7 +138,8 @@ final class UploadSampleViewController: BaseViewController {
|
||||
make.trailing.equalToSuperview().inset(15)
|
||||
}
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.top.equalToSuperview()
|
||||
make.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.bottom.equalTo(bottomBar.snp.top)
|
||||
}
|
||||
formCard.snp.makeConstraints { make in
|
||||
@ -149,6 +160,9 @@ final class UploadSampleViewController: BaseViewController {
|
||||
progressOverlay.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
dropdownOverlay.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
@ -169,6 +183,7 @@ final class UploadSampleViewController: BaseViewController {
|
||||
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) }
|
||||
mediaGridView.onPreview = { [weak self] index in self?.presentMediaPreview(startingAt: 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)
|
||||
@ -229,26 +244,25 @@ final class UploadSampleViewController: BaseViewController {
|
||||
}
|
||||
|
||||
@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)
|
||||
})
|
||||
let types = SampleMediaType.allCases
|
||||
dropdownOverlay.show(
|
||||
anchor: mediaTypeButton,
|
||||
options: types.map { .init(title: $0.title, isSelected: $0 == viewModel.mediaType) }
|
||||
) { [weak self] index in
|
||||
guard types.indices.contains(index) else { return }
|
||||
self?.viewModel.selectMediaType(types[index])
|
||||
}
|
||||
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) }
|
||||
})
|
||||
let scenics = viewModel.scenicList
|
||||
dropdownOverlay.show(
|
||||
anchor: scenicButton,
|
||||
options: scenics.map { .init(title: $0.name, isSelected: $0.id == viewModel.selectedScenic?.id) }
|
||||
) { [weak self] index in
|
||||
guard let self, scenics.indices.contains(index) else { return }
|
||||
Task { await self.viewModel.selectScenic(scenics[index], api: self.api) }
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
@objc private func spotTapped() {
|
||||
@ -256,14 +270,14 @@ final class UploadSampleViewController: BaseViewController {
|
||||
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)
|
||||
})
|
||||
let spots = viewModel.scenicSpotList
|
||||
dropdownOverlay.show(
|
||||
anchor: spotButton,
|
||||
options: spots.map { .init(title: $0.name, isSelected: $0.id == viewModel.selectedScenicSpot?.id) }
|
||||
) { [weak self] index in
|
||||
guard spots.indices.contains(index) else { return }
|
||||
self?.viewModel.selectScenicSpot(spots[index])
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
@objc private func projectTapped() {
|
||||
@ -277,28 +291,36 @@ final class UploadSampleViewController: BaseViewController {
|
||||
await viewModel.loadProjects(refresh: true, api: api)
|
||||
hideLoading()
|
||||
}
|
||||
presentProjectSheet()
|
||||
await MainActor.run { self.presentProjectDropdown() }
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
@MainActor
|
||||
private func presentProjectDropdown() {
|
||||
let projects = viewModel.projectList
|
||||
var options = projects.map { SampleAnchoredDropdownView.Option(title: $0.name, isSelected: $0.id == viewModel.selectedProject?.id) }
|
||||
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()
|
||||
}
|
||||
})
|
||||
options.append(.init(title: "加载更多...", isLoadMore: true))
|
||||
}
|
||||
dropdownOverlay.show(
|
||||
anchor: projectButton,
|
||||
options: options,
|
||||
onReachedBottom: { [weak self] in self?.loadMoreProjectsAndRefreshDropdown() }
|
||||
) { [weak self] index in
|
||||
guard let self else { return }
|
||||
if projects.indices.contains(index) {
|
||||
self.viewModel.selectProject(projects[index])
|
||||
} else if self.viewModel.projectCanLoadMore {
|
||||
self.loadMoreProjectsAndRefreshDropdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMoreProjectsAndRefreshDropdown() {
|
||||
Task {
|
||||
await viewModel.loadProjects(refresh: false, api: api)
|
||||
await MainActor.run { self.presentProjectDropdown() }
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
@ -329,6 +351,19 @@ final class UploadSampleViewController: BaseViewController {
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
private func presentMediaPreview(startingAt sourceIndex: Int) {
|
||||
let completed = viewModel.uploadedMediaList.enumerated().filter { !$0.element.isUploading && !$0.element.ossUrl.isEmpty }
|
||||
guard let startIndex = completed.firstIndex(where: { $0.offset == sourceIndex }) else { return }
|
||||
let previewItems = completed.compactMap { entry -> MediaPreviewItem? in
|
||||
switch viewModel.mediaType {
|
||||
case .image: return .remoteImage(entry.element.ossUrl)
|
||||
case .video: return .remoteVideo(entry.element.ossUrl)
|
||||
}
|
||||
}
|
||||
guard !previewItems.isEmpty else { return }
|
||||
presentMediaPreview(items: previewItems, startIndex: min(startIndex, previewItems.count - 1))
|
||||
}
|
||||
|
||||
private func presentPhotoPicker() {
|
||||
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||
if pickTarget == .cover {
|
||||
@ -424,16 +459,28 @@ extension UploadSampleViewController: PHPickerViewControllerDelegate {
|
||||
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"
|
||||
let fileName = self.resolvedFileName(provider: provider, url: url, fallbackExtension: "mp4")
|
||||
loadedItems[index] = self.makeVideoItem(data: data, fileName: fileName, sourceURL: url)
|
||||
}
|
||||
} else {
|
||||
group.enter()
|
||||
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
||||
let imageType = provider.registeredTypeIdentifiers.first {
|
||||
UTType($0)?.conforms(to: .image) == true
|
||||
} ?? UTType.image.identifier
|
||||
provider.loadFileRepresentation(forTypeIdentifier: imageType) { [weak self] url, _ 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)
|
||||
guard let self, let url, let data = try? Data(contentsOf: url), let image = UIImage(data: data) else { return }
|
||||
let pathExtension = url.pathExtension
|
||||
let ext = UTType(imageType)?.preferredFilenameExtension ?? (pathExtension.isEmpty ? "jpg" : pathExtension)
|
||||
let fileName = self.resolvedFileName(provider: provider, url: url, fallbackExtension: ext)
|
||||
loadedItems[index] = SampleUploadedMediaItem(
|
||||
data: data,
|
||||
thumbnailData: image.jpegData(compressionQuality: 0.75),
|
||||
fileName: fileName,
|
||||
size: Int64(data.count),
|
||||
width: Int(image.size.width * image.scale),
|
||||
height: Int(image.size.height * image.scale)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -442,6 +489,18 @@ extension UploadSampleViewController: PHPickerViewControllerDelegate {
|
||||
self?.uploadPickedItems(loadedItems.compactMap { $0 })
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedFileName(provider: NSItemProvider, url: URL, fallbackExtension: String) -> String {
|
||||
let suggested = provider.suggestedName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !suggested.isEmpty {
|
||||
return URL(fileURLWithPath: suggested).pathExtension.isEmpty
|
||||
? "\(suggested).\(fallbackExtension)"
|
||||
: suggested
|
||||
}
|
||||
let urlName = url.lastPathComponent
|
||||
if !urlName.isEmpty, urlName != "." { return urlName }
|
||||
return "sample_\(Int(Date().timeIntervalSince1970)).\(fallbackExtension)"
|
||||
}
|
||||
}
|
||||
|
||||
extension UploadSampleViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
@ -544,6 +603,7 @@ private final class SampleUploadGridView: UIView {
|
||||
private var mediaType: SampleMediaType = .image
|
||||
var onAdd: (() -> Void)?
|
||||
var onDelete: ((Int) -> Void)?
|
||||
var onPreview: ((Int) -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@ -593,6 +653,7 @@ private final class SampleUploadGridView: UIView {
|
||||
if let item {
|
||||
cell.apply(item: item)
|
||||
cell.onDelete = { [weak self] in self?.onDelete?(index) }
|
||||
cell.onPreview = { [weak self] in self?.onPreview?(index) }
|
||||
} else {
|
||||
cell.applyAdd()
|
||||
cell.onAdd = { [weak self] in self?.onAdd?() }
|
||||
@ -605,14 +666,17 @@ private final class SampleUploadGridView: UIView {
|
||||
private final class SampleUploadThumbView: UIControl {
|
||||
private let imageView = UIImageView()
|
||||
private let addCircle = UIView()
|
||||
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
|
||||
private let addIcon = UIImageView(image: UIImage(named: "material_ic_add"))
|
||||
private let textLabel = UILabel()
|
||||
private let deleteButton = UIButton(type: .system)
|
||||
private let playIcon = UIImageView(image: UIImage(systemName: "play.fill"))
|
||||
private let deleteButton = SampleExpandedTouchButton(type: .system)
|
||||
private let playIcon = UIImageView(image: UIImage(named: "material_ic_play"))
|
||||
private let mediaType: SampleMediaType
|
||||
private let dashedLayer = CAShapeLayer()
|
||||
private var previewEnabled = false
|
||||
|
||||
var onAdd: (() -> Void)?
|
||||
var onDelete: (() -> Void)?
|
||||
var onPreview: (() -> Void)?
|
||||
|
||||
init(mediaType: SampleMediaType) {
|
||||
self.mediaType = mediaType
|
||||
@ -628,7 +692,7 @@ private final class SampleUploadThumbView: UIControl {
|
||||
textLabel.font = .systemFont(ofSize: 12)
|
||||
textLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
textLabel.textAlignment = .center
|
||||
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||
deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
|
||||
deleteButton.tintColor = .white
|
||||
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
||||
deleteButton.layer.cornerRadius = 10
|
||||
@ -665,7 +729,12 @@ private final class SampleUploadThumbView: UIControl {
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(32)
|
||||
}
|
||||
addTarget(self, action: #selector(addTapped), for: .touchUpInside)
|
||||
dashedLayer.strokeColor = UIColor(hex: 0xE5E7EB).cgColor
|
||||
dashedLayer.fillColor = UIColor.clear.cgColor
|
||||
dashedLayer.lineWidth = 1
|
||||
dashedLayer.lineDashPattern = [6, 6]
|
||||
layer.addSublayer(dashedLayer)
|
||||
addTarget(self, action: #selector(cellTapped), for: .touchUpInside)
|
||||
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
@ -674,11 +743,18 @@ private final class SampleUploadThumbView: UIControl {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
dashedLayer.frame = bounds
|
||||
dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 8).cgPath
|
||||
}
|
||||
|
||||
func applyAdd() {
|
||||
imageView.image = nil
|
||||
backgroundColor = .white
|
||||
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
|
||||
layer.borderWidth = 1
|
||||
layer.borderWidth = 0
|
||||
dashedLayer.isHidden = false
|
||||
previewEnabled = false
|
||||
addCircle.isHidden = false
|
||||
textLabel.isHidden = false
|
||||
textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频"
|
||||
@ -688,7 +764,8 @@ private final class SampleUploadThumbView: UIControl {
|
||||
}
|
||||
|
||||
func apply(item: SampleUploadedMediaItem) {
|
||||
layer.borderWidth = 0
|
||||
dashedLayer.isHidden = true
|
||||
previewEnabled = !item.ossUrl.isEmpty && !item.isUploading
|
||||
backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
addCircle.isHidden = true
|
||||
deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading
|
||||
@ -704,9 +781,12 @@ private final class SampleUploadThumbView: UIControl {
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func addTapped() {
|
||||
guard !addCircle.isHidden else { return }
|
||||
onAdd?()
|
||||
@objc private func cellTapped() {
|
||||
if !addCircle.isHidden {
|
||||
onAdd?()
|
||||
} else if previewEnabled {
|
||||
onPreview?()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func deleteTapped() {
|
||||
@ -718,18 +798,22 @@ private final class SampleUploadThumbView: UIControl {
|
||||
private final class SampleCoverUploadView: UIControl {
|
||||
private let imageView = UIImageView()
|
||||
private let addCircle = UIView()
|
||||
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
|
||||
private let addIcon = UIImageView(image: UIImage(named: "material_ic_add"))
|
||||
private let titleLabel = UILabel()
|
||||
private let hintLabel = UILabel()
|
||||
private let deleteButton = UIButton(type: .system)
|
||||
private let deleteButton = SampleExpandedTouchButton(type: .system)
|
||||
private let dashedLayer = CAShapeLayer()
|
||||
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
|
||||
dashedLayer.strokeColor = UIColor(hex: 0xE5E7EB).cgColor
|
||||
dashedLayer.fillColor = UIColor.clear.cgColor
|
||||
dashedLayer.lineWidth = 1
|
||||
dashedLayer.lineDashPattern = [6, 6]
|
||||
layer.addSublayer(dashedLayer)
|
||||
clipsToBounds = true
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
@ -744,7 +828,7 @@ private final class SampleCoverUploadView: UIControl {
|
||||
hintLabel.font = .systemFont(ofSize: 12)
|
||||
hintLabel.textColor = UIColor(hex: 0xB6BECA)
|
||||
hintLabel.textAlignment = .center
|
||||
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||
deleteButton.setImage(UIImage(named: "material_ic_clear"), for: .normal)
|
||||
deleteButton.tintColor = .white
|
||||
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
||||
deleteButton.layer.cornerRadius = 12
|
||||
@ -791,13 +875,22 @@ private final class SampleCoverUploadView: UIControl {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
dashedLayer.frame = bounds
|
||||
dashedLayer.path = UIBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), cornerRadius: 8).cgPath
|
||||
}
|
||||
|
||||
func apply(item: SampleUploadedMediaItem?) {
|
||||
guard let item else {
|
||||
imageView.image = nil
|
||||
addCircle.isHidden = false
|
||||
titleLabel.isHidden = false
|
||||
titleLabel.text = "点击上传图片"
|
||||
titleLabel.numberOfLines = 1
|
||||
hintLabel.isHidden = false
|
||||
deleteButton.isHidden = true
|
||||
dashedLayer.isHidden = false
|
||||
return
|
||||
}
|
||||
if item.isUploading {
|
||||
@ -808,12 +901,14 @@ private final class SampleCoverUploadView: UIControl {
|
||||
titleLabel.numberOfLines = 2
|
||||
hintLabel.isHidden = true
|
||||
deleteButton.isHidden = true
|
||||
dashedLayer.isHidden = true
|
||||
} else {
|
||||
imageView.image = UIImage(data: item.data)
|
||||
addCircle.isHidden = true
|
||||
titleLabel.isHidden = true
|
||||
hintLabel.isHidden = true
|
||||
deleteButton.isHidden = item.ossUrl.isEmpty
|
||||
dashedLayer.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
@ -881,3 +976,12 @@ private final class SampleUploadProgressOverlay: UIView {
|
||||
percentLabel.text = "\(max(0, min(100, state.progress)))%"
|
||||
}
|
||||
}
|
||||
|
||||
/// 在不改变 Android 视觉尺寸的前提下,将小图标按钮触控区域扩展到至少 44pt。
|
||||
private final class SampleExpandedTouchButton: UIButton {
|
||||
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
||||
let horizontalInset = max(0, (44 - bounds.width) / 2)
|
||||
let verticalInset = max(0, (44 - bounds.height) / 2)
|
||||
return bounds.insetBy(dx: -horizontalInset, dy: -verticalInset).contains(point)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user