// // WildPhotographerReportSubmitViewController.swift // suixinkan // import PhotosUI import SnapKit import UIKit /// 提交举报页面,提供类型、证据、说明、定位和联系方式表单。 final class WildPhotographerReportSubmitViewController: BaseViewController { private let viewModel: WildPhotographerReportSubmitViewModel private let homeViewModel: WildPhotographerReportHomeViewModel private let api: any WildPhotographerReportServing private let uploader: any WildReportAttachmentUploading private let scrollView = UIScrollView() private let contentStack = UIStackView() private let descriptionView = UITextView() private let contactField = UITextField() private let submitButton = UIButton(type: .system) private var pickerTarget: WildReportPickerTarget = .image private weak var activeInputView: UIView? init( homeViewModel: WildPhotographerReportHomeViewModel, locationProvider: any LocationProviding = LocationProvider.shared, api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI, uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService ) { self.homeViewModel = homeViewModel self.api = api self.uploader = uploader self.viewModel = WildPhotographerReportSubmitViewModel( homeViewModel: homeViewModel, locationProvider: locationProvider ) super.init(nibName: nil, bundle: nil) } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { NotificationCenter.default.removeObserver(self) } override func setupNavigationBar() { title = "提交举报" } override func setupUI() { view.backgroundColor = AppColor.pageBackgroundSoft scrollView.keyboardDismissMode = .onDrag scrollView.showsVerticalScrollIndicator = false contentStack.axis = .vertical contentStack.spacing = AppSpacing.sm descriptionView.font = .systemFont(ofSize: 15) descriptionView.textColor = AppColor.textPrimary descriptionView.backgroundColor = .clear descriptionView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8) descriptionView.delegate = self contactField.placeholder = "请输入摄影师微信号或手机号(选填)" contactField.font = .app(.body) contactField.textColor = AppColor.textPrimary contactField.backgroundColor = UIColor(hex: 0xFBFCFE) contactField.layer.cornerRadius = 10 contactField.layer.borderWidth = 1 contactField.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor contactField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) contactField.leftViewMode = .always contactField.delegate = self contactField.addTarget(self, action: #selector(contactChanged), for: .editingChanged) submitButton.setTitle("提交举报", for: .normal) submitButton.titleLabel?.font = .systemFont(ofSize: 22, weight: .bold) submitButton.setTitleColor(.white, for: .normal) submitButton.backgroundColor = AppColor.primary submitButton.layer.cornerRadius = 14 submitButton.layer.shadowColor = AppColor.primary.cgColor submitButton.layer.shadowOpacity = 0.22 submitButton.layer.shadowRadius = 12 submitButton.layer.shadowOffset = CGSize(width: 0, height: 6) submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) view.addSubview(scrollView) scrollView.addSubview(contentStack) let dismissTap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) dismissTap.cancelsTouchesInView = false dismissTap.delegate = self scrollView.addGestureRecognizer(dismissTap) registerKeyboardNotifications() rebuildContent() } override func setupConstraints() { scrollView.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.bottom.equalToSuperview() } contentStack.snp.makeConstraints { make in make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 14, left: 18, bottom: 26, right: 18)) make.width.equalTo(scrollView.frameLayoutGuide).offset(-36) } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.rebuildContent() } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } viewModel.onSubmitSuccess = { [weak self] result in Task { @MainActor in guard let self else { return } self.navigationController?.pushViewController( WildPhotographerReportSuccessViewController( result: result, homeViewModel: self.homeViewModel ), animated: true ) } } } override func viewDidLoad() { super.viewDidLoad() viewModel.loadInitialLocationIfNeeded() Task { await viewModel.loadReportTypes(api: api) } } @MainActor private func rebuildContent() { contentStack.arrangedSubviews.forEach { view in contentStack.removeArrangedSubview(view) view.removeFromSuperview() } contentStack.addArrangedSubview(makeRealNameBanner()) contentStack.addArrangedSubview(makeTypeCard()) contentStack.addArrangedSubview(makeEvidenceCard()) contentStack.addArrangedSubview(makeDescriptionCard()) contentStack.addArrangedSubview(makeLocationCard()) contentStack.addArrangedSubview(makeContactCard()) let paymentCard = makePaymentCard() contentStack.addArrangedSubview(paymentCard) contentStack.setCustomSpacing(16, after: paymentCard) contentStack.addArrangedSubview(submitButton) submitButton.setTitle(viewModel.isSubmitting ? viewModel.submitProgressText : "提交举报", for: .normal) submitButton.isEnabled = !viewModel.isSubmitting submitButton.alpha = viewModel.isSubmitting ? 0.78 : 1 submitButton.snp.remakeConstraints { make in make.height.equalTo(56) } if activeInputView?.isFirstResponder == true { scrollActiveInputIntoView(animated: false) } } private func makeRealNameBanner() -> UIView { let banner = UIView() banner.backgroundColor = UIColor(hex: 0xF6FAFF) banner.layer.cornerRadius = 16 banner.layer.borderWidth = 1 banner.layer.borderColor = UIColor(hex: 0xBFD7FF).cgColor let iconWrap = UIView() iconWrap.backgroundColor = AppColor.primary.withAlphaComponent(0.10) iconWrap.layer.cornerRadius = 17 let icon = UIImageView(image: UIImage(systemName: "checkmark.shield.fill")) icon.tintColor = AppColor.primary iconWrap.addSubview(icon) icon.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(22) } let message = UILabel() message.numberOfLines = 0 message.attributedText = realNameBannerText() banner.addSubview(iconWrap) banner.addSubview(message) iconWrap.snp.makeConstraints { make in make.top.leading.equalToSuperview().offset(16) make.size.equalTo(34) make.bottom.lessThanOrEqualToSuperview().inset(16) } message.snp.makeConstraints { make in make.top.trailing.bottom.equalToSuperview().inset(16) make.leading.equalTo(iconWrap.snp.trailing).offset(12) } return banner } private func makeTypeCard() -> UIView { let card = sectionCard(index: 1, title: "举报类型") if viewModel.isLoadingReportTypes { let loading = WildReportUI.label("正在同步举报类型...", font: .systemFont(ofSize: 13), color: AppColor.textSecondary) card.stack.addArrangedSubview(loading) } let grid = UIStackView() grid.axis = .vertical grid.spacing = 8 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() row.axis = .horizontal row.spacing = 8 row.distribution = .fillEqually rowTypes.forEach { type in let button = WildReportTypeOptionButton() 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) } for _ in rowTypes.count..<3 { row.addArrangedSubview(UIView()) } grid.addArrangedSubview(row) } card.stack.addArrangedSubview(grid) return card } private func makeEvidenceCard() -> UIView { let card = sectionCard(index: 2, title: "上传现场证据") card.stack.addArrangedSubview(makeEvidenceUploadBlock( icon: "photo.fill", title: "上传图片", subtitle: "支持 JPG、PNG 格式,最多 9 张", attachments: viewModel.images, maxCount: 9, kind: .image )) card.stack.addArrangedSubview(makeDivider()) card.stack.addArrangedSubview(makeEvidenceUploadBlock( icon: "video.fill", title: "上传视频", subtitle: "支持 MP4 格式,时长不超过 60 秒", attachments: viewModel.videos, maxCount: 3, kind: .video )) return card } private func makeDescriptionCard() -> UIView { let card = sectionCard(index: 3, title: "举报说明") descriptionView.text = viewModel.description let inputContainer = UIView() inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE) inputContainer.layer.cornerRadius = 12 inputContainer.layer.borderWidth = 1 inputContainer.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor let placeholder = UILabel() placeholder.text = "请描述摄影师的位置、特征、行为..." placeholder.font = .systemFont(ofSize: 15) placeholder.textColor = UIColor(hex: 0x9CA3AF) placeholder.isHidden = !viewModel.description.isEmpty let count = WildReportUI.label("\(viewModel.description.count)/500", font: .systemFont(ofSize: 14), color: UIColor(hex: 0x6B7280)) count.textAlignment = .right inputContainer.addSubview(descriptionView) inputContainer.addSubview(placeholder) inputContainer.addSubview(count) descriptionView.snp.makeConstraints { make in make.edges.equalToSuperview() make.height.equalTo(132) } placeholder.snp.makeConstraints { make in make.top.equalToSuperview().offset(18) make.leading.equalToSuperview().offset(14) make.trailing.lessThanOrEqualToSuperview().inset(14) } count.snp.makeConstraints { make in make.trailing.bottom.equalToSuperview().inset(12) } card.stack.addArrangedSubview(inputContainer) return card } private func makeLocationCard() -> UIView { let card = sectionCard(index: 4, title: "举报位置") let container = UIView() container.backgroundColor = UIColor(hex: 0xF6FAFF) container.layer.cornerRadius = 12 container.layer.borderWidth = 1 container.layer.borderColor = UIColor(hex: 0xC8DDFF).cgColor let iconWrap = UIView() iconWrap.backgroundColor = AppColor.primary.withAlphaComponent(0.10) iconWrap.layer.cornerRadius = 21 let icon = UIImageView(image: UIImage(systemName: "mappin.circle.fill")) icon.tintColor = AppColor.primary iconWrap.addSubview(icon) icon.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(30) } let textStack = UIStackView() textStack.axis = .vertical textStack.spacing = 5 let title = WildReportUI.label(viewModel.locationTitleText, font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textPrimary, lines: 2) title.adjustsFontSizeToFitWidth = true title.minimumScaleFactor = 0.78 let subtitle = WildReportUI.label(viewModel.locationSubtitleText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary, lines: 0) textStack.addArrangedSubview(title) textStack.addArrangedSubview(subtitle) let button = UIButton(type: .system) button.setTitle(viewModel.isUpdatingLocation ? "定位中" : "更新定位", for: .normal) button.titleLabel?.font = .systemFont(ofSize: 13, weight: .semibold) button.setTitleColor(AppColor.primary, for: .normal) button.backgroundColor = .white button.layer.cornerRadius = 17 button.layer.borderWidth = 1 button.layer.borderColor = AppColor.primary.cgColor button.isEnabled = !viewModel.isUpdatingLocation button.alpha = viewModel.isUpdatingLocation ? 0.6 : 1 button.addTarget(self, action: #selector(refreshLocationTapped), for: .touchUpInside) container.addSubview(iconWrap) container.addSubview(textStack) container.addSubview(button) iconWrap.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(12) make.size.equalTo(42) } button.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(12) make.centerY.equalToSuperview() make.width.equalTo(82) make.height.equalTo(34) } textStack.snp.makeConstraints { make in make.leading.equalTo(iconWrap.snp.trailing).offset(12) make.trailing.equalTo(button.snp.leading).offset(-8) make.centerY.equalToSuperview() make.top.greaterThanOrEqualToSuperview().offset(12) make.bottom.lessThanOrEqualToSuperview().inset(12) } card.stack.addArrangedSubview(container) return card } private func makeContactCard() -> UIView { let card = sectionCard(index: 5, title: "摄影师微信号/手机号", optional: true) contactField.text = viewModel.contact card.stack.addArrangedSubview(contactField) contactField.snp.makeConstraints { make in make.height.equalTo(48) } return card } private func makePaymentCard() -> UIView { let card = sectionCard(index: 6, title: "线下微信、支付宝截图", optional: true) card.stack.addArrangedSubview(makeEvidenceUploadBlock( icon: "creditcard.fill", title: "上传支付截图", subtitle: "支持 JPG、PNG 格式,最多 3 张", attachments: viewModel.paymentImages, maxCount: 3, kind: .payment )) return card } private func sectionCard(index: Int, title: String, optional: Bool = false) -> WildReportCardView { let card = WildReportCardView(spacing: 18, inset: 16) card.layer.cornerRadius = 16 card.stack.addArrangedSubview(WildReportSectionTitleView(index: index, title: title, optional: optional)) return card } private func makeEvidenceUploadBlock( icon: String, title: String, subtitle: String, attachments: [WildReportAttachment], maxCount: Int, kind: WildReportAttachmentKind ) -> UIView { let block = UIStackView() block.axis = .vertical block.spacing = 12 let header = UIStackView() header.axis = .horizontal header.alignment = .center header.spacing = 12 let iconWrap = UIView() iconWrap.backgroundColor = AppColor.primaryLight iconWrap.layer.cornerRadius = 12 let image = UIImageView(image: UIImage(systemName: icon)) image.tintColor = AppColor.primary iconWrap.addSubview(image) image.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(22) } iconWrap.snp.makeConstraints { make in make.size.equalTo(42) } let textStack = UIStackView() textStack.axis = .vertical textStack.spacing = 3 textStack.addArrangedSubview(WildReportUI.label(title, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary)) textStack.addArrangedSubview(WildReportUI.label(subtitle, font: .systemFont(ofSize: 13), color: AppColor.textSecondary, lines: 0)) header.addArrangedSubview(iconWrap) header.addArrangedSubview(textStack) header.addArrangedSubview(UIView()) block.addArrangedSubview(header) let scroll = UIScrollView() scroll.keyboardDismissMode = .onDrag scroll.showsHorizontalScrollIndicator = false let tiles = UIStackView() tiles.axis = .horizontal tiles.spacing = 10 scroll.addSubview(tiles) tiles.snp.makeConstraints { make in make.edges.equalTo(scroll.contentLayoutGuide).inset(UIEdgeInsets(top: 2, left: 0, bottom: 2, right: 0)) make.height.equalTo(scroll.frameLayoutGuide).offset(-4) } attachments.forEach { attachment in tiles.addArrangedSubview(WildReportAttachmentTileView( attachment: attachment, onPreview: { [weak self] in self?.previewAttachment(attachment) }, onRemove: { [weak self] in self?.viewModel.removeAttachment(attachment) } )) } let addCount = availableAddTileCount(kind: kind, attachments: attachments, maxCount: maxCount) for _ in 0.. UIView { let divider = UIView() divider.backgroundColor = AppColor.border divider.snp.makeConstraints { make in make.height.equalTo(1) } return divider } private func realNameBannerText() -> NSAttributedString { let text = NSMutableAttributedString() [ ("实名登录", AppColor.primary, UIFont.systemFont(ofSize: 16, weight: .bold)), ("后提交,", AppColor.textPrimary, UIFont.systemFont(ofSize: 16, weight: .medium)), ("景区公安", AppColor.primary, UIFont.systemFont(ofSize: 16, weight: .bold)), ("将接收并处理", AppColor.textPrimary, UIFont.systemFont(ofSize: 16, weight: .medium)) ].forEach { value, color, font in text.append(NSAttributedString(string: value, attributes: [ .foregroundColor: color, .font: font ])) } let paragraph = NSMutableParagraphStyle() paragraph.lineSpacing = 4 text.addAttribute(.paragraphStyle, value: paragraph, range: NSRange(location: 0, length: text.length)) return text } private func presentMediaPicker(for target: WildReportPickerTarget) { dismissKeyboard() let remaining = remainingSelectionCount(for: target) guard remaining > 0 else { showToast(target.limitMessage) return } pickerTarget = target var configuration = PHPickerConfiguration(photoLibrary: .shared()) configuration.selectionLimit = remaining configuration.filter = target == .video ? .videos : .images let picker = PHPickerViewController(configuration: configuration) picker.delegate = self present(picker, animated: true) } private func remainingSelectionCount(for target: WildReportPickerTarget) -> Int { switch target { case .image: return max(0, 9 - viewModel.images.count - viewModel.videos.count) case .video: return max(0, min(3 - viewModel.videos.count, 9 - viewModel.images.count - viewModel.videos.count)) case .payment: return max(0, 3 - viewModel.paymentImages.count) } } private func availableAddTileCount( kind: WildReportAttachmentKind, attachments: [WildReportAttachment], maxCount: Int ) -> Int { let ownRemaining = max(0, maxCount - attachments.count) let evidenceRemaining = max(0, 9 - viewModel.images.count - viewModel.videos.count) switch kind { case .image: return min(ownRemaining, evidenceRemaining) > 0 ? 1 : 0 case .video: return min(ownRemaining, evidenceRemaining) > 0 ? 1 : 0 case .payment: return ownRemaining > 0 ? 1 : 0 } } private func previewAttachment(_ attachment: WildReportAttachment) { guard let item = MediaPreviewItem(wildReportAttachment: attachment) else { showToast("当前附件暂无本地预览") return } presentMediaPreview(items: [item], startIndex: 0) } @objc private func refreshLocationTapped() { viewModel.refreshCurrentLocation() } @objc private func submitTapped() { Task { await viewModel.submitReport( api: api, uploader: uploader, scenicId: AppStore.shared.currentScenicId, scenicName: AppStore.shared.currentScenicName ) } } @objc private func contactChanged() { viewModel.updateContact(contactField.text ?? "") } @objc private func dismissKeyboard() { view.endEditing(true) } private func registerKeyboardNotifications() { NotificationCenter.default.addObserver( self, selector: #selector(keyboardWillChangeFrame(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil ) } @objc private func keyboardWillChangeFrame(_ notification: Notification) { guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } let keyboardFrameInView = view.convert(keyboardFrame, from: nil) let overlap = max(0, view.bounds.maxY - keyboardFrameInView.minY) updateScrollInsets(bottom: overlap + 16, notification: notification) scrollActiveInputIntoView(animated: true) } @objc private func keyboardWillHide(_ notification: Notification) { updateScrollInsets(bottom: 0, notification: notification) } private func updateScrollInsets(bottom: CGFloat, notification: Notification) { let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25 let curveRaw = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt ?? UIView.AnimationOptions.curveEaseInOut.rawValue let options = UIView.AnimationOptions(rawValue: curveRaw << 16) UIView.animate(withDuration: duration, delay: 0, options: options) { self.scrollView.contentInset.bottom = bottom self.scrollView.verticalScrollIndicatorInsets.bottom = bottom } } private func scrollActiveInputIntoView(animated: Bool) { guard let activeInputView else { return } view.layoutIfNeeded() let rect = activeInputView.convert(activeInputView.bounds, to: scrollView).insetBy(dx: 0, dy: -24) scrollView.scrollRectToVisible(rect, animated: animated) } } extension WildPhotographerReportSubmitViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard !results.isEmpty else { return } let target = pickerTarget Task { do { let attachments = try await WildReportLocalMediaLoader.load(results: results, target: target) await MainActor.run { switch target { case .image: viewModel.addImages(attachments) case .video: viewModel.addVideos(attachments) case .payment: viewModel.addPaymentImages(attachments) } } } catch { await MainActor.run { showToast(error.localizedDescription) } } } } } extension WildPhotographerReportSubmitViewController: UITextViewDelegate { func textViewDidBeginEditing(_ textView: UITextView) { activeInputView = textView DispatchQueue.main.async { [weak self] in self?.scrollActiveInputIntoView(animated: true) } } func textViewDidChange(_ textView: UITextView) { viewModel.updateDescription(textView.text) } func textViewDidEndEditing(_ textView: UITextView) { if activeInputView === textView { activeInputView = nil } } } extension WildPhotographerReportSubmitViewController: UITextFieldDelegate { func textFieldDidBeginEditing(_ textField: UITextField) { activeInputView = textField DispatchQueue.main.async { [weak self] in self?.scrollActiveInputIntoView(animated: true) } } func textFieldDidEndEditing(_ textField: UITextField) { if activeInputView === textField { activeInputView = nil } } } extension WildPhotographerReportSubmitViewController: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { var touchedView = touch.view while let currentView = touchedView { if currentView is UIControl || currentView is UITextView || currentView is UITextField { return false } touchedView = currentView.superview } return true } } /// 提交页步骤标题,复刻参考页左侧蓝色竖条与选填标识。 private final class WildReportSectionTitleView: UIView { init(index: Int, title: String, optional: Bool) { super.init(frame: .zero) let bar = UIView() bar.backgroundColor = AppColor.primary bar.layer.cornerRadius = 2 let label = UILabel() label.font = .systemFont(ofSize: 17, weight: .bold) label.textColor = AppColor.textPrimary label.text = "\(index). \(title)" let optionalLabel = UILabel() optionalLabel.font = .systemFont(ofSize: 14) optionalLabel.textColor = AppColor.textSecondary optionalLabel.text = "(选填)" optionalLabel.isHidden = !optional addSubview(bar) addSubview(label) addSubview(optionalLabel) bar.snp.makeConstraints { make in make.leading.equalToSuperview() make.centerY.equalToSuperview() make.width.equalTo(4) make.height.equalTo(20) } label.snp.makeConstraints { make in make.leading.equalTo(bar.snp.trailing).offset(8) make.top.bottom.equalToSuperview() } optionalLabel.snp.makeConstraints { make in make.leading.equalTo(label.snp.trailing) make.centerY.equalTo(label) make.trailing.lessThanOrEqualToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 提交页举报类型三列选择按钮。 private final class WildReportTypeOptionButton: UIControl { private let titleLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) layer.cornerRadius = 12 layer.borderWidth = 1 titleLabel.font = .systemFont(ofSize: 15, weight: .bold) titleLabel.textAlignment = .center titleLabel.adjustsFontSizeToFitWidth = true titleLabel.minimumScaleFactor = 0.8 titleLabel.numberOfLines = 2 titleLabel.lineBreakMode = .byTruncatingTail titleLabel.isUserInteractionEnabled = false addSubview(titleLabel) titleLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.trailing.equalToSuperview().inset(6) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(type: WildReportType, isSelected: Bool) { titleLabel.text = type.label backgroundColor = isSelected ? AppColor.primary : UIColor(hex: 0xF6FAFF) layer.borderColor = (isSelected ? AppColor.primary : UIColor(hex: 0xC8DDFF)).cgColor titleLabel.textColor = isSelected ? .white : AppColor.textPrimary } } /// 举报成功页面,展示编号、上传摘要和处理进度。 final class WildPhotographerReportSuccessViewController: BaseViewController { private let viewModel: WildPhotographerReportSuccessViewModel private let homeViewModel: WildPhotographerReportHomeViewModel private let scrollView = UIScrollView() private let stack = UIStackView() init(result: WildReportSubmitResult, homeViewModel: WildPhotographerReportHomeViewModel) { self.viewModel = WildPhotographerReportSuccessViewModel(result: result) self.homeViewModel = homeViewModel 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: 0xF8FAFF) scrollView.showsVerticalScrollIndicator = false stack.axis = .vertical stack.spacing = 12 view.addSubview(scrollView) scrollView.addSubview(stack) stack.addArrangedSubview(WildReportSuccessHeroView()) stack.addArrangedSubview(WildReportSuccessProgressView(steps: viewModel.progressSteps)) let detailCard = WildReportSuccessDetailCardView( reportID: viewModel.result.record.id, reportType: viewModel.result.record.reportType.label, location: viewModel.result.record.location, uploadSummary: viewModel.uploadSummary ) detailCard.onCopyReportID = { [weak self] in guard let self else { return } _ = viewModel.copyReportID() showToast("举报编号已复制") } stack.addArrangedSubview(detailCard) let detailButton = makeActionButton(title: "查看处理进度", style: .primary) detailButton.addTarget(self, action: #selector(detailTapped), for: .touchUpInside) let homeButton = makeActionButton(title: "返回首页", style: .secondary) homeButton.addTarget(self, action: #selector(homeTapped), for: .touchUpInside) stack.addArrangedSubview(detailButton) stack.addArrangedSubview(homeButton) } override func setupConstraints() { scrollView.snp.makeConstraints { make in make.edges.equalTo(view.safeAreaLayoutGuide) } stack.snp.makeConstraints { make in make.top.equalToSuperview().offset(14) make.leading.trailing.equalTo(scrollView.frameLayoutGuide).inset(18) make.bottom.equalToSuperview().inset(28) make.width.equalTo(scrollView.frameLayoutGuide).offset(-36) } } private func makeActionButton(title: String, style: WildReportSuccessActionButtonStyle) -> UIButton { let button = UIButton(type: .system) button.titleLabel?.font = .systemFont(ofSize: 20, weight: .bold) button.setTitle(title, for: .normal) button.layer.cornerRadius = 14 button.layer.borderWidth = style == .secondary ? 1.2 : 0 button.layer.borderColor = AppColor.primary.cgColor button.backgroundColor = style == .primary ? AppColor.primary : .white button.setTitleColor(style == .primary ? .white : AppColor.primary, for: .normal) button.snp.makeConstraints { make in make.height.equalTo(54) } return button } @objc private func detailTapped() { navigationController?.pushViewController( WildPhotographerReportDetailViewController(record: viewModel.result.record, homeViewModel: homeViewModel), animated: true ) } @objc private func homeTapped() { navigationController?.popToViewController( navigationController?.viewControllers.first(where: { $0 is WildPhotographerReportHomeViewController }) ?? self, animated: true ) } } /// 举报成功页底部操作按钮样式。 private enum WildReportSuccessActionButtonStyle { case primary case secondary } /// 举报成功页顶部渐变成功反馈区。 private final class WildReportSuccessHeroView: UIView { private let gradientLayer = CAGradientLayer() private let stack = UIStackView() override init(frame: CGRect) { super.init(frame: frame) setupUI() setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() gradientLayer.frame = bounds gradientLayer.cornerRadius = 18 } private func setupUI() { layer.cornerRadius = 18 layer.borderWidth = 1 layer.borderColor = UIColor(hex: 0xBFD7FF).cgColor layer.masksToBounds = true gradientLayer.colors = [ UIColor.white.cgColor, UIColor(hex: 0xEEF6FF).cgColor ] gradientLayer.startPoint = CGPoint(x: 0, y: 0) gradientLayer.endPoint = CGPoint(x: 1, y: 1) layer.insertSublayer(gradientLayer, at: 0) stack.axis = .vertical stack.alignment = .center stack.spacing = 14 let iconView = WildReportSuccessCheckmarkView() let titleLabel = WildReportUI.label("举报已提交", font: .systemFont(ofSize: 24, weight: .heavy), color: AppColor.textPrimary) let descriptionLabel = WildReportUI.label( "景区公安已收到线索,将尽快前往现场核实处理", font: .systemFont(ofSize: 14), color: AppColor.textSecondary, lines: 0 ) descriptionLabel.textAlignment = .center addSubview(stack) stack.addArrangedSubview(iconView) stack.addArrangedSubview(titleLabel) stack.addArrangedSubview(descriptionLabel) iconView.snp.makeConstraints { make in make.size.equalTo(78) } descriptionLabel.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(16) } } private func setupConstraints() { stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 24, left: 16, bottom: 24, right: 16)) } } } /// 举报成功页圆形对勾图标。 private final class WildReportSuccessCheckmarkView: UIView { private let outerCircle = UIView() private let innerCircle = UIView() private let iconView = UIImageView(image: UIImage(systemName: "checkmark")) override init(frame: CGRect) { super.init(frame: frame) setupUI() setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { outerCircle.backgroundColor = AppColor.primary.withAlphaComponent(0.12) outerCircle.layer.cornerRadius = 39 innerCircle.backgroundColor = AppColor.primary innerCircle.layer.cornerRadius = 29 iconView.tintColor = .white iconView.contentMode = .scaleAspectFit iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 28, weight: .bold) addSubview(outerCircle) addSubview(innerCircle) addSubview(iconView) } private func setupConstraints() { outerCircle.snp.makeConstraints { make in make.edges.equalToSuperview() } innerCircle.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(58) } iconView.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(28) } } } /// 举报成功页横向处理进度卡片。 private final class WildReportSuccessProgressView: UIView { private let stack = UIStackView() private let steps: [String] init(steps: [String]) { self.steps = steps super.init(frame: .zero) setupUI() setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { backgroundColor = .white layer.cornerRadius = 16 layer.borderWidth = 1 layer.borderColor = AppColor.border.cgColor stack.axis = .horizontal stack.alignment = .top stack.spacing = 0 addSubview(stack) for (index, step) in steps.enumerated() { let item = WildReportSuccessProgressItemView(title: step, isCompleted: index == 0) stack.addArrangedSubview(item) item.snp.makeConstraints { make in make.width.equalTo(74) } if index < steps.count - 1 { let connector = WildReportSuccessProgressConnectorView(isCompleted: index == 0) stack.addArrangedSubview(connector) } } } private func setupConstraints() { stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 12, bottom: 16, right: 12)) } } } /// 举报成功页单个进度节点。 private final class WildReportSuccessProgressItemView: UIView { private let nodeView: WildReportSuccessProgressNodeView private let titleLabel = UILabel() init(title: String, isCompleted: Bool) { self.nodeView = WildReportSuccessProgressNodeView(isCompleted: isCompleted) super.init(frame: .zero) setupUI(title: title, isCompleted: isCompleted) setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI(title: String, isCompleted: Bool) { titleLabel.text = title titleLabel.font = .systemFont(ofSize: 11, weight: isCompleted ? .semibold : .regular) titleLabel.textColor = isCompleted ? AppColor.primary : UIColor(hex: 0x9CA3AF) titleLabel.textAlignment = .center titleLabel.numberOfLines = 2 addSubview(nodeView) addSubview(titleLabel) } private func setupConstraints() { nodeView.snp.makeConstraints { make in make.top.centerX.equalToSuperview() make.size.equalTo(24) } titleLabel.snp.makeConstraints { make in make.top.equalTo(nodeView.snp.bottom).offset(8) make.leading.trailing.bottom.equalToSuperview() } } } /// 举报成功页进度节点圆点。 private final class WildReportSuccessProgressNodeView: UIView { private let innerCircle = UIView() private let checkmarkView = UIImageView(image: UIImage(systemName: "checkmark")) private let isCompleted: Bool init(isCompleted: Bool) { self.isCompleted = isCompleted super.init(frame: .zero) setupUI() setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { layer.cornerRadius = 12 layer.borderWidth = 2 layer.borderColor = (isCompleted ? AppColor.primary : UIColor(hex: 0xD1D5DB)).cgColor innerCircle.backgroundColor = isCompleted ? AppColor.primary : .clear innerCircle.layer.cornerRadius = 10 checkmarkView.tintColor = .white checkmarkView.contentMode = .scaleAspectFit checkmarkView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 10, weight: .bold) checkmarkView.isHidden = !isCompleted addSubview(innerCircle) addSubview(checkmarkView) } private func setupConstraints() { innerCircle.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(20) } checkmarkView.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(10) } } } /// 举报成功页进度节点连接线。 private final class WildReportSuccessProgressConnectorView: UIView { private let isCompleted: Bool private let solidLine = UIView() private let dotsStack = UIStackView() init(isCompleted: Bool) { self.isCompleted = isCompleted super.init(frame: .zero) setupUI() setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI() { if isCompleted { solidLine.backgroundColor = AppColor.primary addSubview(solidLine) } else { dotsStack.axis = .horizontal dotsStack.alignment = .center dotsStack.distribution = .equalSpacing dotsStack.spacing = 3 addSubview(dotsStack) for _ in 0..<5 { let dot = UIView() dot.backgroundColor = UIColor(hex: 0xD1D5DB) dot.layer.cornerRadius = 1.5 dot.snp.makeConstraints { make in make.size.equalTo(3) } dotsStack.addArrangedSubview(dot) } } } private func setupConstraints() { snp.makeConstraints { make in make.height.equalTo(24) } if isCompleted { solidLine.snp.makeConstraints { make in make.leading.trailing.equalToSuperview() make.centerY.equalToSuperview() make.height.equalTo(2) } } else { dotsStack.snp.makeConstraints { make in make.centerY.equalToSuperview() make.leading.trailing.equalToSuperview() } } } } /// 举报成功页详情信息卡。 private final class WildReportSuccessDetailCardView: UIView { var onCopyReportID: (() -> Void)? private let stack = UIStackView() init(reportID: String, reportType: String, location: String, uploadSummary: String) { super.init(frame: .zero) setupUI(reportID: reportID, reportType: reportType, location: location, uploadSummary: uploadSummary) setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI(reportID: String, reportType: String, location: String, uploadSummary: String) { backgroundColor = .white layer.cornerRadius = 16 layer.borderWidth = 1 layer.borderColor = AppColor.border.cgColor stack.axis = .vertical stack.spacing = 0 addSubview(stack) appendRow(icon: "doc.text.fill", title: "举报编号", value: reportID, valueColor: AppColor.primary, showsCopy: true) appendDivider() appendRow(icon: "tag.fill", title: "举报类型", value: reportType) appendDivider() appendRow(icon: "mappin.circle.fill", title: "举报位置", value: location) appendDivider() appendRow(icon: "photo.on.rectangle.angled", title: "已上传", value: uploadSummary) } private func setupConstraints() { stack.snp.makeConstraints { make in make.edges.equalToSuperview() } } private func appendRow(icon: String, title: String, value: String, valueColor: UIColor = AppColor.textPrimary, showsCopy: Bool = false) { let row = WildReportSuccessDetailRowView( icon: icon, title: title, value: value, valueColor: valueColor, showsCopy: showsCopy ) row.onCopy = { [weak self] in self?.onCopyReportID?() } stack.addArrangedSubview(row) } private func appendDivider() { let container = UIView() let divider = UIView() divider.backgroundColor = AppColor.border container.addSubview(divider) divider.snp.makeConstraints { make in make.top.bottom.trailing.equalToSuperview() make.leading.equalToSuperview().offset(56) make.height.equalTo(1) } stack.addArrangedSubview(container) } } /// 举报成功页详情信息行。 private final class WildReportSuccessDetailRowView: UIView { var onCopy: (() -> Void)? private let iconContainer = UIView() private let iconView = UIImageView() private let titleLabel = UILabel() private let valueLabel = UILabel() private let copyButton = UIButton(type: .system) private let showsCopy: Bool init(icon: String, title: String, value: String, valueColor: UIColor, showsCopy: Bool) { self.showsCopy = showsCopy super.init(frame: .zero) setupUI(icon: icon, title: title, value: value, valueColor: valueColor) setupConstraints() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func setupUI(icon: String, title: String, value: String, valueColor: UIColor) { iconContainer.backgroundColor = AppColor.primaryLight iconContainer.layer.cornerRadius = 10 iconView.image = UIImage(systemName: icon) iconView.tintColor = AppColor.primary iconView.contentMode = .scaleAspectFit iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 22, weight: .semibold) titleLabel.text = title titleLabel.font = .systemFont(ofSize: 13) titleLabel.textColor = AppColor.textSecondary valueLabel.text = value valueLabel.font = .systemFont(ofSize: 16, weight: .semibold) valueLabel.textColor = valueColor valueLabel.numberOfLines = 2 valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) addSubview(iconContainer) iconContainer.addSubview(iconView) addSubview(titleLabel) addSubview(valueLabel) guard showsCopy else { return } copyButton.setImage(UIImage(systemName: "doc.on.doc"), for: .normal) copyButton.tintColor = AppColor.primary copyButton.backgroundColor = AppColor.primaryLight copyButton.layer.cornerRadius = 17 copyButton.accessibilityLabel = "复制举报编号" copyButton.addTarget(self, action: #selector(copyTapped), for: .touchUpInside) addSubview(copyButton) } private func setupConstraints() { iconContainer.snp.makeConstraints { make in make.leading.equalToSuperview().offset(16) make.centerY.equalToSuperview() make.size.equalTo(42) make.top.greaterThanOrEqualToSuperview().offset(14) make.bottom.lessThanOrEqualToSuperview().inset(14) } iconView.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(22) } titleLabel.snp.makeConstraints { make in make.top.equalToSuperview().offset(14) make.leading.equalTo(iconContainer.snp.trailing).offset(14) make.trailing.lessThanOrEqualToSuperview().inset(16) } valueLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(4) make.leading.equalTo(titleLabel) make.bottom.equalToSuperview().inset(14) if showsCopy { make.trailing.lessThanOrEqualTo(copyButton.snp.leading).offset(-8) } else { make.trailing.equalToSuperview().inset(16) } } guard showsCopy else { return } copyButton.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(16) make.centerY.equalToSuperview() make.size.equalTo(34) } } @objc private func copyTapped() { onCopy?() } }