feat: 更新素材管理和举报风险地图
This commit is contained in:
109
suixinkan/UI/Common/MediaPreviewItem.swift
Normal file
109
suixinkan/UI/Common/MediaPreviewItem.swift
Normal file
@ -0,0 +1,109 @@
|
||||
//
|
||||
// MediaPreviewItem.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 媒体预览资源项,描述可全屏预览的图片或视频。
|
||||
public struct MediaPreviewItem: Hashable {
|
||||
|
||||
/// 媒体类型。
|
||||
public enum MediaType: Equatable {
|
||||
case image
|
||||
case video
|
||||
}
|
||||
|
||||
/// 预览资源来源。
|
||||
public enum Source {
|
||||
case remoteImage(URL)
|
||||
case localImageFile(URL)
|
||||
case image(UIImage)
|
||||
case remoteVideo(URL)
|
||||
case localVideoFile(URL)
|
||||
}
|
||||
|
||||
/// 资源唯一标识,用于 Diffable Data Source。
|
||||
public let id: UUID
|
||||
|
||||
/// 预览资源来源。
|
||||
public let source: Source
|
||||
|
||||
/// 创建媒体预览资源项。
|
||||
public init(id: UUID = UUID(), source: Source) {
|
||||
self.id = id
|
||||
self.source = source
|
||||
}
|
||||
|
||||
/// 媒体类型。
|
||||
public var mediaType: MediaType {
|
||||
switch source {
|
||||
case .remoteImage, .localImageFile, .image:
|
||||
return .image
|
||||
case .remoteVideo, .localVideoFile:
|
||||
return .video
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否为图片资源。
|
||||
public var isImage: Bool {
|
||||
mediaType == .image
|
||||
}
|
||||
|
||||
/// 是否为视频资源。
|
||||
public var isVideo: Bool {
|
||||
mediaType == .video
|
||||
}
|
||||
|
||||
/// 通过远程图片地址创建资源项,空字符串或非法地址返回 nil。
|
||||
public static func remoteImage(_ urlString: String) -> MediaPreviewItem? {
|
||||
makeRemoteURL(from: urlString).map { MediaPreviewItem(source: .remoteImage($0)) }
|
||||
}
|
||||
|
||||
/// 通过远程视频地址创建资源项,空字符串或非法地址返回 nil。
|
||||
public static func remoteVideo(_ urlString: String) -> MediaPreviewItem? {
|
||||
makeRemoteURL(from: urlString).map { MediaPreviewItem(source: .remoteVideo($0)) }
|
||||
}
|
||||
|
||||
/// 通过本地图片文件地址创建资源项。
|
||||
public static func localImage(url: URL) -> MediaPreviewItem {
|
||||
MediaPreviewItem(source: .localImageFile(url))
|
||||
}
|
||||
|
||||
/// 通过本地图片对象创建资源项。
|
||||
public static func image(_ image: UIImage) -> MediaPreviewItem {
|
||||
MediaPreviewItem(source: .image(image))
|
||||
}
|
||||
|
||||
/// 通过本地视频文件地址创建资源项。
|
||||
public static func localVideo(url: URL) -> MediaPreviewItem {
|
||||
MediaPreviewItem(source: .localVideoFile(url))
|
||||
}
|
||||
|
||||
/// 将起始索引限制在可用范围内。
|
||||
public static func clampedStartIndex(_ startIndex: Int, itemCount: Int) -> Int {
|
||||
guard itemCount > 0 else { return 0 }
|
||||
return min(max(0, startIndex), itemCount - 1)
|
||||
}
|
||||
|
||||
/// 从媒体数组中读取安全的起始资源项。
|
||||
public static func currentItem(in items: [MediaPreviewItem], startIndex: Int) -> MediaPreviewItem? {
|
||||
guard !items.isEmpty else { return nil }
|
||||
return items[clampedStartIndex(startIndex, itemCount: items.count)]
|
||||
}
|
||||
|
||||
public static func == (lhs: MediaPreviewItem, rhs: MediaPreviewItem) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
private static func makeRemoteURL(from text: String) -> URL? {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
|
||||
guard let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme) else { return nil }
|
||||
return url
|
||||
}
|
||||
}
|
||||
574
suixinkan/UI/Common/MediaPreviewViewController.swift
Normal file
574
suixinkan/UI/Common/MediaPreviewViewController.swift
Normal file
@ -0,0 +1,574 @@
|
||||
//
|
||||
// MediaPreviewViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 通用全屏媒体预览页,支持图片、视频混排横向预览。
|
||||
class MediaPreviewViewController: UIViewController {
|
||||
private enum Section {
|
||||
case main
|
||||
}
|
||||
|
||||
private let items: [MediaPreviewItem]
|
||||
private let startIndex: Int
|
||||
private var currentIndex: Int
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, MediaPreviewItem>!
|
||||
|
||||
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeCollectionLayout())
|
||||
private let closeButton = UIButton(type: .system)
|
||||
private let counterLabel = UILabel()
|
||||
|
||||
/// 创建通用媒体预览页。
|
||||
init(items: [MediaPreviewItem], startIndex: Int = 0) {
|
||||
self.items = items
|
||||
self.startIndex = MediaPreviewItem.clampedStartIndex(startIndex, itemCount: items.count)
|
||||
self.currentIndex = self.startIndex
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .fullScreen
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 从指定控制器全屏弹出媒体预览页。
|
||||
static func present(from viewController: UIViewController, items: [MediaPreviewItem], startIndex: Int = 0) {
|
||||
guard !items.isEmpty else { return }
|
||||
let preview = MediaPreviewViewController(items: items, startIndex: startIndex)
|
||||
viewController.present(preview, animated: true)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setupUI()
|
||||
setupDataSource()
|
||||
applySnapshot()
|
||||
registerNotifications()
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
guard collectionView.bounds.width > 0 else { return }
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
collectionView.setContentOffset(
|
||||
CGPoint(x: CGFloat(currentIndex) * collectionView.bounds.width, y: 0),
|
||||
animated: false
|
||||
)
|
||||
updateVisibleCellsPlayback()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
updateVisibleCellsPlayback()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
pauseVisibleVideos()
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
view.backgroundColor = .black
|
||||
collectionView.backgroundColor = .black
|
||||
collectionView.isPagingEnabled = true
|
||||
collectionView.showsHorizontalScrollIndicator = false
|
||||
collectionView.alwaysBounceHorizontal = items.count > 1
|
||||
collectionView.delegate = self
|
||||
collectionView.register(MediaPreviewImageCell.self, forCellWithReuseIdentifier: MediaPreviewImageCell.reuseIdentifier)
|
||||
collectionView.register(MediaPreviewVideoCell.self, forCellWithReuseIdentifier: MediaPreviewVideoCell.reuseIdentifier)
|
||||
|
||||
closeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
|
||||
closeButton.tintColor = .white
|
||||
closeButton.backgroundColor = UIColor.black.withAlphaComponent(0.25)
|
||||
closeButton.layer.cornerRadius = 18
|
||||
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||||
|
||||
counterLabel.textColor = .white
|
||||
counterLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
counterLabel.textAlignment = .center
|
||||
counterLabel.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||
counterLabel.layer.cornerRadius = 14
|
||||
counterLabel.clipsToBounds = true
|
||||
updateCounter()
|
||||
|
||||
view.addSubview(collectionView)
|
||||
view.addSubview(closeButton)
|
||||
view.addSubview(counterLabel)
|
||||
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
closeButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
make.size.equalTo(36)
|
||||
}
|
||||
counterLabel.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.centerY.equalTo(closeButton)
|
||||
make.height.equalTo(28)
|
||||
make.width.greaterThanOrEqualTo(64)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupDataSource() {
|
||||
dataSource = UICollectionViewDiffableDataSource<Section, MediaPreviewItem>(
|
||||
collectionView: collectionView
|
||||
) { [weak self] collectionView, indexPath, item in
|
||||
switch item.source {
|
||||
case .remoteImage, .localImageFile, .image:
|
||||
guard let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: MediaPreviewImageCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as? MediaPreviewImageCell else {
|
||||
return UICollectionViewCell()
|
||||
}
|
||||
cell.configure(source: item.source)
|
||||
return cell
|
||||
case .remoteVideo, .localVideoFile:
|
||||
guard let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: MediaPreviewVideoCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as? MediaPreviewVideoCell else {
|
||||
return UICollectionViewCell()
|
||||
}
|
||||
cell.configure(source: item.source)
|
||||
cell.setActive(indexPath.item == self?.currentIndex)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applySnapshot() {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, MediaPreviewItem>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items)
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
private func makeCollectionLayout() -> UICollectionViewLayout {
|
||||
let layout = UICollectionViewFlowLayout()
|
||||
layout.scrollDirection = .horizontal
|
||||
layout.minimumLineSpacing = 0
|
||||
layout.minimumInteritemSpacing = 0
|
||||
return layout
|
||||
}
|
||||
|
||||
private func registerNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(appDidEnterBackground),
|
||||
name: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func updateCurrentIndex() {
|
||||
guard collectionView.bounds.width > 0 else { return }
|
||||
let page = Int(round(collectionView.contentOffset.x / collectionView.bounds.width))
|
||||
currentIndex = min(max(0, page), max(0, items.count - 1))
|
||||
updateCounter()
|
||||
updateVisibleCellsPlayback()
|
||||
}
|
||||
|
||||
private func updateCounter() {
|
||||
counterLabel.text = items.isEmpty ? "0/0" : "\(currentIndex + 1)/\(items.count)"
|
||||
}
|
||||
|
||||
private func updateVisibleCellsPlayback() {
|
||||
for cell in collectionView.visibleCells {
|
||||
guard let indexPath = collectionView.indexPath(for: cell),
|
||||
let videoCell = cell as? MediaPreviewVideoCell
|
||||
else { continue }
|
||||
videoCell.setActive(indexPath.item == currentIndex && view.window != nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func pauseVisibleVideos() {
|
||||
collectionView.visibleCells
|
||||
.compactMap { $0 as? MediaPreviewVideoCell }
|
||||
.forEach { $0.setActive(false) }
|
||||
}
|
||||
|
||||
@objc private func closeTapped() {
|
||||
pauseVisibleVideos()
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
@objc private func appDidEnterBackground() {
|
||||
pauseVisibleVideos()
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaPreviewViewController: UICollectionViewDelegateFlowLayout {
|
||||
func collectionView(
|
||||
_ collectionView: UICollectionView,
|
||||
layout collectionViewLayout: UICollectionViewLayout,
|
||||
sizeForItemAt indexPath: IndexPath
|
||||
) -> CGSize {
|
||||
collectionView.bounds.size
|
||||
}
|
||||
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
updateCurrentIndex()
|
||||
}
|
||||
|
||||
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||
updateCurrentIndex()
|
||||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard scrollView.isDragging || scrollView.isDecelerating else { return }
|
||||
pauseVisibleVideos()
|
||||
}
|
||||
}
|
||||
|
||||
/// 图片预览 Cell,支持远程、本地与内存图片缩放。
|
||||
private final class MediaPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
|
||||
static let reuseIdentifier = "MediaPreviewImageCell"
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let imageView = UIImageView()
|
||||
private let messageLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
imageView.kf.cancelDownloadTask()
|
||||
imageView.image = nil
|
||||
messageLabel.isHidden = true
|
||||
scrollView.zoomScale = 1
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
imageView.frame = scrollView.bounds
|
||||
}
|
||||
|
||||
/// 配置图片资源来源。
|
||||
func configure(source: MediaPreviewItem.Source) {
|
||||
messageLabel.isHidden = true
|
||||
scrollView.zoomScale = 1
|
||||
switch source {
|
||||
case .remoteImage(let url):
|
||||
imageView.kf.setImage(with: url) { [weak self] result in
|
||||
if case .failure = result {
|
||||
self?.showMessage("图片加载失败")
|
||||
}
|
||||
}
|
||||
case .localImageFile(let url):
|
||||
if let image = UIImage(contentsOfFile: url.path) {
|
||||
imageView.image = image
|
||||
} else {
|
||||
showMessage("图片加载失败")
|
||||
}
|
||||
case .image(let image):
|
||||
imageView.image = image
|
||||
case .remoteVideo, .localVideoFile:
|
||||
showMessage("不支持的图片资源")
|
||||
}
|
||||
}
|
||||
|
||||
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||
imageView
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .black
|
||||
scrollView.delegate = self
|
||||
scrollView.minimumZoomScale = 1
|
||||
scrollView.maximumZoomScale = 3
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
scrollView.contentInsetAdjustmentBehavior = .never
|
||||
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.backgroundColor = .black
|
||||
|
||||
messageLabel.textColor = UIColor.white.withAlphaComponent(0.8)
|
||||
messageLabel.font = .systemFont(ofSize: 15)
|
||||
messageLabel.textAlignment = .center
|
||||
messageLabel.isHidden = true
|
||||
|
||||
contentView.addSubview(scrollView)
|
||||
scrollView.addSubview(imageView)
|
||||
contentView.addSubview(messageLabel)
|
||||
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
messageLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
}
|
||||
|
||||
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
|
||||
doubleTap.numberOfTapsRequired = 2
|
||||
scrollView.addGestureRecognizer(doubleTap)
|
||||
}
|
||||
|
||||
private func showMessage(_ text: String) {
|
||||
imageView.image = nil
|
||||
messageLabel.text = text
|
||||
messageLabel.isHidden = false
|
||||
}
|
||||
|
||||
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
|
||||
if scrollView.zoomScale > 1 {
|
||||
scrollView.setZoomScale(1, animated: true)
|
||||
return
|
||||
}
|
||||
let point = gesture.location(in: imageView)
|
||||
let targetScale = scrollView.maximumZoomScale
|
||||
let width = scrollView.bounds.width / targetScale
|
||||
let height = scrollView.bounds.height / targetScale
|
||||
let rect = CGRect(x: point.x - width / 2, y: point.y - height / 2, width: width, height: height)
|
||||
scrollView.zoom(to: rect, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 视频预览 Cell,负责单个 AVPlayer 的播放与暂停。
|
||||
private final class MediaPreviewVideoCell: UICollectionViewCell, UIGestureRecognizerDelegate {
|
||||
static let reuseIdentifier = "MediaPreviewVideoCell"
|
||||
|
||||
private let playerView = MediaPreviewPlayerView()
|
||||
private let playButton = MediaPreviewPlaybackButton()
|
||||
private let messageLabel = UILabel()
|
||||
private var player: AVPlayer?
|
||||
private var didReachEnd = false
|
||||
private var isActive = false
|
||||
private var isUserPaused = false
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
player?.pause()
|
||||
player = nil
|
||||
playerView.playerLayer.player = nil
|
||||
didReachEnd = false
|
||||
isActive = false
|
||||
isUserPaused = false
|
||||
messageLabel.isHidden = true
|
||||
playButton.isHidden = false
|
||||
}
|
||||
|
||||
/// 配置视频资源来源。
|
||||
func configure(source: MediaPreviewItem.Source) {
|
||||
messageLabel.isHidden = true
|
||||
didReachEnd = false
|
||||
isUserPaused = false
|
||||
let url: URL?
|
||||
switch source {
|
||||
case .remoteVideo(let remoteURL), .localVideoFile(let remoteURL):
|
||||
url = remoteURL
|
||||
case .remoteImage, .localImageFile, .image:
|
||||
url = nil
|
||||
}
|
||||
guard let url else {
|
||||
showMessage("视频加载失败")
|
||||
return
|
||||
}
|
||||
|
||||
let player = AVPlayer(url: url)
|
||||
player.actionAtItemEnd = .pause
|
||||
self.player = player
|
||||
playerView.playerLayer.player = player
|
||||
if let item = player.currentItem {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(videoDidEnd),
|
||||
name: .AVPlayerItemDidPlayToEndTime,
|
||||
object: item
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据是否为当前页切换视频播放状态。
|
||||
func setActive(_ active: Bool) {
|
||||
isActive = active
|
||||
guard let player else { return }
|
||||
guard active else {
|
||||
player.pause()
|
||||
playButton.isHidden = !didReachEnd
|
||||
return
|
||||
}
|
||||
if didReachEnd {
|
||||
showPlaybackButton(systemName: "pause.fill")
|
||||
} else if isUserPaused {
|
||||
showPlaybackButton(systemName: "play.fill")
|
||||
player.pause()
|
||||
} else {
|
||||
playButton.isHidden = true
|
||||
player.play()
|
||||
}
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
backgroundColor = .black
|
||||
playerView.playerLayer.videoGravity = .resizeAspect
|
||||
|
||||
playButton.setSymbol(named: "pause.fill")
|
||||
playButton.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
|
||||
|
||||
messageLabel.textColor = UIColor.white.withAlphaComponent(0.8)
|
||||
messageLabel.font = .systemFont(ofSize: 15)
|
||||
messageLabel.textAlignment = .center
|
||||
messageLabel.isHidden = true
|
||||
|
||||
contentView.addSubview(playerView)
|
||||
contentView.addSubview(playButton)
|
||||
contentView.addSubview(messageLabel)
|
||||
|
||||
playerView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
playButton.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(96)
|
||||
}
|
||||
messageLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
}
|
||||
|
||||
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(videoTapped))
|
||||
tapGesture.delegate = self
|
||||
contentView.addGestureRecognizer(tapGesture)
|
||||
}
|
||||
|
||||
private func showMessage(_ text: String) {
|
||||
messageLabel.text = text
|
||||
messageLabel.isHidden = false
|
||||
playButton.isHidden = true
|
||||
}
|
||||
|
||||
private func showPlaybackButton(systemName: String) {
|
||||
playButton.setSymbol(named: systemName)
|
||||
playButton.isHidden = false
|
||||
}
|
||||
|
||||
private func startPlaybackFromCurrentPosition() {
|
||||
guard let player else { return }
|
||||
if didReachEnd {
|
||||
player.seek(to: .zero)
|
||||
didReachEnd = false
|
||||
}
|
||||
isUserPaused = false
|
||||
playButton.isHidden = true
|
||||
player.play()
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
guard touch.view?.isDescendant(of: playButton) != true else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
@objc private func playTapped() {
|
||||
startPlaybackFromCurrentPosition()
|
||||
}
|
||||
|
||||
@objc private func videoTapped() {
|
||||
guard isActive, let player else { return }
|
||||
if didReachEnd || isUserPaused {
|
||||
startPlaybackFromCurrentPosition()
|
||||
return
|
||||
}
|
||||
isUserPaused = true
|
||||
player.pause()
|
||||
showPlaybackButton(systemName: "play.fill")
|
||||
}
|
||||
|
||||
@objc private func videoDidEnd() {
|
||||
didReachEnd = true
|
||||
isUserPaused = false
|
||||
showPlaybackButton(systemName: "pause.fill")
|
||||
}
|
||||
}
|
||||
|
||||
/// 承载 AVPlayerLayer 的视频预览视图。
|
||||
private final class MediaPreviewPlayerView: UIView {
|
||||
override class var layerClass: AnyClass { AVPlayerLayer.self }
|
||||
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
||||
}
|
||||
|
||||
/// 视频预览播放状态按钮,提供大号图标与柔和渐变背景。
|
||||
private final class MediaPreviewPlaybackButton: UIButton {
|
||||
private let gradientLayer = CAGradientLayer()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
gradientLayer.frame = bounds
|
||||
gradientLayer.cornerRadius = bounds.width / 2
|
||||
layer.cornerRadius = bounds.width / 2
|
||||
}
|
||||
|
||||
/// 设置按钮当前展示的 SF Symbol。
|
||||
func setSymbol(named systemName: String) {
|
||||
let configuration = UIImage.SymbolConfiguration(pointSize: 42, weight: .semibold)
|
||||
setImage(UIImage(systemName: systemName, withConfiguration: configuration), for: .normal)
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
tintColor = .white
|
||||
backgroundColor = UIColor.white.withAlphaComponent(0.08)
|
||||
adjustsImageWhenHighlighted = false
|
||||
layer.masksToBounds = false
|
||||
layer.shadowColor = UIColor.black.cgColor
|
||||
layer.shadowOpacity = 0.28
|
||||
layer.shadowRadius = 18
|
||||
layer.shadowOffset = CGSize(width: 0, height: 10)
|
||||
|
||||
gradientLayer.colors = [
|
||||
UIColor.white.withAlphaComponent(0.32).cgColor,
|
||||
UIColor.black.withAlphaComponent(0.38).cgColor
|
||||
]
|
||||
gradientLayer.startPoint = CGPoint(x: 0.18, y: 0.12)
|
||||
gradientLayer.endPoint = CGPoint(x: 0.88, y: 0.92)
|
||||
layer.insertSublayer(gradientLayer, at: 0)
|
||||
|
||||
setSymbol(named: "pause.fill")
|
||||
}
|
||||
}
|
||||
@ -3,100 +3,19 @@
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 图片全屏预览,对齐 Android `MediaPreviewDialog`。
|
||||
final class ImagePreviewViewController: UIViewController {
|
||||
|
||||
private let imageURLs: [String]
|
||||
private let startIndex: Int
|
||||
private let scrollView = UIScrollView()
|
||||
private let pageControl = UIPageControl()
|
||||
/// 图片全屏预览兼容入口,内部委托给通用媒体预览页。
|
||||
final class ImagePreviewViewController: MediaPreviewViewController {
|
||||
|
||||
/// 通过图片地址数组创建全屏图片预览页。
|
||||
init(imageURLs: [String], startIndex: Int) {
|
||||
self.imageURLs = imageURLs
|
||||
self.startIndex = min(max(0, startIndex), max(0, imageURLs.count - 1))
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .fullScreen
|
||||
let items = imageURLs.compactMap { MediaPreviewItem.remoteImage($0) }
|
||||
super.init(items: items, startIndex: startIndex)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
|
||||
scrollView.isPagingEnabled = true
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
scrollView.delegate = self
|
||||
|
||||
pageControl.numberOfPages = imageURLs.count
|
||||
pageControl.currentPage = startIndex
|
||||
pageControl.isHidden = imageURLs.count <= 1
|
||||
|
||||
let closeButton = UIButton(type: .system)
|
||||
closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||
closeButton.tintColor = .white
|
||||
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||||
|
||||
view.addSubview(scrollView)
|
||||
view.addSubview(pageControl)
|
||||
view.addSubview(closeButton)
|
||||
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
pageControl.snp.makeConstraints { make in
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md)
|
||||
make.centerX.equalToSuperview()
|
||||
}
|
||||
closeButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
|
||||
make.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.size.equalTo(AppSpacing.minTouchTarget)
|
||||
}
|
||||
|
||||
imageURLs.enumerated().forEach { index, url in
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
imageView.loadRemoteImage(urlString: url, placeholderColor: .darkGray)
|
||||
scrollView.addSubview(imageView)
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.top.bottom.equalToSuperview()
|
||||
make.width.equalTo(view.snp.width)
|
||||
make.height.equalTo(view.snp.height)
|
||||
make.leading.equalToSuperview().offset(CGFloat(index) * view.bounds.width)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
scrollView.contentSize = CGSize(width: view.bounds.width * CGFloat(imageURLs.count), height: view.bounds.height)
|
||||
scrollView.setContentOffset(CGPoint(x: view.bounds.width * CGFloat(startIndex), y: 0), animated: false)
|
||||
scrollView.subviews.enumerated().forEach { index, subview in
|
||||
subview.frame = CGRect(
|
||||
x: view.bounds.width * CGFloat(index),
|
||||
y: 0,
|
||||
width: view.bounds.width,
|
||||
height: view.bounds.height
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func closeTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension ImagePreviewViewController: UIScrollViewDelegate {
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
guard view.bounds.width > 0 else { return }
|
||||
let page = Int(round(scrollView.contentOffset.x / view.bounds.width))
|
||||
pageControl.currentPage = page
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,710 @@
|
||||
//
|
||||
// MaterialDetailViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 素材详情页,对齐 Android `MaterialDetailNewScreen`。
|
||||
/// 素材详情页,展示轮播、信息、统计、审核与底部操作。
|
||||
final class MaterialDetailViewController: BaseViewController {
|
||||
private let viewModel: MaterialDetailViewModel
|
||||
private let api: any MaterialManagementServing
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let carouselView = MaterialMediaCarouselView()
|
||||
private let infoSection = MaterialDetailInfoSection()
|
||||
private let uploaderSection = MaterialDetailUploaderSection()
|
||||
private let reviewSection = MaterialDetailReviewSection()
|
||||
private let bottomBar = UIView()
|
||||
private let deleteButton = UIButton(type: .system)
|
||||
private let editButton = UIButton(type: .system)
|
||||
|
||||
var onDeleted: ((Int) -> Void)?
|
||||
var onEdited: (() -> Void)?
|
||||
|
||||
/// 初始化素材详情页。
|
||||
init(
|
||||
materialId: Int,
|
||||
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI
|
||||
) {
|
||||
viewModel = MaterialDetailViewModel(materialId: materialId)
|
||||
self.api = api
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "素材详情"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 12
|
||||
scrollView.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
bottomBar.backgroundColor = .white
|
||||
|
||||
configureBottomButton(deleteButton, title: "删除", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary)
|
||||
configureBottomButton(editButton, title: "编辑", backgroundColor: AppColor.primary, titleColor: .white)
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(contentStack)
|
||||
contentStack.addArrangedSubview(carouselView)
|
||||
contentStack.addArrangedSubview(infoSection)
|
||||
contentStack.addArrangedSubview(uploaderSection)
|
||||
contentStack.addArrangedSubview(reviewSection)
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(deleteButton)
|
||||
bottomBar.addSubview(editButton)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
bottomBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
deleteButton.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(16)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.width.equalTo(100)
|
||||
make.height.equalTo(48)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
|
||||
}
|
||||
editButton.snp.makeConstraints { make in
|
||||
make.top.height.equalTo(deleteButton)
|
||||
make.leading.equalTo(deleteButton.snp.trailing).offset(12)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.bottom.equalTo(bottomBar.snp.top)
|
||||
}
|
||||
contentStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
make.width.equalTo(scrollView)
|
||||
}
|
||||
carouselView.snp.makeConstraints { make in
|
||||
make.height.equalTo(carouselView.snp.width).multipliedBy(9.0 / 16.0)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
viewModel.onDeleted = { [weak self] id in
|
||||
Task { @MainActor in
|
||||
self?.onDeleted?(id)
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||
editButton.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { await viewModel.load(api: api) }
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
carouselView.pause()
|
||||
}
|
||||
|
||||
private func configureBottomButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(titleColor, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
button.backgroundColor = backgroundColor
|
||||
button.layer.cornerRadius = 8
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
if (viewModel.isLoading || viewModel.isDeleting) && viewModel.detail == nil {
|
||||
showLoading()
|
||||
} else {
|
||||
hideLoading()
|
||||
}
|
||||
guard let detail = viewModel.detail else { return }
|
||||
carouselView.apply(media: detail.mediaList ?? [])
|
||||
infoSection.apply(detail: detail)
|
||||
uploaderSection.apply(detail: detail)
|
||||
reviewSection.apply(detail: detail)
|
||||
}
|
||||
|
||||
@objc private func deleteTapped() {
|
||||
let alert = UIAlertController(title: "确认删除", message: "确定删除该素材吗?", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task {
|
||||
self.showLoading()
|
||||
await self.viewModel.delete(api: self.api)
|
||||
self.hideLoading()
|
||||
}
|
||||
})
|
||||
present(alert, animated: true)
|
||||
}
|
||||
|
||||
@objc private func editTapped() {
|
||||
let controller = MaterialFormViewController(mode: .edit(id: viewModel.materialId))
|
||||
controller.onSubmitSuccess = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.onEdited?()
|
||||
Task { await self.viewModel.load(api: self.api) }
|
||||
}
|
||||
navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材详情媒体轮播,支持图片自动切换与视频播完切换。
|
||||
/// 素材详情顶部媒体轮播视图,支持图片与视频播放。
|
||||
private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
|
||||
private let scrollView = UIScrollView()
|
||||
private let pageControl = UIPageControl()
|
||||
private let placeholderLabel = UILabel()
|
||||
private var media: [MaterialDetailMediaItem] = []
|
||||
private var players: [Int: AVPlayer] = [:]
|
||||
private var timer: Timer?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
scrollView.isPagingEnabled = true
|
||||
scrollView.showsHorizontalScrollIndicator = false
|
||||
scrollView.delegate = self
|
||||
scrollView.bounces = false
|
||||
pageControl.currentPageIndicatorTintColor = .white
|
||||
pageControl.pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.5)
|
||||
placeholderLabel.text = "暂无媒体"
|
||||
placeholderLabel.font = .systemFont(ofSize: 14)
|
||||
placeholderLabel.textColor = AppColor.textSecondary
|
||||
placeholderLabel.textAlignment = .center
|
||||
addSubview(scrollView)
|
||||
addSubview(pageControl)
|
||||
addSubview(placeholderLabel)
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
pageControl.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.bottom.equalToSuperview().inset(8)
|
||||
}
|
||||
placeholderLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
layoutPages()
|
||||
}
|
||||
|
||||
/// 应用媒体列表。
|
||||
func apply(media: [MaterialDetailMediaItem]) {
|
||||
stopTimer()
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
players.values.forEach { $0.pause() }
|
||||
players.removeAll()
|
||||
self.media = media
|
||||
scrollView.subviews.forEach { $0.removeFromSuperview() }
|
||||
pageControl.numberOfPages = media.count
|
||||
pageControl.currentPage = 0
|
||||
pageControl.isHidden = media.count <= 1
|
||||
placeholderLabel.isHidden = !media.isEmpty
|
||||
scrollView.isHidden = media.isEmpty
|
||||
guard !media.isEmpty else { return }
|
||||
|
||||
for (index, item) in media.enumerated() {
|
||||
let page = UIView()
|
||||
page.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
scrollView.addSubview(page)
|
||||
if item.type == 2 {
|
||||
addVideo(urlText: item.displayURL, to: page, index: index)
|
||||
} else {
|
||||
addImage(urlText: item.displayURL, to: page)
|
||||
}
|
||||
}
|
||||
setNeedsLayout()
|
||||
layoutIfNeeded()
|
||||
playCurrentPageIfNeeded()
|
||||
}
|
||||
|
||||
/// 暂停当前轮播。
|
||||
func pause() {
|
||||
stopTimer()
|
||||
players.values.forEach { $0.pause() }
|
||||
}
|
||||
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
updateCurrentPage()
|
||||
}
|
||||
|
||||
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||
updateCurrentPage()
|
||||
}
|
||||
|
||||
private func addImage(urlText: String, to page: UIView) {
|
||||
let imageView = UIImageView()
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
page.addSubview(imageView)
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
if let url = URL(string: urlText), !urlText.isEmpty {
|
||||
imageView.kf.setImage(with: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func addVideo(urlText: String, to page: UIView, index: Int) {
|
||||
guard let url = URL(string: urlText), !urlText.isEmpty else {
|
||||
addImage(urlText: "", to: page)
|
||||
return
|
||||
}
|
||||
let player = AVPlayer(url: url)
|
||||
players[index] = player
|
||||
let playerView = MaterialPlayerLayerView()
|
||||
playerView.playerLayer.player = player
|
||||
page.addSubview(playerView)
|
||||
playerView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(videoDidEnd(_:)),
|
||||
name: .AVPlayerItemDidPlayToEndTime,
|
||||
object: player.currentItem
|
||||
)
|
||||
}
|
||||
|
||||
private func layoutPages() {
|
||||
let size = bounds.size
|
||||
guard size.width > 0, size.height > 0 else { return }
|
||||
for (index, page) in scrollView.subviews.enumerated() {
|
||||
page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height)
|
||||
}
|
||||
scrollView.contentSize = CGSize(width: size.width * CGFloat(media.count), height: size.height)
|
||||
scrollView.contentOffset = CGPoint(x: CGFloat(pageControl.currentPage) * size.width, y: 0)
|
||||
}
|
||||
|
||||
private func updateCurrentPage() {
|
||||
guard bounds.width > 0 else { return }
|
||||
let page = Int(round(scrollView.contentOffset.x / bounds.width))
|
||||
pageControl.currentPage = max(0, min(page, media.count - 1))
|
||||
playCurrentPageIfNeeded()
|
||||
}
|
||||
|
||||
private func playCurrentPageIfNeeded() {
|
||||
stopTimer()
|
||||
players.forEach { index, player in
|
||||
if index == pageControl.currentPage {
|
||||
player.seek(to: .zero)
|
||||
player.play()
|
||||
} else {
|
||||
player.pause()
|
||||
}
|
||||
}
|
||||
guard !media.isEmpty else { return }
|
||||
if media[pageControl.currentPage].type == 1 {
|
||||
startImageTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private func startImageTimer() {
|
||||
guard media.count > 1 else { return }
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in
|
||||
self?.advancePage()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
@objc private func videoDidEnd(_ notification: Notification) {
|
||||
advancePage()
|
||||
}
|
||||
|
||||
private func advancePage() {
|
||||
guard media.count > 1, bounds.width > 0 else { return }
|
||||
let nextPage = (pageControl.currentPage + 1) % media.count
|
||||
pageControl.currentPage = nextPage
|
||||
scrollView.setContentOffset(CGPoint(x: CGFloat(nextPage) * bounds.width, y: 0), animated: true)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||
self?.playCurrentPageIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// AVPlayerLayer 容器。
|
||||
/// 承载 AVPlayerLayer 的视频播放容器。
|
||||
private final class MaterialPlayerLayerView: UIView {
|
||||
override class var layerClass: AnyClass { AVPlayerLayer.self }
|
||||
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
||||
}
|
||||
|
||||
/// 素材详情基础信息区。
|
||||
/// 素材详情的基础信息区块。
|
||||
private final class MaterialDetailInfoSection: UIView {
|
||||
private let titleLabel = UILabel()
|
||||
private let listingBadge = UILabel()
|
||||
private let tagStack = UIStackView()
|
||||
private let statsStack = UIStackView()
|
||||
private let downloadStat = MaterialDetailStatView(systemIconName: "arrow.down.circle")
|
||||
private let likeStat = MaterialDetailStatView(iconName: "sample_ic_like_count")
|
||||
private let collectStat = MaterialDetailStatView(iconName: "sample_ic_collect_count")
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
|
||||
titleLabel.textColor = UIColor(hex: 0x2E3746)
|
||||
titleLabel.numberOfLines = 0
|
||||
listingBadge.font = .systemFont(ofSize: 12)
|
||||
listingBadge.layer.cornerRadius = 4
|
||||
listingBadge.clipsToBounds = true
|
||||
listingBadge.textAlignment = .center
|
||||
tagStack.axis = .horizontal
|
||||
tagStack.spacing = 8
|
||||
tagStack.alignment = .leading
|
||||
statsStack.axis = .horizontal
|
||||
statsStack.spacing = 16
|
||||
[downloadStat, likeStat, collectStat].forEach(statsStack.addArrangedSubview)
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(listingBadge)
|
||||
addSubview(tagStack)
|
||||
addSubview(statsStack)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().offset(16)
|
||||
make.trailing.lessThanOrEqualTo(listingBadge.snp.leading).offset(-12)
|
||||
}
|
||||
listingBadge.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
make.centerY.equalTo(titleLabel)
|
||||
make.height.equalTo(24)
|
||||
make.width.greaterThanOrEqualTo(54)
|
||||
}
|
||||
tagStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
statsStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(tagStack.snp.bottom).offset(12)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.trailing.lessThanOrEqualToSuperview().inset(16)
|
||||
make.bottom.equalToSuperview().inset(16)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(detail: MaterialDetail) {
|
||||
titleLabel.text = detail.name ?? ""
|
||||
let listed = detail.listingStatus == 1
|
||||
listingBadge.text = listed ? "已上架" : "未上架"
|
||||
let color = listed ? UIColor(hex: 0x22C55E) : AppColor.textSecondary
|
||||
listingBadge.textColor = color
|
||||
listingBadge.backgroundColor = color.withAlphaComponent(0.1)
|
||||
tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
(detail.materialTag ?? []).forEach { tagStack.addArrangedSubview(makeTag($0.name)) }
|
||||
downloadStat.setCount(detail.downloadCount)
|
||||
likeStat.setCount(detail.likesCount)
|
||||
collectStat.setCount(detail.collectCount)
|
||||
}
|
||||
|
||||
private func makeTag(_ title: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.font = .systemFont(ofSize: 12)
|
||||
label.textColor = AppColor.primary
|
||||
label.textAlignment = .center
|
||||
label.backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||
label.layer.cornerRadius = 4
|
||||
label.clipsToBounds = true
|
||||
label.snp.makeConstraints { make in
|
||||
make.height.equalTo(22)
|
||||
make.width.greaterThanOrEqualTo(40)
|
||||
}
|
||||
return label
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材上传人信息区。
|
||||
/// 素材详情的上传人信息区块。
|
||||
private final class MaterialDetailUploaderSection: UIView {
|
||||
private let titleLabel = UILabel()
|
||||
private let avatarView = UIImageView()
|
||||
private let nameLabel = UILabel()
|
||||
private let tagStack = UIStackView()
|
||||
private let descriptionLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
titleLabel.text = "上传人"
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .bold)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
avatarView.contentMode = .scaleAspectFill
|
||||
avatarView.clipsToBounds = true
|
||||
avatarView.layer.cornerRadius = 24
|
||||
avatarView.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
nameLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
nameLabel.textColor = UIColor(hex: 0x002255)
|
||||
descriptionLabel.font = .systemFont(ofSize: 14)
|
||||
descriptionLabel.textColor = UIColor(hex: 0x7B8EAA)
|
||||
descriptionLabel.numberOfLines = 0
|
||||
tagStack.axis = .horizontal
|
||||
tagStack.spacing = 8
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(avatarView)
|
||||
addSubview(nameLabel)
|
||||
addSubview(tagStack)
|
||||
addSubview(descriptionLabel)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
avatarView.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(12)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.size.equalTo(48)
|
||||
make.bottom.lessThanOrEqualToSuperview().inset(16)
|
||||
}
|
||||
nameLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(avatarView)
|
||||
make.leading.equalTo(avatarView.snp.trailing).offset(12)
|
||||
}
|
||||
tagStack.snp.makeConstraints { make in
|
||||
make.leading.equalTo(nameLabel.snp.trailing).offset(8)
|
||||
make.centerY.equalTo(nameLabel)
|
||||
make.trailing.lessThanOrEqualToSuperview().inset(16)
|
||||
}
|
||||
descriptionLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(nameLabel.snp.bottom).offset(4)
|
||||
make.leading.equalTo(nameLabel)
|
||||
make.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalToSuperview().inset(16)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(detail: MaterialDetail) {
|
||||
nameLabel.text = detail.uploaderName ?? ""
|
||||
descriptionLabel.text = detail.uploaderDescription ?? ""
|
||||
tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
if detail.platformCertification {
|
||||
tagStack.addArrangedSubview(makeTag("平台认证"))
|
||||
}
|
||||
if detail.scenicCertification {
|
||||
tagStack.addArrangedSubview(makeTag("景区认证"))
|
||||
}
|
||||
if let urlText = detail.uploaderAvatar, let url = URL(string: urlText), !urlText.isEmpty {
|
||||
avatarView.kf.setImage(with: url, placeholder: UIImage(systemName: "person.crop.circle.fill"))
|
||||
} else {
|
||||
avatarView.image = UIImage(systemName: "person.crop.circle.fill")
|
||||
avatarView.tintColor = AppColor.textTertiary
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTag(_ title: String) -> UILabel {
|
||||
let label = UILabel()
|
||||
label.text = title
|
||||
label.textColor = .white
|
||||
label.font = .systemFont(ofSize: 8)
|
||||
label.textAlignment = .center
|
||||
label.backgroundColor = AppColor.primary
|
||||
label.layer.cornerRadius = 4
|
||||
label.clipsToBounds = true
|
||||
label.snp.makeConstraints { make in
|
||||
make.height.equalTo(16)
|
||||
make.width.greaterThanOrEqualTo(44)
|
||||
}
|
||||
return label
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材审核信息区。
|
||||
/// 素材详情的审核信息区块。
|
||||
private final class MaterialDetailReviewSection: UIView {
|
||||
private let titleLabel = UILabel()
|
||||
private let stack = UIStackView()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
titleLabel.text = "审核信息"
|
||||
titleLabel.font = .systemFont(ofSize: 14, weight: .bold)
|
||||
titleLabel.textColor = .black
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 0
|
||||
addSubview(titleLabel)
|
||||
addSubview(stack)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(12)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(16)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(detail: MaterialDetail) {
|
||||
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
stack.addArrangedSubview(MaterialInfoRow(label: "审核人", value: detail.reviewer ?? "--"))
|
||||
stack.addArrangedSubview(separator())
|
||||
stack.addArrangedSubview(MaterialInfoRow(label: "审核时间", value: detail.reviewTime ?? "--"))
|
||||
stack.addArrangedSubview(separator())
|
||||
stack.addArrangedSubview(MaterialStatusRow(status: detail.status))
|
||||
stack.addArrangedSubview(separator())
|
||||
stack.addArrangedSubview(MaterialInfoRow(label: "审核备注", value: detail.reviewNotes ?? "--", valueColor: UIColor(hex: 0x4B5563), valueFont: .systemFont(ofSize: 12)))
|
||||
}
|
||||
|
||||
private func separator() -> UIView {
|
||||
let view = UIView()
|
||||
view.backgroundColor = UIColor(hex: 0xEEEEEE)
|
||||
view.snp.makeConstraints { make in
|
||||
make.height.equalTo(1)
|
||||
}
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
/// 详情统计图标与数字。
|
||||
/// 素材详情中的统计指标视图。
|
||||
private final class MaterialDetailStatView: UIView {
|
||||
private let iconView = UIImageView()
|
||||
private let countLabel = UILabel()
|
||||
|
||||
init(iconName: String? = nil, systemIconName: String? = nil) {
|
||||
super.init(frame: .zero)
|
||||
if let iconName {
|
||||
iconView.image = UIImage(named: iconName)
|
||||
} else if let systemIconName {
|
||||
iconView.image = UIImage(systemName: systemIconName)
|
||||
}
|
||||
iconView.tintColor = UIColor(hex: 0x6B7280)
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
countLabel.font = .systemFont(ofSize: 14)
|
||||
countLabel.textColor = UIColor(hex: 0x6B7280)
|
||||
addSubview(iconView)
|
||||
addSubview(countLabel)
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview()
|
||||
make.size.equalTo(16)
|
||||
}
|
||||
countLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(iconView.snp.trailing).offset(4)
|
||||
make.trailing.centerY.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setCount(_ count: Int) {
|
||||
countLabel.text = MaterialDisplayFormatter.formatCount(count)
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材详情普通信息行。
|
||||
/// 标题和值组成的信息行。
|
||||
private final class MaterialInfoRow: UIView {
|
||||
private let label = UILabel()
|
||||
private let valueLabel = UILabel()
|
||||
|
||||
init(label: String, value: String, valueColor: UIColor = .black, valueFont: UIFont = .systemFont(ofSize: 14, weight: .medium)) {
|
||||
super.init(frame: .zero)
|
||||
self.label.text = label
|
||||
self.label.font = .systemFont(ofSize: 14)
|
||||
self.label.textColor = UIColor(hex: 0x2E3746)
|
||||
valueLabel.text = value
|
||||
valueLabel.font = valueFont
|
||||
valueLabel.textColor = valueColor
|
||||
valueLabel.textAlignment = .right
|
||||
valueLabel.numberOfLines = 1
|
||||
addSubview(self.label)
|
||||
addSubview(valueLabel)
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
self.label.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview()
|
||||
}
|
||||
valueLabel.snp.makeConstraints { make in
|
||||
make.leading.greaterThanOrEqualTo(self.label.snp.trailing).offset(12)
|
||||
make.trailing.centerY.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材审核状态行。
|
||||
/// 素材详情中展示上架状态的信息行。
|
||||
private final class MaterialStatusRow: UIView {
|
||||
private let label = UILabel()
|
||||
private let badge = MaterialStatusBadgeView()
|
||||
|
||||
init(status: Int) {
|
||||
super.init(frame: .zero)
|
||||
label.text = "审核状态"
|
||||
label.font = .systemFont(ofSize: 14)
|
||||
label.textColor = UIColor(hex: 0x2E3746)
|
||||
badge.apply(status: status)
|
||||
addSubview(label)
|
||||
addSubview(badge)
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
label.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview()
|
||||
}
|
||||
badge.snp.makeConstraints { make in
|
||||
make.trailing.centerY.equalToSuperview()
|
||||
make.height.equalTo(24)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
929
suixinkan/UI/MaterialManagement/MaterialFormViewController.swift
Normal file
929
suixinkan/UI/MaterialManagement/MaterialFormViewController.swift
Normal file
@ -0,0 +1,929 @@
|
||||
//
|
||||
// MaterialFormViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Kingfisher
|
||||
import PhotosUI
|
||||
import SnapKit
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
/// 素材上传 / 编辑页,对齐 Android `UploadMaterialScreen` 与 `EditMaterialScreen`。
|
||||
/// 素材上传与编辑页,负责字段录入、媒体选择和提交交互。
|
||||
final class MaterialFormViewController: BaseViewController {
|
||||
private enum PickTarget {
|
||||
case material
|
||||
case cover
|
||||
}
|
||||
|
||||
private let viewModel: MaterialFormViewModel
|
||||
private let api: any MaterialManagementServing
|
||||
private let uploader: any MaterialOSSUploading
|
||||
|
||||
private let scrollView = UIScrollView()
|
||||
private let formCard = UIView()
|
||||
private let formStack = UIStackView()
|
||||
private let nameField = UITextField()
|
||||
private let nameSection = MaterialFormFieldView(title: "素材名称")
|
||||
private let spotButton = MaterialSelectButton()
|
||||
private let spotSection = MaterialFormFieldView(title: "关联打卡点")
|
||||
private let mediaTypeButton = MaterialSelectButton()
|
||||
private let mediaTypeSection = MaterialFormFieldView(title: "素材类型")
|
||||
private let mediaGridView = MaterialUploadGridView()
|
||||
private let materialSection = MaterialFormFieldView(title: "上传素材")
|
||||
private let coverView = MaterialCoverUploadView()
|
||||
private let coverSection = MaterialFormFieldView(title: "上传封面")
|
||||
private let tagView = MaterialTagEditorView()
|
||||
private let tagSection = MaterialFormFieldView(title: "标签")
|
||||
private let bottomBar = UIView()
|
||||
private let cancelButton = UIButton(type: .system)
|
||||
private let confirmButton = UIButton(type: .system)
|
||||
private let progressOverlay = MaterialUploadProgressOverlay()
|
||||
|
||||
private var pickTarget: PickTarget = .material
|
||||
var onSubmitSuccess: (() -> Void)?
|
||||
|
||||
/// 初始化素材表单页。
|
||||
init(
|
||||
mode: MaterialFormViewModel.Mode,
|
||||
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI,
|
||||
uploader: any MaterialOSSUploading = NetworkServices.shared.ossUploadService
|
||||
) {
|
||||
viewModel = MaterialFormViewModel(mode: mode)
|
||||
self.api = api
|
||||
self.uploader = uploader
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
switch viewModel.mode {
|
||||
case .create: title = "上传素材"
|
||||
case .edit: title = "编辑素材"
|
||||
}
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
formCard.backgroundColor = .white
|
||||
formCard.layer.cornerRadius = 12
|
||||
formCard.clipsToBounds = true
|
||||
formStack.axis = .vertical
|
||||
formStack.spacing = 16
|
||||
|
||||
nameField.placeholder = "请输入素材名称"
|
||||
nameField.font = .systemFont(ofSize: 14)
|
||||
nameField.textColor = .black
|
||||
nameField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
|
||||
nameField.layer.borderWidth = 1
|
||||
nameField.layer.cornerRadius = 8
|
||||
nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44))
|
||||
nameField.leftViewMode = .always
|
||||
nameField.clearButtonMode = .whileEditing
|
||||
nameSection.setContent(nameField)
|
||||
|
||||
spotSection.setContent(spotButton)
|
||||
mediaTypeSection.setContent(mediaTypeButton)
|
||||
materialSection.setContent(mediaGridView)
|
||||
coverSection.setContent(coverView)
|
||||
tagSection.setContent(tagView)
|
||||
[nameSection, spotSection, mediaTypeSection, materialSection, coverSection, tagSection].forEach(formStack.addArrangedSubview)
|
||||
|
||||
bottomBar.backgroundColor = .white
|
||||
configureBottomButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary)
|
||||
configureBottomButton(confirmButton, title: "确认", backgroundColor: AppColor.primary, titleColor: .white)
|
||||
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(formCard)
|
||||
formCard.addSubview(formStack)
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(cancelButton)
|
||||
bottomBar.addSubview(confirmButton)
|
||||
view.addSubview(progressOverlay)
|
||||
progressOverlay.isHidden = true
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
bottomBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
cancelButton.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(16)
|
||||
make.leading.equalToSuperview().offset(15)
|
||||
make.width.equalTo(100)
|
||||
make.height.equalTo(48)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
|
||||
}
|
||||
confirmButton.snp.makeConstraints { make in
|
||||
make.top.height.equalTo(cancelButton)
|
||||
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
|
||||
make.trailing.equalToSuperview().inset(15)
|
||||
}
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.bottom.equalTo(bottomBar.snp.top)
|
||||
}
|
||||
formCard.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
make.width.equalTo(scrollView).offset(-32)
|
||||
}
|
||||
formStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
}
|
||||
nameField.snp.makeConstraints { make in
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
[spotButton, mediaTypeButton].forEach { button in
|
||||
button.snp.makeConstraints { make in
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
}
|
||||
progressOverlay.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
viewModel.onSubmitSuccess = { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.onSubmitSuccess?()
|
||||
self?.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
}
|
||||
nameField.addTarget(self, action: #selector(nameChanged), for: .editingChanged)
|
||||
spotButton.addTarget(self, action: #selector(spotTapped), for: .touchUpInside)
|
||||
mediaTypeButton.addTarget(self, action: #selector(mediaTypeTapped), for: .touchUpInside)
|
||||
mediaGridView.onAdd = { [weak self] in self?.presentPickerSource(target: .material) }
|
||||
mediaGridView.onDelete = { [weak self] index in self?.viewModel.deleteMaterial(at: index) }
|
||||
coverView.onAdd = { [weak self] in self?.presentPickerSource(target: .cover) }
|
||||
coverView.onDelete = { [weak self] in self?.viewModel.deleteCoverImage() }
|
||||
tagView.onTextChanged = { [weak self] text in self?.viewModel.updateTagInput(text) }
|
||||
tagView.onAdd = { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.addTag(api: self.api) }
|
||||
}
|
||||
tagView.onDelete = { [weak self] tag in self?.viewModel.deleteTag(tag) }
|
||||
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task {
|
||||
showLoading()
|
||||
await viewModel.loadInitial(api: api)
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
if nameField.text != viewModel.materialName {
|
||||
nameField.text = viewModel.materialName
|
||||
}
|
||||
spotButton.setTitle(viewModel.selectedScenicSpot?.name, placeholder: "请选择打卡点")
|
||||
mediaTypeButton.setTitle(viewModel.mediaType.title, placeholder: "")
|
||||
mediaGridView.apply(items: viewModel.uploadedMediaList, mediaType: viewModel.mediaType)
|
||||
coverView.apply(item: viewModel.coverImage)
|
||||
tagView.apply(tags: viewModel.tagList, input: viewModel.tagInput)
|
||||
confirmButton.alpha = viewModel.isSubmitting ? 0.6 : 1
|
||||
confirmButton.isEnabled = !viewModel.isSubmitting
|
||||
if let dialog = viewModel.uploadDialogState {
|
||||
progressOverlay.apply(state: dialog)
|
||||
progressOverlay.isHidden = false
|
||||
} else {
|
||||
progressOverlay.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
private func configureBottomButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) {
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(titleColor, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
button.backgroundColor = backgroundColor
|
||||
button.layer.cornerRadius = 8
|
||||
}
|
||||
|
||||
@objc private func nameChanged() {
|
||||
viewModel.updateMaterialName(nameField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func spotTapped() {
|
||||
let sheet = UIAlertController(title: "关联打卡点", message: nil, preferredStyle: .actionSheet)
|
||||
viewModel.scenicSpotList.forEach { spot in
|
||||
sheet.addAction(UIAlertAction(title: spot.name, style: .default) { [weak self] _ in
|
||||
self?.viewModel.selectScenicSpot(spot)
|
||||
})
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
@objc private func mediaTypeTapped() {
|
||||
let sheet = UIAlertController(title: "素材类型", message: nil, preferredStyle: .actionSheet)
|
||||
MaterialMediaType.allCases.forEach { type in
|
||||
sheet.addAction(UIAlertAction(title: type.title, style: .default) { [weak self] _ in
|
||||
self?.viewModel.selectMediaType(type)
|
||||
})
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
Task {
|
||||
showLoading()
|
||||
await viewModel.submit(api: api)
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
private func presentPickerSource(target: PickTarget) {
|
||||
pickTarget = target
|
||||
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||
if UIImagePickerController.isSourceTypeAvailable(.camera) {
|
||||
let cameraTitle = viewModel.mediaType == .video && target == .material ? "拍摄视频" : "拍照"
|
||||
sheet.addAction(UIAlertAction(title: cameraTitle, style: .default) { [weak self] _ in
|
||||
self?.presentCamera()
|
||||
})
|
||||
}
|
||||
sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in
|
||||
self?.presentPhotoPicker()
|
||||
})
|
||||
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(sheet, animated: true)
|
||||
}
|
||||
|
||||
private func presentPhotoPicker() {
|
||||
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||
if pickTarget == .cover {
|
||||
configuration.filter = .images
|
||||
configuration.selectionLimit = 1
|
||||
} else {
|
||||
configuration.filter = viewModel.mediaType == .image ? .images : .videos
|
||||
configuration.selectionLimit = viewModel.mediaType == .video
|
||||
? 1
|
||||
: max(1, viewModel.mediaType.maxCount - viewModel.uploadedMediaList.count)
|
||||
}
|
||||
let picker = PHPickerViewController(configuration: configuration)
|
||||
picker.delegate = self
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
private func presentCamera() {
|
||||
let picker = UIImagePickerController()
|
||||
picker.sourceType = .camera
|
||||
picker.delegate = self
|
||||
if pickTarget == .material, viewModel.mediaType == .video {
|
||||
picker.mediaTypes = [UTType.movie.identifier]
|
||||
picker.cameraCaptureMode = .video
|
||||
} else {
|
||||
picker.mediaTypes = [UTType.image.identifier]
|
||||
picker.cameraCaptureMode = .photo
|
||||
}
|
||||
present(picker, animated: true)
|
||||
}
|
||||
|
||||
private func uploadPickedItems(_ items: [MaterialUploadedMediaItem]) {
|
||||
guard !items.isEmpty else { return }
|
||||
Task {
|
||||
switch pickTarget {
|
||||
case .material:
|
||||
await viewModel.addLocalMedia(items, uploader: uploader)
|
||||
case .cover:
|
||||
await viewModel.setCoverImage(items[0], uploader: uploader)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeImageItem(image: UIImage, fileName: String) -> MaterialUploadedMediaItem? {
|
||||
guard let data = image.jpegData(compressionQuality: 0.9) else { return nil }
|
||||
return MaterialUploadedMediaItem(
|
||||
data: data,
|
||||
fileName: fileName,
|
||||
width: Int(image.size.width * image.scale),
|
||||
height: Int(image.size.height * image.scale)
|
||||
)
|
||||
}
|
||||
|
||||
private func makeVideoItem(data: Data, fileName: String, sourceURL: URL) -> MaterialUploadedMediaItem {
|
||||
let size = videoDisplaySize(url: sourceURL)
|
||||
return MaterialUploadedMediaItem(
|
||||
data: data,
|
||||
thumbnailData: videoThumbnailData(url: sourceURL),
|
||||
fileName: fileName,
|
||||
width: Int(size.width),
|
||||
height: Int(size.height)
|
||||
)
|
||||
}
|
||||
|
||||
private func videoDisplaySize(url: URL) -> CGSize {
|
||||
let asset = AVAsset(url: url)
|
||||
guard let track = asset.tracks(withMediaType: .video).first else { return .zero }
|
||||
let transformed = track.naturalSize.applying(track.preferredTransform)
|
||||
return CGSize(width: abs(transformed.width), height: abs(transformed.height))
|
||||
}
|
||||
|
||||
private func videoThumbnailData(url: URL) -> Data? {
|
||||
let generator = AVAssetImageGenerator(asset: AVAsset(url: url))
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return nil }
|
||||
return UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75)
|
||||
}
|
||||
}
|
||||
|
||||
extension MaterialFormViewController: PHPickerViewControllerDelegate {
|
||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||
picker.dismiss(animated: true)
|
||||
guard !results.isEmpty else { return }
|
||||
|
||||
var loadedItems = [MaterialUploadedMediaItem?](repeating: nil, count: results.count)
|
||||
let group = DispatchGroup()
|
||||
|
||||
for (index, result) in results.enumerated() {
|
||||
let provider = result.itemProvider
|
||||
if pickTarget == .material, viewModel.mediaType == .video {
|
||||
group.enter()
|
||||
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, _ in
|
||||
defer { group.leave() }
|
||||
guard let self, let url else { return }
|
||||
let data = (try? Data(contentsOf: url)) ?? Data()
|
||||
guard !data.isEmpty else { return }
|
||||
let fileName = provider.suggestedName.map { "\($0).mp4" } ?? "material_video_\(Int(Date().timeIntervalSince1970)).mp4"
|
||||
loadedItems[index] = self.makeVideoItem(data: data, fileName: fileName, sourceURL: url)
|
||||
}
|
||||
} else {
|
||||
group.enter()
|
||||
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
||||
defer { group.leave() }
|
||||
guard let self, let image = object as? UIImage else { return }
|
||||
let fileName = "material_image_\(Int(Date().timeIntervalSince1970))_\(index).jpg"
|
||||
loadedItems[index] = self.makeImageItem(image: image, fileName: fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: .main) { [weak self] in
|
||||
self?.uploadPickedItems(loadedItems.compactMap { $0 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MaterialFormViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
picker.dismiss(animated: true)
|
||||
}
|
||||
|
||||
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||
picker.dismiss(animated: true)
|
||||
if pickTarget == .material, viewModel.mediaType == .video,
|
||||
let url = info[.mediaURL] as? URL,
|
||||
let data = try? Data(contentsOf: url) {
|
||||
uploadPickedItems([makeVideoItem(data: data, fileName: url.lastPathComponent, sourceURL: url)])
|
||||
return
|
||||
}
|
||||
if let image = info[.originalImage] as? UIImage,
|
||||
let item = makeImageItem(image: image, fileName: "material_image_\(Int(Date().timeIntervalSince1970)).jpg") {
|
||||
uploadPickedItems([item])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材表单字段容器。
|
||||
/// 素材表单中的标题字段容器。
|
||||
private final class MaterialFormFieldView: UIView {
|
||||
private let titleLabel = UILabel()
|
||||
private let contentHolder = UIView()
|
||||
|
||||
init(title: String) {
|
||||
super.init(frame: .zero)
|
||||
titleLabel.text = title
|
||||
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
titleLabel.textColor = .black
|
||||
addSubview(titleLabel)
|
||||
addSubview(contentHolder)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
}
|
||||
contentHolder.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setContent(_ view: UIView) {
|
||||
contentHolder.subviews.forEach { $0.removeFromSuperview() }
|
||||
contentHolder.addSubview(view)
|
||||
view.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材下拉选择按钮。
|
||||
/// 带标题和值的表单选择控件。
|
||||
private final class MaterialSelectButton: UIControl {
|
||||
private let titleLabel = UILabel()
|
||||
private let chevronView = UIImageView(image: UIImage(systemName: "chevron.down"))
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
|
||||
layer.borderWidth = 1
|
||||
layer.cornerRadius = 8
|
||||
titleLabel.font = .systemFont(ofSize: 14)
|
||||
chevronView.tintColor = .black
|
||||
addSubview(titleLabel)
|
||||
addSubview(chevronView)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(12)
|
||||
make.centerY.equalToSuperview()
|
||||
make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8)
|
||||
}
|
||||
chevronView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(12)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setTitle(_ title: String?, placeholder: String) {
|
||||
let text = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
titleLabel.text = text.isEmpty ? placeholder : text
|
||||
titleLabel.textColor = text.isEmpty ? UIColor(hex: 0x4B5563) : .black
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材上传网格。
|
||||
/// 素材媒体上传网格视图。
|
||||
private final class MaterialUploadGridView: UIView {
|
||||
private let stack = UIStackView()
|
||||
var onAdd: (() -> Void)?
|
||||
var onDelete: ((Int) -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
addSubview(stack)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(items: [MaterialUploadedMediaItem], mediaType: MaterialMediaType) {
|
||||
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
let showAdd = items.count < mediaType.maxCount
|
||||
let cells = items.map(Optional.some) + (showAdd ? [nil] : [])
|
||||
let rows = Int(ceil(Double(cells.count) / 3.0))
|
||||
guard rows > 0 else { return }
|
||||
for row in 0 ..< rows {
|
||||
let rowStack = UIStackView()
|
||||
rowStack.axis = .horizontal
|
||||
rowStack.spacing = 8
|
||||
rowStack.distribution = .fillEqually
|
||||
stack.addArrangedSubview(rowStack)
|
||||
for column in 0 ..< 3 {
|
||||
let index = row * 3 + column
|
||||
if index < cells.count {
|
||||
rowStack.addArrangedSubview(makeCell(item: cells[index], index: index, mediaType: mediaType))
|
||||
} else {
|
||||
rowStack.addArrangedSubview(UIView())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeCell(item: MaterialUploadedMediaItem?, index: Int, mediaType: MaterialMediaType) -> UIView {
|
||||
let cell = MaterialUploadThumbView(mediaType: mediaType)
|
||||
cell.snp.makeConstraints { make in
|
||||
make.height.equalTo(cell.snp.width).dividedBy(1.3)
|
||||
}
|
||||
if let item {
|
||||
cell.apply(item: item)
|
||||
cell.onDelete = { [weak self] in self?.onDelete?(index) }
|
||||
} else {
|
||||
cell.applyAdd()
|
||||
cell.onAdd = { [weak self] in self?.onAdd?() }
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材上传缩略图格子。
|
||||
/// 已选素材媒体缩略图视图。
|
||||
private final class MaterialUploadThumbView: UIControl {
|
||||
private let imageView = UIImageView()
|
||||
private let addCircle = UIView()
|
||||
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
|
||||
private let textLabel = UILabel()
|
||||
private let deleteButton = UIButton(type: .system)
|
||||
private let playIcon = UIImageView(image: UIImage(systemName: "play.fill"))
|
||||
private let mediaType: MaterialMediaType
|
||||
|
||||
var onAdd: (() -> Void)?
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
init(mediaType: MaterialMediaType) {
|
||||
self.mediaType = mediaType
|
||||
super.init(frame: .zero)
|
||||
layer.cornerRadius = 8
|
||||
clipsToBounds = true
|
||||
backgroundColor = .white
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
addCircle.backgroundColor = AppColor.primary
|
||||
addCircle.layer.cornerRadius = 14
|
||||
addIcon.tintColor = .white
|
||||
textLabel.font = .systemFont(ofSize: 12)
|
||||
textLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
textLabel.textAlignment = .center
|
||||
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||
deleteButton.tintColor = .white
|
||||
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
||||
deleteButton.layer.cornerRadius = 10
|
||||
playIcon.tintColor = .white
|
||||
playIcon.isHidden = true
|
||||
addSubview(imageView)
|
||||
addSubview(addCircle)
|
||||
addCircle.addSubview(addIcon)
|
||||
addSubview(textLabel)
|
||||
addSubview(deleteButton)
|
||||
addSubview(playIcon)
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
addCircle.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.centerY.equalToSuperview().offset(-10)
|
||||
make.size.equalTo(28)
|
||||
}
|
||||
addIcon.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
textLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(addCircle.snp.bottom).offset(4)
|
||||
make.leading.trailing.equalToSuperview().inset(4)
|
||||
}
|
||||
deleteButton.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview().inset(4)
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
playIcon.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(32)
|
||||
}
|
||||
addTarget(self, action: #selector(addTapped), for: .touchUpInside)
|
||||
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func applyAdd() {
|
||||
imageView.image = nil
|
||||
imageView.kf.cancelDownloadTask()
|
||||
backgroundColor = .white
|
||||
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
|
||||
layer.borderWidth = 1
|
||||
addCircle.isHidden = false
|
||||
textLabel.isHidden = false
|
||||
textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频"
|
||||
textLabel.numberOfLines = 1
|
||||
deleteButton.isHidden = true
|
||||
playIcon.isHidden = true
|
||||
}
|
||||
|
||||
func apply(item: MaterialUploadedMediaItem) {
|
||||
layer.borderWidth = 0
|
||||
backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
addCircle.isHidden = true
|
||||
deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading
|
||||
playIcon.isHidden = mediaType != .video || item.ossUrl.isEmpty || item.isUploading
|
||||
if item.isUploading {
|
||||
imageView.image = nil
|
||||
textLabel.isHidden = false
|
||||
textLabel.text = "上传中...\n\(item.uploadProgress)%"
|
||||
textLabel.numberOfLines = 2
|
||||
} else {
|
||||
textLabel.isHidden = true
|
||||
if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
|
||||
imageView.kf.setImage(with: url)
|
||||
} else {
|
||||
imageView.image = UIImage(data: item.thumbnailData ?? item.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func addTapped() {
|
||||
guard !addCircle.isHidden else { return }
|
||||
onAdd?()
|
||||
}
|
||||
|
||||
@objc private func deleteTapped() {
|
||||
onDelete?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材封面上传视图。
|
||||
/// 素材封面上传视图。
|
||||
private final class MaterialCoverUploadView: UIControl {
|
||||
private let imageView = UIImageView()
|
||||
private let addCircle = UIView()
|
||||
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
|
||||
private let titleLabel = UILabel()
|
||||
private let hintLabel = UILabel()
|
||||
private let deleteButton = UIButton(type: .system)
|
||||
var onAdd: (() -> Void)?
|
||||
var onDelete: (() -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
layer.cornerRadius = 8
|
||||
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
|
||||
layer.borderWidth = 1
|
||||
clipsToBounds = true
|
||||
imageView.contentMode = .scaleAspectFill
|
||||
imageView.clipsToBounds = true
|
||||
addCircle.backgroundColor = AppColor.primary
|
||||
addCircle.layer.cornerRadius = 16
|
||||
addIcon.tintColor = .white
|
||||
titleLabel.text = "点击上传图片"
|
||||
titleLabel.font = .systemFont(ofSize: 14)
|
||||
titleLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
titleLabel.textAlignment = .center
|
||||
hintLabel.text = "建议尺寸: 1920x1080px, 大小不超过5MB"
|
||||
hintLabel.font = .systemFont(ofSize: 12)
|
||||
hintLabel.textColor = UIColor(hex: 0xB6BECA)
|
||||
hintLabel.textAlignment = .center
|
||||
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||
deleteButton.tintColor = .white
|
||||
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
||||
deleteButton.layer.cornerRadius = 12
|
||||
addSubview(imageView)
|
||||
addSubview(addCircle)
|
||||
addCircle.addSubview(addIcon)
|
||||
addSubview(titleLabel)
|
||||
addSubview(hintLabel)
|
||||
addSubview(deleteButton)
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(snp.width).dividedBy(2.59)
|
||||
}
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
addCircle.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.centerY.equalToSuperview().offset(-22)
|
||||
make.size.equalTo(32)
|
||||
}
|
||||
addIcon.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.size.equalTo(24)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(addCircle.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(12)
|
||||
}
|
||||
hintLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||
make.leading.trailing.equalToSuperview().inset(12)
|
||||
}
|
||||
deleteButton.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview().inset(8)
|
||||
make.size.equalTo(24)
|
||||
}
|
||||
addTarget(self, action: #selector(addTapped), for: .touchUpInside)
|
||||
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(item: MaterialUploadedMediaItem?) {
|
||||
guard let item else {
|
||||
imageView.image = nil
|
||||
imageView.kf.cancelDownloadTask()
|
||||
addCircle.isHidden = false
|
||||
titleLabel.isHidden = false
|
||||
titleLabel.text = "点击上传图片"
|
||||
titleLabel.numberOfLines = 1
|
||||
hintLabel.isHidden = false
|
||||
deleteButton.isHidden = true
|
||||
return
|
||||
}
|
||||
if item.isUploading {
|
||||
imageView.image = nil
|
||||
addCircle.isHidden = true
|
||||
titleLabel.isHidden = false
|
||||
titleLabel.text = "上传中...\n\(item.uploadProgress)%"
|
||||
titleLabel.numberOfLines = 2
|
||||
hintLabel.isHidden = true
|
||||
deleteButton.isHidden = true
|
||||
} else {
|
||||
if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
|
||||
imageView.kf.setImage(with: url)
|
||||
} else {
|
||||
imageView.image = UIImage(data: item.data)
|
||||
}
|
||||
addCircle.isHidden = true
|
||||
titleLabel.isHidden = true
|
||||
hintLabel.isHidden = true
|
||||
deleteButton.isHidden = item.ossUrl.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func addTapped() {
|
||||
guard addCircle.isHidden == false else { return }
|
||||
onAdd?()
|
||||
}
|
||||
|
||||
@objc private func deleteTapped() {
|
||||
onDelete?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材标签编辑视图。
|
||||
/// 素材标签编辑视图。
|
||||
private final class MaterialTagEditorView: UIView {
|
||||
private let stack = UIStackView()
|
||||
private let tagsStack = UIStackView()
|
||||
private let inputRow = UIStackView()
|
||||
private let textField = UITextField()
|
||||
private let addButton = UIButton(type: .system)
|
||||
private var tags: [MaterialTagItem] = []
|
||||
|
||||
var onTextChanged: ((String) -> Void)?
|
||||
var onAdd: (() -> Void)?
|
||||
var onDelete: ((MaterialTagItem) -> Void)?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
tagsStack.axis = .horizontal
|
||||
tagsStack.spacing = 8
|
||||
tagsStack.alignment = .leading
|
||||
inputRow.axis = .horizontal
|
||||
inputRow.spacing = 8
|
||||
textField.placeholder = "请输入标签"
|
||||
textField.font = .systemFont(ofSize: 14)
|
||||
textField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
|
||||
textField.layer.borderWidth = 1
|
||||
textField.layer.cornerRadius = 8
|
||||
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44))
|
||||
textField.leftViewMode = .always
|
||||
addButton.setTitle("添加", for: .normal)
|
||||
addButton.setTitleColor(.white, for: .normal)
|
||||
addButton.backgroundColor = AppColor.primary
|
||||
addButton.layer.cornerRadius = 8
|
||||
addSubview(stack)
|
||||
stack.addArrangedSubview(tagsStack)
|
||||
stack.addArrangedSubview(inputRow)
|
||||
inputRow.addArrangedSubview(textField)
|
||||
inputRow.addArrangedSubview(addButton)
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
textField.snp.makeConstraints { make in
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
addButton.snp.makeConstraints { make in
|
||||
make.width.equalTo(72)
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
|
||||
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(tags: [MaterialTagItem], input: String) {
|
||||
self.tags = tags
|
||||
if textField.text != input {
|
||||
textField.text = input
|
||||
}
|
||||
tagsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
tags.forEach { tag in
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle("\(tag.name) ×", for: .normal)
|
||||
button.setTitleColor(AppColor.primary, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 12)
|
||||
button.backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||
button.layer.cornerRadius = 4
|
||||
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
|
||||
button.snp.makeConstraints { make in
|
||||
make.height.equalTo(28)
|
||||
}
|
||||
button.addAction(UIAction { [weak self] _ in
|
||||
self?.onDelete?(tag)
|
||||
}, for: .touchUpInside)
|
||||
tagsStack.addArrangedSubview(button)
|
||||
}
|
||||
tagsStack.isHidden = tags.isEmpty
|
||||
}
|
||||
|
||||
@objc private func textChanged() {
|
||||
onTextChanged?(textField.text ?? "")
|
||||
}
|
||||
|
||||
@objc private func addTapped() {
|
||||
onAdd?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材上传进度蒙层。
|
||||
/// 素材上传进度遮罩视图。
|
||||
private final class MaterialUploadProgressOverlay: UIView {
|
||||
private let card = UIView()
|
||||
private let titleLabel = UILabel()
|
||||
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||
private let percentLabel = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = UIColor.black.withAlphaComponent(0.2)
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 12
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
progressView.progressTintColor = AppColor.primary
|
||||
progressView.trackTintColor = UIColor(hex: 0xF3F4F6)
|
||||
percentLabel.font = .systemFont(ofSize: 14)
|
||||
percentLabel.textColor = AppColor.textSecondary
|
||||
percentLabel.textAlignment = .center
|
||||
addSubview(card)
|
||||
card.addSubview(titleLabel)
|
||||
card.addSubview(progressView)
|
||||
card.addSubview(percentLabel)
|
||||
card.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(44)
|
||||
make.height.equalTo(card.snp.width).dividedBy(1.7)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(24)
|
||||
}
|
||||
progressView.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(32)
|
||||
make.leading.trailing.equalToSuperview().inset(24)
|
||||
make.height.equalTo(6)
|
||||
}
|
||||
percentLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(progressView.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview().inset(24)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(state: MaterialUploadDialogState) {
|
||||
titleLabel.text = state.title
|
||||
let progress = Float(max(0, min(100, state.progress))) / 100
|
||||
progressView.setProgress(progress, animated: true)
|
||||
percentLabel.text = "\(max(0, min(100, state.progress)))%"
|
||||
}
|
||||
}
|
||||
654
suixinkan/UI/MaterialManagement/MaterialListViewController.swift
Normal file
654
suixinkan/UI/MaterialManagement/MaterialListViewController.swift
Normal file
@ -0,0 +1,654 @@
|
||||
//
|
||||
// MaterialListViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 素材管理列表页,对齐 Android `MaterialListScreen`。
|
||||
/// 素材管理列表页,展示筛选、统计、素材卡片与添加入口。
|
||||
final class MaterialListViewController: BaseViewController {
|
||||
private let viewModel: MaterialListViewModel
|
||||
private let api: any MaterialManagementServing
|
||||
|
||||
private let searchContainer = UIView()
|
||||
private let searchBox = UIView()
|
||||
private let searchIconView = UIImageView(image: UIImage(systemName: "magnifyingglass"))
|
||||
private let searchTextField = UITextField()
|
||||
private let filterContainer = UIView()
|
||||
private let filterButton = UIButton(type: .system)
|
||||
private let filterTitleLabel = UILabel()
|
||||
private let filterChevronView = UIImageView(image: UIImage(systemName: "chevron.down"))
|
||||
private let filterDropdownView = UIView()
|
||||
private let filterDropdownStack = UIStackView()
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private let emptyLabel = UILabel()
|
||||
private let bottomBar = UIView()
|
||||
private let addButton = UIButton(type: .system)
|
||||
private var dataSource: UITableViewDiffableDataSource<Int, MaterialListRow>!
|
||||
private var filterOptionButtons: [(MaterialFilterStatus, UIButton)] = []
|
||||
private var needsRefreshOnAppear = false
|
||||
|
||||
/// 初始化素材管理列表页。
|
||||
init(
|
||||
viewModel: MaterialListViewModel = MaterialListViewModel(),
|
||||
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.api = api
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "素材管理"
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackground
|
||||
|
||||
searchContainer.backgroundColor = .white
|
||||
searchBox.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
searchBox.layer.cornerRadius = 4
|
||||
searchIconView.tintColor = AppColor.textSecondary
|
||||
searchIconView.contentMode = .scaleAspectFit
|
||||
searchTextField.placeholder = "搜索素材名称"
|
||||
searchTextField.font = .systemFont(ofSize: 13)
|
||||
searchTextField.textColor = AppColor.textPrimary
|
||||
searchTextField.clearButtonMode = .whileEditing
|
||||
searchTextField.returnKeyType = .search
|
||||
|
||||
filterContainer.backgroundColor = .white
|
||||
filterButton.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||
filterButton.layer.cornerRadius = 4
|
||||
filterTitleLabel.text = "筛选"
|
||||
filterTitleLabel.font = .systemFont(ofSize: 14)
|
||||
filterTitleLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
filterChevronView.tintColor = UIColor(hex: 0x4B5563)
|
||||
filterChevronView.contentMode = .scaleAspectFit
|
||||
filterDropdownView.backgroundColor = .white
|
||||
filterDropdownView.layer.cornerRadius = 4
|
||||
filterDropdownView.layer.shadowColor = UIColor.black.cgColor
|
||||
filterDropdownView.layer.shadowOpacity = 0.12
|
||||
filterDropdownView.layer.shadowRadius = 10
|
||||
filterDropdownView.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||
filterDropdownView.isHidden = true
|
||||
filterDropdownStack.axis = .vertical
|
||||
|
||||
tableView.backgroundColor = AppColor.pageBackground
|
||||
tableView.separatorStyle = .none
|
||||
tableView.estimatedRowHeight = 148
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.delegate = self
|
||||
tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 16, right: 0)
|
||||
tableView.refreshControl = UIRefreshControl()
|
||||
tableView.register(MaterialStatsCell.self, forCellReuseIdentifier: MaterialStatsCell.reuseIdentifier)
|
||||
tableView.register(MaterialListCell.self, forCellReuseIdentifier: MaterialListCell.reuseIdentifier)
|
||||
|
||||
dataSource = UITableViewDiffableDataSource<Int, MaterialListRow>(tableView: tableView) {
|
||||
[weak self] tableView, indexPath, row in
|
||||
switch row {
|
||||
case .stats(let info):
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: MaterialStatsCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! MaterialStatsCell
|
||||
cell.apply(info: info)
|
||||
return cell
|
||||
case .item(let item):
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: MaterialListCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as! MaterialListCell
|
||||
cell.apply(item: item)
|
||||
cell.onToggle = { [weak self] checked in
|
||||
guard let self else { return }
|
||||
Task { await self.viewModel.toggleListing(materialId: item.id, checked: checked, api: self.api) }
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
emptyLabel.text = "暂无素材"
|
||||
emptyLabel.font = .systemFont(ofSize: 16)
|
||||
emptyLabel.textColor = AppColor.textSecondary
|
||||
emptyLabel.textAlignment = .center
|
||||
emptyLabel.isHidden = true
|
||||
|
||||
bottomBar.backgroundColor = .white
|
||||
addButton.setTitle("添加", for: .normal)
|
||||
addButton.setTitleColor(.white, for: .normal)
|
||||
addButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
addButton.backgroundColor = AppColor.primary
|
||||
addButton.layer.cornerRadius = 8
|
||||
|
||||
view.addSubview(searchContainer)
|
||||
searchContainer.addSubview(searchBox)
|
||||
searchBox.addSubview(searchIconView)
|
||||
searchBox.addSubview(searchTextField)
|
||||
view.addSubview(filterContainer)
|
||||
filterContainer.addSubview(filterButton)
|
||||
filterButton.addSubview(filterTitleLabel)
|
||||
filterButton.addSubview(filterChevronView)
|
||||
view.addSubview(tableView)
|
||||
view.addSubview(emptyLabel)
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.addSubview(addButton)
|
||||
view.addSubview(filterDropdownView)
|
||||
filterDropdownView.addSubview(filterDropdownStack)
|
||||
configureFilterDropdown()
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
searchContainer.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(68)
|
||||
}
|
||||
searchBox.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.height.equalTo(36)
|
||||
}
|
||||
searchIconView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(12)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(15)
|
||||
}
|
||||
searchTextField.snp.makeConstraints { make in
|
||||
make.leading.equalTo(searchIconView.snp.trailing).offset(12)
|
||||
make.trailing.equalToSuperview().inset(12)
|
||||
make.top.bottom.equalToSuperview()
|
||||
}
|
||||
filterContainer.snp.makeConstraints { make in
|
||||
make.top.equalTo(searchContainer.snp.bottom).offset(1)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(68)
|
||||
}
|
||||
filterButton.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.height.equalTo(36)
|
||||
}
|
||||
filterTitleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
filterChevronView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().inset(14)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(18)
|
||||
}
|
||||
bottomBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
addButton.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(16)
|
||||
make.leading.trailing.equalToSuperview().inset(15)
|
||||
make.height.equalTo(48)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(filterContainer.snp.bottom).offset(12)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(bottomBar.snp.top)
|
||||
}
|
||||
filterDropdownView.snp.makeConstraints { make in
|
||||
make.top.equalTo(filterButton.snp.bottom)
|
||||
make.leading.trailing.equalTo(filterButton)
|
||||
}
|
||||
filterDropdownStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
emptyLabel.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview()
|
||||
make.centerY.equalTo(tableView)
|
||||
make.leading.trailing.equalToSuperview().inset(24)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
searchTextField.addTarget(self, action: #selector(searchTextChanged), for: .editingChanged)
|
||||
searchTextField.delegate = self
|
||||
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
|
||||
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
|
||||
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task { await viewModel.loadInitial(api: api) }
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
if needsRefreshOnAppear {
|
||||
needsRefreshOnAppear = false
|
||||
Task { await viewModel.refresh(api: api) }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
filterTitleLabel.text = viewModel.filterStatus == .all ? "筛选" : viewModel.filterStatus.title
|
||||
filterOptionButtons.forEach { status, button in
|
||||
let selected = status == viewModel.filterStatus
|
||||
button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .medium : .regular)
|
||||
}
|
||||
if searchTextField.text != viewModel.searchText {
|
||||
searchTextField.text = viewModel.searchText
|
||||
}
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Int, MaterialListRow>()
|
||||
snapshot.appendSections([0])
|
||||
snapshot.appendItems([.stats(viewModel.orderInfo)] + viewModel.items.map(MaterialListRow.item))
|
||||
dataSource.apply(snapshot, animatingDifferences: true)
|
||||
emptyLabel.isHidden = !viewModel.items.isEmpty || viewModel.isLoading || viewModel.isRefreshing
|
||||
viewModel.isLoading && viewModel.items.isEmpty ? showLoading() : hideLoading()
|
||||
if !viewModel.isRefreshing {
|
||||
tableView.refreshControl?.endRefreshing()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func searchTextChanged() {
|
||||
let text = searchTextField.text ?? ""
|
||||
Task { await viewModel.updateSearchText(text, api: api) }
|
||||
}
|
||||
|
||||
@objc private func filterTapped() {
|
||||
filterDropdownView.isHidden.toggle()
|
||||
view.bringSubviewToFront(filterDropdownView)
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.filterChevronView.transform = self.filterDropdownView.isHidden
|
||||
? .identity
|
||||
: CGAffineTransform(rotationAngle: .pi)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func addTapped() {
|
||||
let controller = MaterialFormViewController(mode: .create)
|
||||
controller.onSubmitSuccess = { [weak self] in
|
||||
self?.needsRefreshOnAppear = true
|
||||
}
|
||||
navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
|
||||
@objc private func refreshPulled() {
|
||||
hideFilterDropdown()
|
||||
Task { await viewModel.refresh(api: api) }
|
||||
}
|
||||
|
||||
private func configureFilterDropdown() {
|
||||
MaterialFilterStatus.allCases.forEach { status in
|
||||
let button = UIButton(type: .system)
|
||||
button.contentHorizontalAlignment = .leading
|
||||
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||||
button.setTitle(status.title, for: .normal)
|
||||
button.backgroundColor = .white
|
||||
button.snp.makeConstraints { make in
|
||||
make.height.equalTo(48)
|
||||
}
|
||||
button.addAction(UIAction { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.hideFilterDropdown()
|
||||
Task { await self.viewModel.selectFilter(status, api: self.api) }
|
||||
}, for: .touchUpInside)
|
||||
filterDropdownStack.addArrangedSubview(button)
|
||||
filterOptionButtons.append((status, button))
|
||||
}
|
||||
}
|
||||
|
||||
private func hideFilterDropdown() {
|
||||
guard !filterDropdownView.isHidden else { return }
|
||||
filterDropdownView.isHidden = true
|
||||
UIView.animate(withDuration: 0.2) {
|
||||
self.filterChevronView.transform = .identity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MaterialListViewController: UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
hideFilterDropdown()
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
guard case .item(let item) = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
let controller = MaterialDetailViewController(materialId: item.id)
|
||||
controller.onDeleted = { [weak self] deletedId in
|
||||
self?.viewModel.removeMaterial(id: deletedId)
|
||||
}
|
||||
controller.onEdited = { [weak self] in
|
||||
self?.needsRefreshOnAppear = true
|
||||
}
|
||||
navigationController?.pushViewController(controller, animated: true)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||
guard case .item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: indexPath.row - 1, api: api) }
|
||||
}
|
||||
}
|
||||
|
||||
extension MaterialListViewController: UITextFieldDelegate {
|
||||
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||
textField.resignFirstResponder()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材列表 Diffable 数据源行类型。
|
||||
private enum MaterialListRow: Hashable {
|
||||
case stats(MaterialOrderInfo)
|
||||
case item(MaterialItem)
|
||||
}
|
||||
|
||||
/// 素材统计卡片行。
|
||||
/// 素材列表顶部统计单元格。
|
||||
private final class MaterialStatsCell: UITableViewCell {
|
||||
static let reuseIdentifier = "MaterialStatsCell"
|
||||
private let cardView = UIView()
|
||||
private let stack = UIStackView()
|
||||
private let totalView = MaterialStatisticView(title: "订单总数量", color: UIColor(hex: 0x7F00FF), background: UIColor(hex: 0xEBD8FF))
|
||||
private let priceView = MaterialStatisticView(title: "客单价", color: UIColor(hex: 0x22C55E), background: UIColor(hex: 0xF0FDF4))
|
||||
private let refundView = MaterialStatisticView(title: "退款金额", color: UIColor(hex: 0xFF7B00), background: UIColor(hex: 0xFFF0E2))
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
backgroundColor = AppColor.pageBackground
|
||||
contentView.backgroundColor = AppColor.pageBackground
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 12
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = 12
|
||||
stack.distribution = .fillEqually
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(stack)
|
||||
[totalView, priceView, refundView].forEach(stack.addArrangedSubview)
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.top.bottom.equalToSuperview().inset(8)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(info: MaterialOrderInfo) {
|
||||
totalView.setValue(MaterialDisplayFormatter.formatNumber(info.totalNum))
|
||||
priceView.setValue("+\(info.avgOrderAmount)")
|
||||
refundView.setValue(info.refundTotal)
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材统计卡片。
|
||||
/// 单个素材统计指标视图。
|
||||
private final class MaterialStatisticView: UIView {
|
||||
private let titleLabel = UILabel()
|
||||
private let valueLabel = UILabel()
|
||||
|
||||
init(title: String, color: UIColor, background: UIColor) {
|
||||
super.init(frame: .zero)
|
||||
self.backgroundColor = background
|
||||
layer.cornerRadius = 12
|
||||
titleLabel.text = title
|
||||
titleLabel.font = .systemFont(ofSize: 14)
|
||||
titleLabel.textColor = .black
|
||||
titleLabel.textAlignment = .center
|
||||
valueLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||
valueLabel.textColor = color
|
||||
valueLabel.textAlignment = .center
|
||||
valueLabel.adjustsFontSizeToFitWidth = true
|
||||
valueLabel.minimumScaleFactor = 0.7
|
||||
addSubview(titleLabel)
|
||||
addSubview(valueLabel)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(8)
|
||||
}
|
||||
valueLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(10)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(8)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setValue(_ value: String) {
|
||||
valueLabel.text = value
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材列表卡片 cell,对齐 Android `MaterialItemView`。
|
||||
/// 素材列表卡片单元格。
|
||||
private final class MaterialListCell: UITableViewCell {
|
||||
static let reuseIdentifier = "MaterialListCell"
|
||||
|
||||
private let cardView = UIView()
|
||||
private let coverImageView = UIImageView()
|
||||
private let placeholderLabel = UILabel()
|
||||
private let titleLabel = UILabel()
|
||||
private let timeLabel = UILabel()
|
||||
private let statusBadge = MaterialStatusBadgeView()
|
||||
private let listingSwitch = UISwitch()
|
||||
private let statsStack = UIStackView()
|
||||
private let downloadStatView = MaterialStatView(systemIconName: "arrow.down.circle")
|
||||
private let likeStatView = MaterialStatView(iconName: "sample_ic_like_count")
|
||||
private let collectStatView = MaterialStatView(iconName: "sample_ic_collect_count")
|
||||
private var isApplying = false
|
||||
|
||||
var onToggle: ((Bool) -> Void)?
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
setupUI()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(item: MaterialItem) {
|
||||
isApplying = true
|
||||
titleLabel.text = item.name
|
||||
timeLabel.text = item.createdAt
|
||||
statusBadge.apply(status: item.status)
|
||||
listingSwitch.setOn(item.listingStatus == 1, animated: false)
|
||||
downloadStatView.setCount(item.downloadCount)
|
||||
likeStatView.setCount(item.likesCount)
|
||||
collectStatView.setCount(item.collectCount)
|
||||
|
||||
if let url = URL(string: item.coverUrl), !item.coverUrl.isEmpty {
|
||||
placeholderLabel.isHidden = true
|
||||
coverImageView.kf.setImage(with: url)
|
||||
} else {
|
||||
coverImageView.image = nil
|
||||
placeholderLabel.isHidden = false
|
||||
}
|
||||
isApplying = false
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
selectionStyle = .none
|
||||
backgroundColor = AppColor.pageBackground
|
||||
contentView.backgroundColor = AppColor.pageBackground
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 12
|
||||
cardView.clipsToBounds = true
|
||||
|
||||
coverImageView.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||
coverImageView.contentMode = .scaleAspectFill
|
||||
coverImageView.clipsToBounds = true
|
||||
coverImageView.layer.cornerRadius = 8
|
||||
|
||||
placeholderLabel.text = "暂无图片"
|
||||
placeholderLabel.font = .systemFont(ofSize: 12)
|
||||
placeholderLabel.textColor = AppColor.textSecondary
|
||||
placeholderLabel.textAlignment = .center
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .bold)
|
||||
titleLabel.textColor = .black
|
||||
titleLabel.numberOfLines = 1
|
||||
|
||||
timeLabel.font = .systemFont(ofSize: 12)
|
||||
timeLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
timeLabel.numberOfLines = 1
|
||||
|
||||
listingSwitch.onTintColor = AppColor.primary
|
||||
listingSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged)
|
||||
|
||||
statsStack.axis = .horizontal
|
||||
statsStack.spacing = 16
|
||||
statsStack.alignment = .center
|
||||
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(coverImageView)
|
||||
coverImageView.addSubview(placeholderLabel)
|
||||
cardView.addSubview(titleLabel)
|
||||
cardView.addSubview(timeLabel)
|
||||
cardView.addSubview(statusBadge)
|
||||
cardView.addSubview(listingSwitch)
|
||||
cardView.addSubview(statsStack)
|
||||
[downloadStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview)
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.top.bottom.equalToSuperview().inset(8)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
coverImageView.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalToSuperview().inset(12)
|
||||
make.width.equalTo(109)
|
||||
make.height.equalTo(100)
|
||||
}
|
||||
placeholderLabel.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(8)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(16)
|
||||
make.leading.equalTo(coverImageView.snp.trailing).offset(12)
|
||||
make.trailing.equalToSuperview().inset(12)
|
||||
}
|
||||
timeLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||
make.leading.trailing.equalTo(titleLabel)
|
||||
}
|
||||
statusBadge.snp.makeConstraints { make in
|
||||
make.top.equalTo(timeLabel.snp.bottom).offset(8)
|
||||
make.leading.equalTo(titleLabel)
|
||||
make.height.equalTo(24)
|
||||
}
|
||||
listingSwitch.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(statusBadge)
|
||||
make.trailing.equalToSuperview().inset(12)
|
||||
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
|
||||
}
|
||||
statsStack.snp.makeConstraints { make in
|
||||
make.top.equalTo(statusBadge.snp.bottom).offset(8)
|
||||
make.leading.equalTo(titleLabel)
|
||||
make.trailing.lessThanOrEqualToSuperview().inset(12)
|
||||
make.bottom.lessThanOrEqualToSuperview().inset(12)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func switchChanged() {
|
||||
guard !isApplying else { return }
|
||||
onToggle?(listingSwitch.isOn)
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材状态 badge。
|
||||
/// 素材审核状态标签视图。
|
||||
final class MaterialStatusBadgeView: UIView {
|
||||
private let label = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
layer.cornerRadius = 4
|
||||
addSubview(label)
|
||||
label.font = .systemFont(ofSize: 12, weight: .medium)
|
||||
label.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview().inset(6)
|
||||
make.centerY.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 设置审核状态。
|
||||
func apply(status: Int) {
|
||||
let color: UIColor
|
||||
switch status {
|
||||
case 1: color = UIColor(hex: 0x22C55E)
|
||||
case 0: color = UIColor(hex: 0xFF7B00)
|
||||
case 2: color = UIColor(hex: 0xEF4444)
|
||||
default: color = AppColor.textSecondary
|
||||
}
|
||||
label.text = MaterialDisplayFormatter.reviewStatusText(status)
|
||||
label.textColor = color
|
||||
backgroundColor = color.withAlphaComponent(0.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材统计图标与数字。
|
||||
/// 素材卡片中的图标计数视图。
|
||||
private final class MaterialStatView: UIView {
|
||||
private let iconView = UIImageView()
|
||||
private let countLabel = UILabel()
|
||||
|
||||
init(iconName: String? = nil, systemIconName: String? = nil) {
|
||||
super.init(frame: .zero)
|
||||
if let iconName {
|
||||
iconView.image = UIImage(named: iconName)
|
||||
} else if let systemIconName {
|
||||
iconView.image = UIImage(systemName: systemIconName)
|
||||
}
|
||||
iconView.tintColor = UIColor(hex: 0x6B7280)
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
countLabel.font = .systemFont(ofSize: 14)
|
||||
countLabel.textColor = UIColor(hex: 0x6B7280)
|
||||
addSubview(iconView)
|
||||
addSubview(countLabel)
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview()
|
||||
make.size.equalTo(16)
|
||||
}
|
||||
countLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(iconView.snp.trailing).offset(4)
|
||||
make.trailing.centerY.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setCount(_ count: Int) {
|
||||
countLabel.text = MaterialDisplayFormatter.formatCount(count)
|
||||
}
|
||||
}
|
||||
@ -30,6 +30,7 @@ final class ProfileViewController: BaseViewController {
|
||||
private let realNameRow = ProfileInfoRowView(title: "认证状态")
|
||||
private let statusRow = ProfileInfoRowView(title: "账号状态")
|
||||
private let scenicRow = ProfileInfoRowView(title: "当前景区")
|
||||
private let settingRow = ProfileInfoRowView(title: "设置中心")
|
||||
private let logoutButton = UIButton(type: .system)
|
||||
private var hasLoadedProfileOnce = false
|
||||
|
||||
@ -85,6 +86,7 @@ final class ProfileViewController: BaseViewController {
|
||||
passwordRow.addTarget(self, action: #selector(changePasswordTapped), for: .touchUpInside)
|
||||
withdrawalRow.addTarget(self, action: #selector(withdrawalTapped), for: .touchUpInside)
|
||||
realNameRow.addTarget(self, action: #selector(realNameTapped), for: .touchUpInside)
|
||||
settingRow.addTarget(self, action: #selector(settingTapped), for: .touchUpInside)
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
@ -177,14 +179,15 @@ final class ProfileViewController: BaseViewController {
|
||||
|
||||
[
|
||||
nameRow, accountRow, phoneRow, withdrawalRow,
|
||||
passwordRow, realNameRow, statusRow, scenicRow
|
||||
passwordRow, realNameRow, statusRow, scenicRow,
|
||||
settingRow
|
||||
].forEach { infoCard.addArrangedSubview($0) }
|
||||
|
||||
nameRow.isUserInteractionEnabled = false
|
||||
phoneRow.isUserInteractionEnabled = false
|
||||
statusRow.isUserInteractionEnabled = false
|
||||
scenicRow.isUserInteractionEnabled = false
|
||||
scenicRow.showsDivider = false
|
||||
settingRow.showsDivider = false
|
||||
}
|
||||
|
||||
private func setupLogoutButton() {
|
||||
@ -224,6 +227,7 @@ final class ProfileViewController: BaseViewController {
|
||||
value: viewModel.currentScenicName,
|
||||
badge: .scenic
|
||||
)
|
||||
settingRow.configure(value: nil, accessory: .chevron)
|
||||
}
|
||||
|
||||
private func configureWithdrawalRow() {
|
||||
@ -342,6 +346,10 @@ final class ProfileViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func settingTapped() {
|
||||
navigationController?.pushViewController(SettingViewController(), animated: true)
|
||||
}
|
||||
|
||||
@objc private func logoutTapped() {
|
||||
let alert = UIAlertController(title: "确定退出登录?", message: "退出后将需要重新登录", preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
|
||||
67
suixinkan/UI/Setting/AgreementWebViewController.swift
Normal file
67
suixinkan/UI/Setting/AgreementWebViewController.swift
Normal file
@ -0,0 +1,67 @@
|
||||
//
|
||||
// AgreementWebViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
import WebKit
|
||||
|
||||
/// 协议 H5 页面,使用应用内 WebView 展示关于我们、用户协议和隐私政策。
|
||||
final class AgreementWebViewController: BaseViewController {
|
||||
private let pageTitle: String
|
||||
private let url: URL
|
||||
|
||||
private let webView = WKWebView(frame: .zero)
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||
|
||||
init(title: String, url: URL) {
|
||||
pageTitle = title
|
||||
self.url = url
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = pageTitle
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.cardBackground
|
||||
webView.navigationDelegate = self
|
||||
view.addSubview(webView)
|
||||
view.addSubview(loadingIndicator)
|
||||
loadingIndicator.hidesWhenStopped = true
|
||||
loadingIndicator.startAnimating()
|
||||
webView.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
webView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
loadingIndicator.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AgreementWebViewController: WKNavigationDelegate {
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
loadingIndicator.stopAnimating()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
loadingIndicator.stopAnimating()
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
loadingIndicator.stopAnimating()
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
230
suixinkan/UI/Setting/SettingViewController.swift
Normal file
230
suixinkan/UI/Setting/SettingViewController.swift
Normal file
@ -0,0 +1,230 @@
|
||||
//
|
||||
// SettingViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 设置中心页面,对齐 Android `SettingScreen` 的当前可见设置项。
|
||||
final class SettingViewController: BaseViewController {
|
||||
private let viewModel: SettingViewModel
|
||||
private let settingAPI: SettingAPI
|
||||
|
||||
private let contentView = UIView()
|
||||
private let cardView = UIView()
|
||||
private let rowsStack = UIStackView()
|
||||
private let versionRow = SettingMenuRow(title: "系统版本", showsChevron: false)
|
||||
private let copyrightLabel = UILabel()
|
||||
|
||||
init(
|
||||
viewModel: SettingViewModel = SettingViewModel(),
|
||||
settingAPI: SettingAPI = NetworkServices.shared.settingAPI
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.settingAPI = settingAPI
|
||||
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
|
||||
|
||||
view.addSubview(contentView)
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(rowsStack)
|
||||
contentView.addSubview(copyrightLabel)
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 12
|
||||
cardView.clipsToBounds = true
|
||||
|
||||
rowsStack.axis = .vertical
|
||||
|
||||
copyrightLabel.text = "Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
|
||||
copyrightLabel.font = .systemFont(ofSize: 14)
|
||||
copyrightLabel.textColor = UIColor(hex: 0xB6BECA)
|
||||
copyrightLabel.textAlignment = .center
|
||||
copyrightLabel.numberOfLines = 0
|
||||
|
||||
let rows: [SettingMenuRow] = [
|
||||
SettingMenuRow(title: "关于我们"),
|
||||
versionRow,
|
||||
SettingMenuRow(title: "App下载", value: "复制链接", valueColor: AppColor.primary, showsChevron: false),
|
||||
SettingMenuRow(title: "用户协议"),
|
||||
SettingMenuRow(title: "隐私政策", showsDivider: false),
|
||||
]
|
||||
rows.forEach(rowsStack.addArrangedSubview)
|
||||
|
||||
rows[0].addTarget(self, action: #selector(aboutTapped), for: .touchUpInside)
|
||||
rows[2].addTarget(self, action: #selector(copyDownloadTapped), for: .touchUpInside)
|
||||
rows[3].addTarget(self, action: #selector(userAgreementTapped), for: .touchUpInside)
|
||||
rows[4].addTarget(self, action: #selector(privacyTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
contentView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||||
}
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
}
|
||||
rowsStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16))
|
||||
}
|
||||
copyrightLabel.snp.makeConstraints { make in
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalToSuperview().offset(-16)
|
||||
}
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.applyViewModel() }
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
applyViewModel()
|
||||
Task { await viewModel.checkLatestVersion(api: settingAPI) }
|
||||
}
|
||||
|
||||
private func applyViewModel() {
|
||||
versionRow.configure(value: viewModel.appVersion, showsUpgrade: viewModel.hasNewVersion)
|
||||
}
|
||||
|
||||
@objc private func aboutTapped() {
|
||||
openAgreement(.aboutUs)
|
||||
}
|
||||
|
||||
@objc private func copyDownloadTapped() {
|
||||
UIPasteboard.general.string = viewModel.appDownloadURL.absoluteString
|
||||
showToast("已复制到剪贴板!")
|
||||
}
|
||||
|
||||
@objc private func userAgreementTapped() {
|
||||
openAgreement(.userAgreement)
|
||||
}
|
||||
|
||||
@objc private func privacyTapped() {
|
||||
openAgreement(.privacyPolicy)
|
||||
}
|
||||
|
||||
private func openAgreement(_ kind: SettingAgreementKind) {
|
||||
let destination = viewModel.agreementDestination(for: kind)
|
||||
navigationController?.pushViewController(
|
||||
AgreementWebViewController(title: destination.title, url: destination.url),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置中心菜单行,展示标题、右侧文本、更新标记和箭头。
|
||||
final class SettingMenuRow: UIControl {
|
||||
private let titleLabel = UILabel()
|
||||
private let rightStack = UIStackView()
|
||||
private let valueLabel = UILabel()
|
||||
private let upgradeImageView = UIImageView()
|
||||
private let chevronImageView = UIImageView()
|
||||
private let divider = UIView()
|
||||
private let showsChevron: Bool
|
||||
|
||||
init(
|
||||
title: String,
|
||||
value: String? = nil,
|
||||
valueColor: UIColor = AppColor.textPrimary,
|
||||
showsChevron: Bool = true,
|
||||
showsDivider: Bool = true
|
||||
) {
|
||||
self.showsChevron = showsChevron
|
||||
super.init(frame: .zero)
|
||||
setupUI()
|
||||
setupConstraints()
|
||||
titleLabel.text = title
|
||||
valueLabel.text = value
|
||||
valueLabel.textColor = valueColor
|
||||
chevronImageView.isHidden = !showsChevron
|
||||
divider.isHidden = !showsDivider
|
||||
accessibilityTraits.insert(.button)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var isHighlighted: Bool {
|
||||
didSet {
|
||||
backgroundColor = isHighlighted ? UIColor.black.withAlphaComponent(0.04) : .clear
|
||||
}
|
||||
}
|
||||
|
||||
func configure(value: String?, showsUpgrade: Bool) {
|
||||
valueLabel.text = value
|
||||
upgradeImageView.isHidden = !showsUpgrade
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
titleLabel.font = .systemFont(ofSize: 14)
|
||||
titleLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
|
||||
valueLabel.font = .systemFont(ofSize: 14)
|
||||
valueLabel.textColor = AppColor.textPrimary
|
||||
|
||||
upgradeImageView.image = UIImage(systemName: "arrow.up.circle")?.withRenderingMode(.alwaysTemplate)
|
||||
upgradeImageView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||
upgradeImageView.contentMode = .scaleAspectFit
|
||||
upgradeImageView.isHidden = true
|
||||
|
||||
chevronImageView.image = UIImage(systemName: "chevron.right")?.withRenderingMode(.alwaysTemplate)
|
||||
chevronImageView.tintColor = AppColor.textTertiary
|
||||
chevronImageView.contentMode = .scaleAspectFit
|
||||
|
||||
divider.backgroundColor = AppColor.border
|
||||
|
||||
rightStack.axis = .horizontal
|
||||
rightStack.alignment = .center
|
||||
rightStack.spacing = 4
|
||||
[valueLabel, upgradeImageView, chevronImageView].forEach(rightStack.addArrangedSubview)
|
||||
|
||||
[titleLabel, rightStack, divider].forEach(addSubview)
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(52)
|
||||
}
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.centerY.equalToSuperview()
|
||||
}
|
||||
|
||||
upgradeImageView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(24)
|
||||
}
|
||||
|
||||
chevronImageView.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(24)
|
||||
}
|
||||
|
||||
rightStack.snp.makeConstraints { make in
|
||||
make.centerY.equalToSuperview()
|
||||
make.trailing.equalToSuperview()
|
||||
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
|
||||
}
|
||||
|
||||
divider.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.height.equalTo(0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -328,6 +328,19 @@ enum WildReportLocalMediaLoader {
|
||||
}
|
||||
}
|
||||
|
||||
extension MediaPreviewItem {
|
||||
/// 从举报本地附件创建媒体预览资源项。
|
||||
init?(wildReportAttachment attachment: WildReportAttachment) {
|
||||
guard let url = attachment.localFileURL else { return nil }
|
||||
switch attachment.kind {
|
||||
case .image, .payment:
|
||||
self = .localImage(url: url)
|
||||
case .video:
|
||||
self = .localVideo(url: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报模块附件缩略 tile,优先展示本地图片或视频封面。
|
||||
final class WildReportAttachmentTileView: UIControl {
|
||||
private let gradientLayer = CAGradientLayer()
|
||||
|
||||
@ -9,11 +9,16 @@ import UIKit
|
||||
/// 举报摄影师首页,提供立即举报、我的举报、风险地图和规则入口。
|
||||
final class WildPhotographerReportHomeViewController: BaseViewController {
|
||||
private let viewModel: WildPhotographerReportHomeViewModel
|
||||
private let api: any WildPhotographerReportServing
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
|
||||
init(viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel()) {
|
||||
init(
|
||||
viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel(),
|
||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
|
||||
) {
|
||||
self.viewModel = viewModel
|
||||
self.api = api
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@ -25,20 +30,6 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
|
||||
override func setupNavigationBar() {
|
||||
title = "举报摄影师"
|
||||
navigationItem.largeTitleDisplayMode = .never
|
||||
let backButton = UIButton(type: .system)
|
||||
backButton.backgroundColor = .white
|
||||
backButton.tintColor = AppColor.textPrimary
|
||||
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
|
||||
backButton.layer.cornerRadius = 25
|
||||
backButton.layer.shadowColor = UIColor.black.cgColor
|
||||
backButton.layer.shadowOpacity = 0.05
|
||||
backButton.layer.shadowOffset = CGSize(width: 0, height: 6)
|
||||
backButton.layer.shadowRadius = 16
|
||||
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
|
||||
backButton.snp.makeConstraints { make in
|
||||
make.size.equalTo(50)
|
||||
}
|
||||
navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backButton)
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
@ -68,6 +59,16 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
|
||||
viewModel.onStateChange = { [weak self] in
|
||||
Task { @MainActor in self?.rebuildContent() }
|
||||
}
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
Task {
|
||||
await viewModel.loadReportCopy(api: api)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -81,18 +82,16 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
|
||||
case .normal:
|
||||
contentStack.addArrangedSubview(makeHeroCard())
|
||||
contentStack.addArrangedSubview(makeRiskMapEntry())
|
||||
contentStack.addArrangedSubview(makeNoticeCard())
|
||||
if viewModel.shouldShowNoticeModule {
|
||||
contentStack.addArrangedSubview(makeNoticeCard())
|
||||
}
|
||||
contentStack.addArrangedSubview(makeTrustCard())
|
||||
case .loading:
|
||||
contentStack.addArrangedSubview(makeStateCard(icon: "clock", title: "加载中", message: "正在加载举报服务"))
|
||||
case .empty:
|
||||
contentStack.addArrangedSubview(makeStateCard(icon: "exclamationmark.shield", title: viewModel.emptyTitle, message: viewModel.emptyMessage, buttonTitle: "恢复演示数据") { [weak self] in
|
||||
self?.viewModel.restoreDemoData()
|
||||
})
|
||||
contentStack.addArrangedSubview(makeStateCard(icon: "exclamationmark.shield", title: viewModel.emptyTitle, message: viewModel.emptyMessage))
|
||||
case .error:
|
||||
contentStack.addArrangedSubview(makeStateCard(icon: "wifi.exclamationmark", title: "加载失败", message: "举报服务暂时不可用,请稍后重试。", buttonTitle: "重新加载") { [weak self] in
|
||||
self?.viewModel.retryLoadAfterError()
|
||||
})
|
||||
contentStack.addArrangedSubview(makeStateCard(icon: "wifi.exclamationmark", title: "加载失败", message: "举报服务暂时不可用,请稍后重试。"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -172,31 +171,28 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
|
||||
headerIcon.tintColor = AppColor.primary
|
||||
headerIcon.snp.makeConstraints { make in make.size.equalTo(24) }
|
||||
let title = WildReportUI.label("举报须知", font: .systemFont(ofSize: 18, weight: .bold), color: AppColor.textPrimary)
|
||||
let ruleButton = UIButton(type: .system)
|
||||
ruleButton.setTitle("举报规则", for: .normal)
|
||||
ruleButton.setImage(UIImage(systemName: "shield.lefthalf.filled"), for: .normal)
|
||||
ruleButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
|
||||
ruleButton.tintColor = AppColor.primary
|
||||
ruleButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
ruleButton.backgroundColor = AppColor.primaryLight
|
||||
ruleButton.layer.cornerRadius = 18
|
||||
ruleButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
||||
ruleButton.addTarget(self, action: #selector(ruleTapped), for: .touchUpInside)
|
||||
ruleButton.snp.makeConstraints { make in
|
||||
make.height.equalTo(36)
|
||||
}
|
||||
header.addArrangedSubview(headerIcon)
|
||||
header.addArrangedSubview(title)
|
||||
header.addArrangedSubview(UIView())
|
||||
header.addArrangedSubview(ruleButton)
|
||||
if viewModel.shouldShowRuleButton {
|
||||
let ruleButton = UIButton(type: .system)
|
||||
ruleButton.setTitle("举报规则", for: .normal)
|
||||
ruleButton.setImage(UIImage(systemName: "shield.lefthalf.filled"), for: .normal)
|
||||
ruleButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
|
||||
ruleButton.tintColor = AppColor.primary
|
||||
ruleButton.setTitleColor(AppColor.primary, for: .normal)
|
||||
ruleButton.backgroundColor = AppColor.primaryLight
|
||||
ruleButton.layer.cornerRadius = 18
|
||||
ruleButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
||||
ruleButton.addTarget(self, action: #selector(ruleTapped), for: .touchUpInside)
|
||||
ruleButton.snp.makeConstraints { make in
|
||||
make.height.equalTo(36)
|
||||
}
|
||||
header.addArrangedSubview(ruleButton)
|
||||
}
|
||||
|
||||
let items = [
|
||||
"需实名登录",
|
||||
"请尽量上传清晰证据",
|
||||
"恶意举报将被追责"
|
||||
]
|
||||
card.stack.addArrangedSubview(header)
|
||||
items.forEach { item in
|
||||
viewModel.noticeItems.forEach { item in
|
||||
card.stack.addArrangedSubview(makeBullet(item))
|
||||
}
|
||||
return card
|
||||
@ -206,7 +202,7 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
|
||||
let badges = UIStackView()
|
||||
badges.axis = .horizontal
|
||||
badges.distribution = .fillEqually
|
||||
badges.spacing = AppSpacing.sm
|
||||
badges.spacing = AppSpacing.xs
|
||||
[
|
||||
("实名", "身份核验 真实可靠", "person.crop.circle.badge.checkmark", AppColor.primary, AppColor.primaryLight),
|
||||
("安全", "数据加密 隐私保护", "checkmark.shield.fill", AppColor.success, AppColor.successBackground),
|
||||
@ -308,19 +304,14 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
|
||||
}
|
||||
|
||||
@objc private func mapTapped() {
|
||||
let record = viewModel.reports.first ?? WildPhotographerReportMockStore.seedReports[0]
|
||||
navigationController?.pushViewController(
|
||||
WildReportRiskMapViewController(record: record, riskPoints: viewModel.riskPoints),
|
||||
WildReportRiskMapViewController(),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func ruleTapped() {
|
||||
navigationController?.pushViewController(WildReportRuleViewController(), animated: true)
|
||||
}
|
||||
|
||||
@objc private func backTapped() {
|
||||
navigationController?.popViewController(animated: true)
|
||||
navigationController?.pushViewController(WildReportRuleViewController(ruleGroups: viewModel.ruleGroups), animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -455,7 +446,19 @@ private final class WildReportTrustItemView: UIView {
|
||||
|
||||
/// 举报规则页面。
|
||||
final class WildReportRuleViewController: BaseViewController {
|
||||
private let scrollView = UIScrollView()
|
||||
private let stack = UIStackView()
|
||||
private let ruleGroups: [WildReportRuleGroup]
|
||||
|
||||
init(ruleGroups: [WildReportRuleGroup]) {
|
||||
self.ruleGroups = ruleGroups
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "举报规则"
|
||||
@ -463,24 +466,32 @@ final class WildReportRuleViewController: BaseViewController {
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = AppColor.pageBackgroundSoft
|
||||
scrollView.alwaysBounceVertical = true
|
||||
scrollView.showsVerticalScrollIndicator = false
|
||||
stack.axis = .vertical
|
||||
stack.spacing = AppSpacing.sm
|
||||
view.addSubview(stack)
|
||||
addSection(title: "可举报行为", items: ["疑似打野摄影师主动揽客", "未佩戴工牌开展摄影服务", "引导游客线下付款或私下交易"])
|
||||
addSection(title: "材料要求", items: ["尽量提供现场图片、视频、位置和文字说明", "联系方式可选填,用于景区工作人员核实"])
|
||||
addSection(title: "处理说明", items: ["举报提交后景区公安会接收线索", "处理进度可在“我的举报”中查看", "请勿提交虚假或恶意举报"])
|
||||
view.addSubview(scrollView)
|
||||
scrollView.addSubview(stack)
|
||||
ruleGroups.forEach { group in
|
||||
addSection(title: group.title, items: group.items)
|
||||
}
|
||||
}
|
||||
|
||||
override func setupConstraints() {
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
stack.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md)
|
||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||
make.edges.equalTo(scrollView.contentLayoutGuide).inset(AppSpacing.md)
|
||||
make.width.equalTo(scrollView.frameLayoutGuide).offset(-AppSpacing.md * 2)
|
||||
}
|
||||
}
|
||||
|
||||
private func addSection(title: String, items: [String]) {
|
||||
let card = WildReportCardView(spacing: AppSpacing.sm)
|
||||
card.stack.addArrangedSubview(WildReportUI.label(title, font: .app(.title), color: AppColor.textPrimary))
|
||||
if !title.isEmpty {
|
||||
card.stack.addArrangedSubview(WildReportUI.label(title, font: .app(.title), color: AppColor.textPrimary))
|
||||
}
|
||||
items.forEach { item in
|
||||
card.stack.addArrangedSubview(WildReportUI.label("· \(item)", font: .app(.body), color: AppColor.textSecondary, lines: 0))
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
//
|
||||
|
||||
import Kingfisher
|
||||
import MAMapKit
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@ -928,7 +929,7 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
||||
}
|
||||
viewModel.paymentScreenshotItems.forEach { item in
|
||||
let tile = WildReportDetailMediaThumbnailView(reporterItem: item)
|
||||
tile.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reporterMediaTapped(_:))))
|
||||
tile.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(paymentMediaTapped(_:))))
|
||||
tile.snp.makeConstraints { make in
|
||||
make.size.equalTo(108)
|
||||
}
|
||||
@ -987,7 +988,8 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
||||
section.addArrangedSubview(makeLocationHeader())
|
||||
let mapPreview = WildReportDetailMapPreviewView(
|
||||
scenicName: viewModel.scenicAreaName,
|
||||
detailAddress: viewModel.detailAddress
|
||||
detailAddress: viewModel.detailAddress,
|
||||
coordinate: viewModel.mapCoordinate
|
||||
)
|
||||
section.addArrangedSubview(mapPreview)
|
||||
mapPreview.snp.makeConstraints { make in
|
||||
@ -1198,18 +1200,63 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
||||
}
|
||||
|
||||
@objc private func shareTapped() { viewModel.shareReport() }
|
||||
@objc private func previewTapped() { viewModel.showMediaPreview(kind: "图片/视频") }
|
||||
@objc private func paymentPreviewTapped() { viewModel.showMediaPreview(kind: "支付截图") }
|
||||
@objc private func previewTapped() { presentReporterMediaPreview(selectedKey: nil) }
|
||||
@objc private func paymentPreviewTapped() { presentPaymentMediaPreview(selectedKey: nil) }
|
||||
@objc private func locationTapped() { viewModel.showLocationPreview() }
|
||||
|
||||
@objc private func reporterMediaTapped(_ recognizer: UITapGestureRecognizer) {
|
||||
guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return }
|
||||
viewModel.showMediaPreview(kind: tile.previewKindText)
|
||||
presentReporterMediaPreview(selectedKey: tile.previewKey)
|
||||
}
|
||||
|
||||
@objc private func paymentMediaTapped(_ recognizer: UITapGestureRecognizer) {
|
||||
guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return }
|
||||
presentPaymentMediaPreview(selectedKey: tile.previewKey)
|
||||
}
|
||||
|
||||
@objc private func completionMediaTapped(_ recognizer: UITapGestureRecognizer) {
|
||||
guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return }
|
||||
viewModel.showMediaPreview(kind: tile.previewKindText)
|
||||
presentCompletionMediaPreview(selectedKey: tile.previewKey)
|
||||
}
|
||||
|
||||
private func presentReporterMediaPreview(selectedKey: String?) {
|
||||
presentMediaPreview(entries: previewEntries(for: viewModel.reporterMediaItems), selectedKey: selectedKey, emptyMessage: "当前举报材料暂无可预览文件")
|
||||
}
|
||||
|
||||
private func presentPaymentMediaPreview(selectedKey: String?) {
|
||||
presentMediaPreview(entries: previewEntries(for: viewModel.paymentScreenshotItems), selectedKey: selectedKey, emptyMessage: "当前支付截图暂无可预览文件")
|
||||
}
|
||||
|
||||
private func presentCompletionMediaPreview(selectedKey: String?) {
|
||||
let entries = viewModel.handlerInfo?.completionMedia.compactMap { item -> (key: String, item: MediaPreviewItem)? in
|
||||
guard let key = item.previewKey,
|
||||
let previewItem = MediaPreviewItem(wildReportCompletionItem: item)
|
||||
else { return nil }
|
||||
return (key, previewItem)
|
||||
} ?? []
|
||||
presentMediaPreview(entries: entries, selectedKey: selectedKey, emptyMessage: "当前处理凭证暂无可预览文件")
|
||||
}
|
||||
|
||||
private func previewEntries(for mediaItems: [WildReportReporterMediaItem]) -> [(key: String, item: MediaPreviewItem)] {
|
||||
mediaItems.compactMap { mediaItem in
|
||||
guard let key = mediaItem.previewKey,
|
||||
let previewItem = MediaPreviewItem(wildReportReporterItem: mediaItem)
|
||||
else { return nil }
|
||||
return (key, previewItem)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentMediaPreview(
|
||||
entries: [(key: String, item: MediaPreviewItem)],
|
||||
selectedKey: String?,
|
||||
emptyMessage: String
|
||||
) {
|
||||
guard !entries.isEmpty else {
|
||||
showToast(emptyMessage)
|
||||
return
|
||||
}
|
||||
let startIndex = selectedKey.flatMap { key in entries.firstIndex { $0.key == key } } ?? 0
|
||||
presentMediaPreview(items: entries.map(\.item), startIndex: startIndex)
|
||||
}
|
||||
|
||||
@objc private func supplementTapped() {
|
||||
@ -1320,15 +1367,57 @@ private final class WildReportDetailInfoRowView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
private extension MediaPreviewItem {
|
||||
init?(wildReportReporterItem item: WildReportReporterMediaItem) {
|
||||
guard let urlText = item.previewKey else { return nil }
|
||||
switch item {
|
||||
case .image:
|
||||
guard let previewItem = MediaPreviewItem.remoteImage(urlText) else { return nil }
|
||||
self = previewItem
|
||||
case .video:
|
||||
guard let previewItem = MediaPreviewItem.remoteVideo(urlText) else { return nil }
|
||||
self = previewItem
|
||||
}
|
||||
}
|
||||
|
||||
init?(wildReportCompletionItem item: WildReportCompletionMediaItem) {
|
||||
guard let urlText = item.previewKey else { return nil }
|
||||
switch item.kind {
|
||||
case .image:
|
||||
guard let previewItem = MediaPreviewItem.remoteImage(urlText) else { return nil }
|
||||
self = previewItem
|
||||
case .video:
|
||||
guard let previewItem = MediaPreviewItem.remoteVideo(urlText) else { return nil }
|
||||
self = previewItem
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension WildReportReporterMediaItem {
|
||||
var previewKey: String? {
|
||||
guard let text = fileURL?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil }
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
private extension WildReportCompletionMediaItem {
|
||||
var previewKey: String? {
|
||||
let text = coverURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报详情页材料缩略图,展示远程图片、视频和处理凭证。
|
||||
private final class WildReportDetailMediaThumbnailView: UIView {
|
||||
let previewKindText: String
|
||||
let previewKey: String?
|
||||
private let gradient = CAGradientLayer()
|
||||
private let remoteImageView = UIImageView()
|
||||
private let footerLabel = PaddingLabel(insets: UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8))
|
||||
private let playView = UIImageView(image: UIImage(systemName: "play.circle.fill"))
|
||||
|
||||
init(reporterItem: WildReportReporterMediaItem) {
|
||||
previewKey = reporterItem.previewKey
|
||||
switch reporterItem {
|
||||
case .image(let index, let url, let fileName, let fileTypeText):
|
||||
previewKindText = fileName?.isEmpty == false ? fileName! : "第\(index + 1)张举报图片"
|
||||
@ -1342,11 +1431,12 @@ private final class WildReportDetailMediaThumbnailView: UIView {
|
||||
}
|
||||
|
||||
init(completionItem: WildReportCompletionMediaItem) {
|
||||
previewKey = completionItem.previewKey
|
||||
switch completionItem.kind {
|
||||
case .image:
|
||||
previewKindText = "处理凭证图片"
|
||||
super.init(frame: .zero)
|
||||
configure(kind: .image(index: 0), footerText: "凭证")
|
||||
configure(kind: .image(index: 0), footerText: "凭证", imageURL: completionItem.coverURL)
|
||||
case .video:
|
||||
previewKindText = "处理凭证视频"
|
||||
super.init(frame: .zero)
|
||||
@ -1500,107 +1590,41 @@ private final class WildReportDetailMediaThumbnailView: UIView {
|
||||
|
||||
/// 举报详情页地图预览,绘制简化道路、路径和定位点。
|
||||
private final class WildReportDetailMapPreviewView: UIView {
|
||||
private let scenicName: String
|
||||
private let detailAddress: String
|
||||
private let detailLabel = UILabel()
|
||||
private let scenicLabel = UILabel()
|
||||
private let pinView = UIImageView(image: UIImage(systemName: "mappin.circle.fill"))
|
||||
private let mapView: MAMapView
|
||||
|
||||
init(scenicName: String, detailAddress: String) {
|
||||
self.scenicName = scenicName
|
||||
self.detailAddress = detailAddress
|
||||
init(scenicName: String, detailAddress: String, coordinate: CLLocationCoordinate2D?) {
|
||||
_ = AMapBootstrap.configureIfNeeded()
|
||||
mapView = MAMapView(frame: .zero)
|
||||
super.init(frame: .zero)
|
||||
backgroundColor = UIColor(hex: 0xE1F4D8)
|
||||
backgroundColor = AppColor.pageBackgroundSoft
|
||||
layer.cornerRadius = 10
|
||||
clipsToBounds = true
|
||||
|
||||
detailLabel.text = detailAddress.replacingOccurrences(of: "附近", with: "")
|
||||
detailLabel.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||
detailLabel.textColor = UIColor(hex: 0x64748B)
|
||||
detailLabel.textAlignment = .center
|
||||
|
||||
scenicLabel.text = scenicName
|
||||
scenicLabel.font = .systemFont(ofSize: 13, weight: .semibold)
|
||||
scenicLabel.textColor = UIColor(hex: 0x418C48)
|
||||
scenicLabel.textAlignment = .center
|
||||
|
||||
pinView.tintColor = AppColor.danger
|
||||
pinView.contentMode = .scaleAspectFit
|
||||
|
||||
addSubview(detailLabel)
|
||||
addSubview(scenicLabel)
|
||||
addSubview(pinView)
|
||||
detailLabel.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview().multipliedBy(0.94)
|
||||
make.centerY.equalToSuperview().multipliedBy(0.64)
|
||||
make.leading.greaterThanOrEqualToSuperview().offset(12)
|
||||
make.trailing.lessThanOrEqualToSuperview().inset(12)
|
||||
}
|
||||
scenicLabel.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview().multipliedBy(1.56)
|
||||
make.centerY.equalToSuperview().multipliedBy(1.16)
|
||||
make.leading.greaterThanOrEqualToSuperview().offset(12)
|
||||
make.trailing.lessThanOrEqualToSuperview().inset(12)
|
||||
}
|
||||
pinView.snp.makeConstraints { make in
|
||||
make.centerX.equalToSuperview().multipliedBy(1.06)
|
||||
make.centerY.equalToSuperview().multipliedBy(1.08)
|
||||
make.size.equalTo(36)
|
||||
mapView.showsCompass = false
|
||||
mapView.showsScale = false
|
||||
mapView.isScrollEnabled = false
|
||||
mapView.isZoomEnabled = false
|
||||
mapView.isRotateEnabled = false
|
||||
mapView.isRotateCameraEnabled = false
|
||||
mapView.zoomLevel = 16
|
||||
addSubview(mapView)
|
||||
mapView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview()
|
||||
}
|
||||
|
||||
guard let coordinate else { return }
|
||||
let annotation = MAPointAnnotation()
|
||||
annotation.coordinate = coordinate
|
||||
annotation.title = scenicName
|
||||
annotation.subtitle = detailAddress
|
||||
mapView.addAnnotation(annotation)
|
||||
mapView.setCenter(coordinate, animated: false)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
super.draw(rect)
|
||||
guard let context = UIGraphicsGetCurrentContext() else { return }
|
||||
|
||||
let colors = [UIColor(hex: 0xE1F4D8).cgColor, UIColor(hex: 0xBDE6C7).cgColor] as CFArray
|
||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||
if let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: [0, 1]) {
|
||||
context.drawLinearGradient(
|
||||
gradient,
|
||||
start: CGPoint(x: 0, y: 0),
|
||||
end: CGPoint(x: rect.width, y: rect.height),
|
||||
options: []
|
||||
)
|
||||
}
|
||||
|
||||
let dashedPath = UIBezierPath()
|
||||
dashedPath.move(to: CGPoint(x: rect.width * 0.05, y: rect.height * 0.32))
|
||||
dashedPath.addCurve(
|
||||
to: CGPoint(x: rect.width * 0.95, y: rect.height * 0.22),
|
||||
controlPoint1: CGPoint(x: rect.width * 0.28, y: rect.height * 0.62),
|
||||
controlPoint2: CGPoint(x: rect.width * 0.56, y: rect.height * 0.02)
|
||||
)
|
||||
UIColor(hex: 0x65A6D7).withAlphaComponent(0.75).setStroke()
|
||||
dashedPath.lineWidth = 1.4
|
||||
dashedPath.setLineDash([5, 5], count: 2, phase: 0)
|
||||
dashedPath.stroke()
|
||||
|
||||
let road = UIBezierPath()
|
||||
road.move(to: CGPoint(x: rect.width * 0.00, y: rect.height * 0.74))
|
||||
road.addCurve(
|
||||
to: CGPoint(x: rect.width * 1.00, y: rect.height * 0.62),
|
||||
controlPoint1: CGPoint(x: rect.width * 0.26, y: rect.height * 0.20),
|
||||
controlPoint2: CGPoint(x: rect.width * 0.68, y: rect.height * 1.00)
|
||||
)
|
||||
UIColor.white.withAlphaComponent(0.88).setStroke()
|
||||
road.lineWidth = 5
|
||||
road.stroke()
|
||||
UIColor(hex: 0x99C59C).setStroke()
|
||||
road.lineWidth = 1
|
||||
road.stroke()
|
||||
|
||||
let pinCenter = CGPoint(x: rect.width * 0.53, y: rect.height * 0.54)
|
||||
UIColor(hex: 0xEF4444).withAlphaComponent(0.18).setFill()
|
||||
context.fillEllipse(in: CGRect(x: pinCenter.x - 32, y: pinCenter.y - 32, width: 64, height: 64))
|
||||
UIColor(hex: 0xEF4444).withAlphaComponent(0.22).setFill()
|
||||
context.fillEllipse(in: CGRect(x: pinCenter.x - 22, y: pinCenter.y - 22, width: 44, height: 44))
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报详情页处理进度时间线单行。
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import QuickLook
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@ -20,7 +19,6 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
||||
private let contactField = UITextField()
|
||||
private let submitButton = UIButton(type: .system)
|
||||
private var pickerTarget: WildReportPickerTarget = .image
|
||||
private var previewURL: URL?
|
||||
private weak var activeInputView: UIView?
|
||||
|
||||
init(
|
||||
@ -213,7 +211,12 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
||||
grid.axis = .vertical
|
||||
grid.spacing = 8
|
||||
|
||||
let types = viewModel.reportTypes.isEmpty ? WildReportType.fallbackOptions : viewModel.reportTypes
|
||||
let types = viewModel.reportTypes
|
||||
if types.isEmpty, !viewModel.isLoadingReportTypes {
|
||||
let empty = WildReportUI.label("暂无可选举报类型,请稍后重试", font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
|
||||
card.stack.addArrangedSubview(empty)
|
||||
return card
|
||||
}
|
||||
stride(from: 0, to: types.count, by: 3).forEach { startIndex in
|
||||
let rowTypes = Array(types[startIndex ..< min(startIndex + 3, types.count)])
|
||||
let row = UIStackView()
|
||||
@ -223,7 +226,7 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
||||
|
||||
rowTypes.forEach { type in
|
||||
let button = WildReportTypeOptionButton()
|
||||
button.apply(type: type, isSelected: type == viewModel.selectedReportType)
|
||||
button.apply(type: type, isSelected: viewModel.selectedReportType == type)
|
||||
button.addAction(UIAction { [weak self] _ in self?.viewModel.selectReportType(type) }, for: .touchUpInside)
|
||||
button.snp.makeConstraints { make in make.height.equalTo(72) }
|
||||
row.addArrangedSubview(button)
|
||||
@ -537,14 +540,11 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
||||
}
|
||||
|
||||
private func previewAttachment(_ attachment: WildReportAttachment) {
|
||||
guard let url = attachment.localFileURL else {
|
||||
guard let item = MediaPreviewItem(wildReportAttachment: attachment) else {
|
||||
showToast("当前附件暂无本地预览")
|
||||
return
|
||||
}
|
||||
previewURL = url
|
||||
let preview = QLPreviewController()
|
||||
preview.dataSource = self
|
||||
present(preview, animated: true)
|
||||
presentMediaPreview(items: [item], startIndex: 0)
|
||||
}
|
||||
|
||||
@objc private func refreshLocationTapped() { viewModel.refreshCurrentLocation() }
|
||||
@ -631,16 +631,6 @@ extension WildPhotographerReportSubmitViewController: PHPickerViewControllerDele
|
||||
}
|
||||
}
|
||||
|
||||
extension WildPhotographerReportSubmitViewController: QLPreviewControllerDataSource {
|
||||
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
|
||||
previewURL == nil ? 0 : 1
|
||||
}
|
||||
|
||||
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
|
||||
previewURL! as NSURL
|
||||
}
|
||||
}
|
||||
|
||||
extension WildPhotographerReportSubmitViewController: UITextViewDelegate {
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
activeInputView = textView
|
||||
|
||||
@ -25,18 +25,16 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
private let detailContainerView = UIView()
|
||||
private let scrollView = UIScrollView()
|
||||
private let contentStack = UIStackView()
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||
private var annotationMap: [String: WildReportMapMarker] = [:]
|
||||
private var isShowingRiskMapLoading = false
|
||||
|
||||
init(
|
||||
record: WildReportRecord,
|
||||
riskPoints: [WildReportRiskPoint],
|
||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
|
||||
locationProvider: any LocationProviding = LocationProvider.shared,
|
||||
scenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId },
|
||||
scenicNameProvider: @escaping () -> String = { AppStore.shared.currentScenicName }
|
||||
) {
|
||||
self.viewModel = WildReportRiskMapViewModel(record: record, riskPoints: riskPoints)
|
||||
self.viewModel = WildReportRiskMapViewModel()
|
||||
self.api = api
|
||||
self.locationProvider = locationProvider
|
||||
self.scenicIdProvider = scenicIdProvider
|
||||
@ -61,6 +59,13 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
if isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
|
||||
setRiskMapLoadingVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "附近风险地图"
|
||||
}
|
||||
@ -184,6 +189,7 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
|
||||
@MainActor
|
||||
private func refreshContent(animated: Bool) {
|
||||
setRiskMapLoadingVisible(viewModel.isLoading)
|
||||
scenicNameLabel.text = viewModel.scenicAreaName
|
||||
syncMapAnnotations()
|
||||
mapView.setRegion(viewModel.region, animated: animated)
|
||||
@ -192,6 +198,13 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
rebuildPanel()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func setRiskMapLoadingVisible(_ isVisible: Bool) {
|
||||
guard isShowingRiskMapLoading != isVisible else { return }
|
||||
isShowingRiskMapLoading = isVisible
|
||||
isVisible ? showLoading() : hideLoading()
|
||||
}
|
||||
|
||||
private func syncMapAnnotations() {
|
||||
let oldAnnotations = mapView.annotations.compactMap { $0 as? WildReportMapAnnotation }
|
||||
mapView.removeAnnotations(oldAnnotations)
|
||||
@ -234,9 +247,6 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
|
||||
if viewModel.isLoading {
|
||||
contentStack.addArrangedSubview(makeLoadingView(text: "正在加载风险点位..."))
|
||||
}
|
||||
if let message = viewModel.errorMessage, !viewModel.isLoading {
|
||||
contentStack.addArrangedSubview(makeStateCard(icon: "exclamationmark.triangle.fill", text: message))
|
||||
}
|
||||
@ -401,15 +411,6 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
return row
|
||||
}
|
||||
|
||||
private func makeLoadingView(text: String) -> UIView {
|
||||
let card = WildReportCardView(spacing: 12, inset: 16)
|
||||
card.stack.alignment = .center
|
||||
loadingIndicator.startAnimating()
|
||||
card.stack.addArrangedSubview(loadingIndicator)
|
||||
card.stack.addArrangedSubview(WildReportUI.label(text, font: .systemFont(ofSize: 14), color: AppColor.textSecondary))
|
||||
return card
|
||||
}
|
||||
|
||||
private func makeInlineLoading(text: String) -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
@ -464,7 +465,7 @@ final class WildReportRiskMapViewController: BaseViewController {
|
||||
}
|
||||
|
||||
private func presentImagePreview(url: String) {
|
||||
present(WildReportRiskImagePreviewController(url: url), animated: true)
|
||||
presentImagePreview(imageURLs: [url], startIndex: 0)
|
||||
}
|
||||
|
||||
@objc private func navigateTapped() {
|
||||
@ -711,46 +712,3 @@ private final class WildReportRiskImageTile: UIView {
|
||||
onTap?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 风险地图图片预览页面。
|
||||
private final class WildReportRiskImagePreviewController: UIViewController {
|
||||
private let url: String
|
||||
private let imageView = UIImageView()
|
||||
|
||||
init(url: String) {
|
||||
self.url = url
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .fullScreen
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
imageView.contentMode = .scaleAspectFit
|
||||
view.addSubview(imageView)
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
if let imageURL = URL(string: url) {
|
||||
imageView.kf.setImage(with: imageURL)
|
||||
}
|
||||
let closeButton = UIButton(type: .system)
|
||||
closeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
|
||||
closeButton.tintColor = .white
|
||||
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||||
view.addSubview(closeButton)
|
||||
closeButton.snp.makeConstraints { make in
|
||||
make.top.trailing.equalTo(view.safeAreaLayoutGuide).inset(18)
|
||||
make.size.equalTo(36)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func closeTapped() {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import QuickLook
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
@ -23,7 +22,6 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
|
||||
private let submitButton = UIButton(type: .system)
|
||||
|
||||
private var pickerTarget: WildReportPickerTarget = .image
|
||||
private var previewURL: URL?
|
||||
private weak var activeInputView: UIView?
|
||||
|
||||
init(
|
||||
@ -322,14 +320,11 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
|
||||
}
|
||||
|
||||
private func previewAttachment(_ attachment: WildReportAttachment) {
|
||||
guard let url = attachment.localFileURL else {
|
||||
guard let item = MediaPreviewItem(wildReportAttachment: attachment) else {
|
||||
showToast("当前附件暂无本地预览")
|
||||
return
|
||||
}
|
||||
previewURL = url
|
||||
let preview = QLPreviewController()
|
||||
preview.dataSource = self
|
||||
present(preview, animated: true)
|
||||
presentMediaPreview(items: [item], startIndex: 0)
|
||||
}
|
||||
|
||||
@objc private func submitTapped() {
|
||||
@ -420,16 +415,6 @@ extension WildReportSupplementEvidenceViewController: PHPickerViewControllerDele
|
||||
}
|
||||
}
|
||||
|
||||
extension WildReportSupplementEvidenceViewController: QLPreviewControllerDataSource {
|
||||
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
|
||||
previewURL == nil ? 0 : 1
|
||||
}
|
||||
|
||||
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
|
||||
previewURL! as NSURL
|
||||
}
|
||||
}
|
||||
|
||||
extension WildReportSupplementEvidenceViewController: UITextViewDelegate {
|
||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
activeInputView = textView
|
||||
|
||||
Reference in New Issue
Block a user