// // 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 } }