Files
suixinkan_uikit/suixinkan/UI/PunchPoint/PunchPointDetailViewController.swift
汉秋 afcd412f11 完善样片管理列表、详情与上传页 UI 交互。
对齐 Android 样片模块布局与加载状态,补充共享 UI 组件与图片资源,并增强列表解析容错与单元测试覆盖。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 15:00:18 +08:00

574 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// PunchPointDetailViewController.swift
// suixinkan
//
import Kingfisher
import Photos
import SnapKit
import UIKit
/// Android `PunchPointDetailScreen`
final class PunchPointDetailViewController: BaseViewController {
private let viewModel: PunchPointDetailViewModel
private let api: any PunchPointAPIProtocol
private let mapView = PunchPointMapView()
private let zoomStack = UIStackView()
private let zoomInButton = PunchPointMapControlButton(symbol: "plus")
private let zoomOutButton = PunchPointMapControlButton(symbol: "minus")
private let locateButton = PunchPointMapControlButton(symbol: "location.fill")
private let cardView = UIView()
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let bottomBar = UIView()
private let editButton = UIButton(type: .system)
private let emptyContainer = UIStackView()
private let emptyLabel = UILabel()
private let retryButton = UIButton(type: .system)
var onChanged: (() -> Void)?
///
init(
punchPointId: Int64,
viewModel: PunchPointDetailViewModel? = nil,
api: (any PunchPointAPIProtocol)? = nil
) {
self.viewModel = viewModel ?? PunchPointDetailViewModel(punchPointId: punchPointId)
self.api = api ?? NetworkServices.shared.punchPointAPI
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
zoomStack.axis = .vertical
zoomStack.spacing = 0
zoomStack.addArrangedSubview(zoomInButton)
zoomStack.addArrangedSubview(zoomOutButton)
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
cardView.clipsToBounds = true
scrollView.showsVerticalScrollIndicator = false
contentStack.axis = .vertical
contentStack.spacing = 12
bottomBar.backgroundColor = .white
editButton.setTitle("去编辑", for: .normal)
editButton.setTitleColor(.white, for: .normal)
editButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
editButton.backgroundColor = AppColor.primary
editButton.layer.cornerRadius = 12
emptyContainer.axis = .vertical
emptyContainer.alignment = .center
emptyContainer.spacing = 12
emptyLabel.text = "暂未获取到打卡点信息"
emptyLabel.textColor = AppColor.textSecondary
emptyLabel.font = .systemFont(ofSize: 15)
retryButton.setTitle("重新加载", for: .normal)
retryButton.setTitleColor(.white, for: .normal)
retryButton.backgroundColor = AppColor.primary
retryButton.layer.cornerRadius = 8
emptyContainer.addArrangedSubview(emptyLabel)
emptyContainer.addArrangedSubview(retryButton)
emptyContainer.isHidden = true
view.addSubview(mapView)
view.addSubview(zoomStack)
view.addSubview(locateButton)
view.addSubview(cardView)
cardView.addSubview(scrollView)
scrollView.addSubview(contentStack)
view.addSubview(bottomBar)
bottomBar.addSubview(editButton)
view.addSubview(emptyContainer)
}
override func setupConstraints() {
mapView.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.height.equalToSuperview().multipliedBy(0.5)
}
zoomStack.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(cardView.snp.top).offset(-16)
}
locateButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(zoomStack.snp.top).offset(-12)
make.size.equalTo(40)
}
zoomInButton.snp.makeConstraints { make in
make.size.equalTo(40)
}
zoomOutButton.snp.makeConstraints { make in
make.size.equalTo(40)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
editButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(16)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-20)
}
cardView.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(bottomBar.snp.top)
make.height.lessThanOrEqualToSuperview().multipliedBy(0.45)
make.top.greaterThanOrEqualTo(mapView.snp.top).offset(60)
}
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 12, bottom: 12, right: 12))
make.width.equalTo(scrollView.snp.width).offset(-24)
}
emptyContainer.snp.makeConstraints { make in
make.center.equalToSuperview()
}
retryButton.snp.makeConstraints { make in
make.width.equalTo(118)
make.height.equalTo(40)
}
}
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) }
}
zoomInButton.addTarget(self, action: #selector(zoomInTapped), for: .touchUpInside)
zoomOutButton.addTarget(self, action: #selector(zoomOutTapped), for: .touchUpInside)
locateButton.addTarget(self, action: #selector(locateTapped), for: .touchUpInside)
retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside)
editButton.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.load(api: api) }
}
@MainActor
private func applyViewModel() {
viewModel.isLoading && viewModel.detail == nil ? showLoading() : hideLoading()
emptyContainer.isHidden = viewModel.detail != nil || viewModel.isLoading
guard let detail = viewModel.detail else {
cardView.isHidden = true
bottomBar.isHidden = true
return
}
cardView.isHidden = false
bottomBar.isHidden = false
if let region = detail.region {
let coordinate = CLLocationCoordinate2D(latitude: region.lat, longitude: region.lot)
mapView.updateMarker(coordinate: coordinate)
mapView.setCenter(coordinate, zoomLevel: 16)
}
rebuildContent(detail)
}
private func rebuildContent(_ detail: PunchPointDetail) {
contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点名称", value: detail.name))
let coordinateText = detail.region.map { "\($0.lat),\($0.lot)" } ?? "--"
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点坐标", value: coordinateText))
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "*打卡点地址", value: detail.displayAddress.nonEmptyTrimmed ?? "--"))
if let description = detail.description?.nonEmptyTrimmed {
contentStack.addArrangedSubview(PunchPointReadOnlyField(title: "打卡点描述", value: description, multiline: true))
}
contentStack.addArrangedSubview(makeImageSection(detail.guideImages))
contentStack.addArrangedSubview(PunchPointRowField(title: "负责人", value: detail.creator?.nonEmptyTrimmed ?? "--"))
contentStack.addArrangedSubview(PunchPointDivider())
contentStack.addArrangedSubview(PunchPointRowField(title: "负责人手机号", value: PunchPointDisplayFormatter.maskedPhone(detail.creatorPhone)))
contentStack.addArrangedSubview(PunchPointDivider())
contentStack.addArrangedSubview(makeStatusRow(detail))
contentStack.addArrangedSubview(PunchPointDivider())
contentStack.addArrangedSubview(PunchPointRowField(title: "审核人", value: detail.auditor?.nonEmptyTrimmed ?? "--"))
contentStack.addArrangedSubview(PunchPointDivider())
contentStack.addArrangedSubview(PunchPointRowField(title: "审核时间", value: detail.auditTime?.nonEmptyTrimmed ?? "--"))
contentStack.addArrangedSubview(PunchPointDivider())
contentStack.addArrangedSubview(PunchPointRowField(title: "审核备注", value: detail.auditRemark?.nonEmptyTrimmed ?? "--"))
contentStack.addArrangedSubview(makeQRSection(detail.mpQrcode))
}
private func makeImageSection(_ urls: [String]) -> UIView {
let section = UIStackView()
section.axis = .vertical
section.spacing = 8
let title = UILabel()
title.attributedText = requiredTitle("*上传图片 (最多9张)")
section.addArrangedSubview(title)
guard !urls.isEmpty else {
let empty = UILabel()
empty.text = "暂无图片"
empty.font = .systemFont(ofSize: 12)
empty.textColor = AppColor.textSecondary
section.addArrangedSubview(empty)
return section
}
let scroll = UIScrollView()
scroll.showsHorizontalScrollIndicator = false
let row = UIStackView()
row.axis = .horizontal
row.spacing = 12
scroll.addSubview(row)
row.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(90)
}
urls.enumerated().forEach { index, urlText in
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 10
imageView.isUserInteractionEnabled = true
imageView.tag = index
if let url = URL(string: urlText) {
imageView.kf.setImage(with: url)
}
imageView.snp.makeConstraints { make in
make.width.equalTo(120)
}
imageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imageTapped(_:))))
row.addArrangedSubview(imageView)
}
scroll.snp.makeConstraints { make in
make.height.equalTo(90)
}
section.addArrangedSubview(scroll)
return section
}
private func makeStatusRow(_ detail: PunchPointDetail) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.distribution = .equalSpacing
let label = UILabel()
label.text = "审核状态"
label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0x2E3746)
let chip = PunchPointStatusChip()
chip.apply(text: detail.statusLabel.nonEmptyTrimmed ?? "--", status: detail.status)
row.addArrangedSubview(label)
row.addArrangedSubview(chip)
row.isLayoutMarginsRelativeArrangement = true
row.layoutMargins = UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0)
return row
}
private func makeQRSection(_ qrCode: String?) -> UIView {
let section = UIStackView()
section.axis = .vertical
section.alignment = .center
section.spacing = 12
let title = UILabel()
title.text = "打卡点二维码"
title.font = .systemFont(ofSize: 14)
title.textColor = UIColor(hex: 0x2E3746)
title.textAlignment = .left
section.addArrangedSubview(title)
title.snp.makeConstraints { make in
make.width.equalTo(section.snp.width)
}
guard let qrCode = qrCode?.nonEmptyTrimmed else {
let empty = UILabel()
empty.text = "暂无二维码"
empty.font = .systemFont(ofSize: 12)
empty.textColor = AppColor.textSecondary
section.addArrangedSubview(empty)
return section
}
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 16
if let url = URL(string: qrCode) {
imageView.kf.setImage(with: url)
}
imageView.snp.makeConstraints { make in
make.size.equalTo(180)
}
let download = UIButton(type: .system)
download.setTitle("下载二维码", for: .normal)
download.setTitleColor(AppColor.primary, for: .normal)
download.titleLabel?.font = .systemFont(ofSize: 14)
download.backgroundColor = AppColor.primaryLight
download.layer.cornerRadius = 8
download.snp.makeConstraints { make in
make.height.equalTo(38)
make.width.equalTo(128)
}
download.addAction(UIAction { [weak self] _ in
self?.downloadQRCode(qrCode)
}, for: .touchUpInside)
section.addArrangedSubview(imageView)
section.addArrangedSubview(download)
return section
}
private func downloadQRCode(_ urlText: String) {
guard let url = URL(string: urlText) else {
showToast("二维码地址无效")
return
}
Task {
do {
let (data, _) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else { throw APIError.decodeFailed("二维码图片无效") }
try await PHPhotoLibrary.shared().performChanges {
PHAssetChangeRequest.creationRequestForAsset(from: image)
}
await MainActor.run { self.showToast("已保存到相册") }
} catch {
await MainActor.run { self.showToast(error.localizedDescription.isEmpty ? "保存失败" : error.localizedDescription) }
}
}
}
private func requiredTitle(_ text: String) -> NSAttributedString {
let mutable = NSMutableAttributedString(string: text, attributes: [
.font: UIFont.systemFont(ofSize: 14, weight: .medium),
.foregroundColor: UIColor(hex: 0x2E3746),
])
if text.hasPrefix("*") {
mutable.addAttribute(.foregroundColor, value: AppColor.danger, range: NSRange(location: 0, length: 1))
}
return mutable
}
@objc private func imageTapped(_ gesture: UITapGestureRecognizer) {
guard let imageView = gesture.view, let urls = viewModel.detail?.guideImages else { return }
presentImagePreview(imageURLs: urls, startIndex: imageView.tag)
}
@objc private func zoomInTapped() {
mapView.zoomIn()
}
@objc private func zoomOutTapped() {
mapView.zoomOut()
}
@objc private func locateTapped() {
mapView.centerOnUserLocation()
}
@objc private func retryTapped() {
Task { await viewModel.load(api: api) }
}
@objc private func editTapped() {
guard let detail = viewModel.detail else { return }
let controller = PunchPointFormViewController(mode: .edit(id: detail.id), initialDetail: detail)
controller.onSubmitSuccess = { [weak self] in
guard let self else { return }
self.onChanged?()
Task { await self.viewModel.load(api: self.api) }
}
navigationController?.pushViewController(controller, animated: true)
}
}
///
/// marker
final class PunchPointMapView: UIView, MAMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
private let mapView: MAMapView
private var markerAnnotation: MAPointAnnotation?
override init(frame: CGRect) {
_ = AMapBootstrap.configureIfNeeded()
mapView = MAMapView(frame: .zero)
super.init(frame: frame)
mapView.delegate = self
mapView.showsUserLocation = true
mapView.showsCompass = false
mapView.showsScale = false
mapView.zoomLevel = 15
addSubview(mapView)
mapView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func updateMarker(coordinate: CLLocationCoordinate2D?) {
if let markerAnnotation {
mapView.removeAnnotation(markerAnnotation)
self.markerAnnotation = nil
}
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
let annotation = MAPointAnnotation()
annotation.coordinate = coordinate
annotation.title = "打卡点位置"
markerAnnotation = annotation
mapView.addAnnotation(annotation)
}
///
func setCenter(_ coordinate: CLLocationCoordinate2D, zoomLevel: Double = 15, animated: Bool = true) {
mapView.setCenter(coordinate, animated: animated)
mapView.setZoomLevel(zoomLevel, animated: animated)
}
///
func zoomIn() {
mapView.setZoomLevel(mapView.zoomLevel + 1, animated: true)
}
///
func zoomOut() {
mapView.setZoomLevel(mapView.zoomLevel - 1, animated: true)
}
///
func centerOnUserLocation() {
guard let coordinate = mapView.userLocation.location?.coordinate,
CLLocationCoordinate2DIsValid(coordinate),
coordinate.latitude != 0 || coordinate.longitude != 0 else { return }
setCenter(coordinate)
}
func mapView(_ mapView: MAMapView!, didSingleTappedAt coordinate: CLLocationCoordinate2D) {
onMapTap?(coordinate)
}
}
///
///
final class PunchPointMapControlButton: UIButton {
init(symbol: String) {
super.init(frame: .zero)
setImage(UIImage(systemName: symbol), for: .normal)
tintColor = AppColor.textPrimary
backgroundColor = .white
layer.cornerRadius = 12
clipsToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
///
final class PunchPointReadOnlyField: UIView {
init(title: String, value: String, multiline: Bool = false) {
super.init(frame: .zero)
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 8
let titleLabel = UILabel()
titleLabel.attributedText = Self.requiredTitle(title)
let valueLabel = UILabel()
valueLabel.text = value.nonEmptyTrimmed ?? "--"
valueLabel.font = .systemFont(ofSize: 14, weight: .medium)
valueLabel.textColor = AppColor.textPrimary
valueLabel.numberOfLines = multiline ? 0 : 2
stack.addArrangedSubview(titleLabel)
stack.addArrangedSubview(valueLabel)
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private static func requiredTitle(_ text: String) -> NSAttributedString {
let mutable = NSMutableAttributedString(string: text, attributes: [
.font: UIFont.systemFont(ofSize: 14, weight: .medium),
.foregroundColor: UIColor(hex: 0x2E3746),
])
if text.hasPrefix("*") {
mutable.addAttribute(.foregroundColor, value: AppColor.danger, range: NSRange(location: 0, length: 1))
}
return mutable
}
}
///
///
final class PunchPointRowField: UIView {
init(title: String, value: String) {
super.init(frame: .zero)
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x2E3746)
let valueLabel = UILabel()
valueLabel.text = value.nonEmptyTrimmed ?? "--"
valueLabel.font = .systemFont(ofSize: 14, weight: .medium)
valueLabel.textColor = .black
valueLabel.textAlignment = .right
valueLabel.numberOfLines = 2
addSubview(titleLabel)
addSubview(valueLabel)
titleLabel.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 0, bottom: 12, right: 0))
}
valueLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
make.trailing.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// 线
/// 线
final class PunchPointDivider: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(hex: 0xF0F0F0)
snp.makeConstraints { make in
make.height.equalTo(1)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private extension String {
var nonEmptyTrimmed: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}