// // WildPhotographerReportListViewController.swift // suixinkan // import Kingfisher import MAMapKit import SnapKit import UIKit /// 我的举报列表页,支持按状态筛选并进入举报详情。 final class WildPhotographerReportListViewController: BaseViewController { private let viewModel: WildPhotographerReportListViewModel private let api: any WildPhotographerReportServing private let scrollView = UIScrollView() private let stack = UIStackView() private let filterTabBar = WildReportFilterTabBarView() init( homeViewModel: WildPhotographerReportHomeViewModel, api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI ) { self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel) 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.pageBackgroundSoft scrollView.showsVerticalScrollIndicator = false scrollView.alwaysBounceVertical = true scrollView.delegate = self stack.axis = .vertical stack.spacing = 14 filterTabBar.onSelect = { [weak self] filter in guard let self else { return } Task { await self.viewModel.selectFilter( filter, api: self.api, scenicId: self.currentScenicId ) } } view.addSubview(filterTabBar) view.addSubview(scrollView) scrollView.addSubview(stack) reloadContent(animated: false) } override func setupConstraints() { filterTabBar.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(14) make.leading.trailing.equalToSuperview().inset(18) } scrollView.snp.makeConstraints { make in make.top.equalTo(filterTabBar.snp.bottom).offset(14) make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide) } stack.snp.makeConstraints { make in make.top.equalTo(scrollView.contentLayoutGuide) make.leading.equalTo(scrollView.contentLayoutGuide).offset(18) make.trailing.equalTo(scrollView.contentLayoutGuide).inset(18) make.bottom.equalTo(scrollView.contentLayoutGuide).inset(24) make.width.equalTo(scrollView.frameLayoutGuide).offset(-36) } } override func viewDidLoad() { super.viewDidLoad() Task { await viewModel.loadInitial(api: api, scenicId: currentScenicId) } } override func bindActions() { viewModel.onStateChange = { [weak self] in Task { @MainActor in self?.reloadContent(animated: true) } } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } } @MainActor private func reloadContent(animated: Bool) { let changes: () -> Void = { [weak self] in self?.rebuildContent() } guard animated else { changes() return } UIView.transition( with: stack, duration: 0.22, options: [.transitionCrossDissolve, .allowUserInteraction], animations: changes ) } private func rebuildContent() { filterTabBar.apply(selectedFilter: viewModel.selectedFilter) stack.arrangedSubviews.forEach { view in stack.removeArrangedSubview(view) view.removeFromSuperview() } let reports = viewModel.filteredReports if reports.isEmpty { stack.addArrangedSubview(viewModel.isLoading ? WildReportListLoadingView(text: "正在加载举报记录...") : WildReportListEmptyView()) } else { reports.forEach { record in let card = WildReportListCardView(record: record) card.onOpen = { [weak self] in self?.openDetail(record) } card.onShare = { [weak self] in self?.shareReport(record) } stack.addArrangedSubview(card) } } let footer = WildReportUI.label(viewModel.footerDisplayText, font: .app(.caption), color: UIColor(hex: 0x9CA3AF), lines: 0) footer.textAlignment = .center let footerWrap = UIView() footerWrap.addSubview(footer) footer.snp.makeConstraints { make in make.top.equalToSuperview().offset(8) make.leading.trailing.bottom.equalToSuperview() } stack.addArrangedSubview(footerWrap) } private func openDetail(_ record: WildReportRecord) { navigationController?.pushViewController( WildPhotographerReportDetailViewController(record: record, homeViewModel: viewModel.homeViewModel, api: api), animated: true ) } private func shareReport(_ record: WildReportRecord) { showLoading() Task { [weak self] in guard let self else { return } let payload = await self.viewModel.shareReport(record, api: self.api) await MainActor.run { self.hideLoading() guard let payload else { return } WeChatShareService.shareMiniProgram(payload) { [weak self] result in self?.showWildReportShareResult(result) } } } } private var currentScenicId: Int? { let scenicId = AppStore.shared.currentScenicId return scenicId > 0 ? scenicId : nil } } extension WildPhotographerReportListViewController: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { let threshold: CGFloat = 80 let visibleBottom = scrollView.contentOffset.y + scrollView.bounds.height let triggerY = scrollView.contentSize.height - threshold guard visibleBottom >= triggerY, scrollView.contentSize.height > scrollView.bounds.height else { return } Task { await viewModel.loadMoreIfNeeded(api: api, scenicId: currentScenicId) } } } /// 我的举报筛选栏,使用四段等宽胶囊按钮展示状态过滤。 private final class WildReportFilterTabBarView: UIView { var onSelect: ((WildReportListFilter) -> Void)? private let stack = UIStackView() private var buttons: [WildReportListFilter: UIButton] = [:] override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .white layer.cornerRadius = 12 layer.borderWidth = 1 layer.borderColor = AppColor.cardOutline.cgColor stack.axis = .horizontal stack.spacing = 0 stack.distribution = .fillEqually addSubview(stack) stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(4) } WildReportListFilter.allCases.forEach { filter in let button = UIButton(type: .system) button.setTitle(filter.rawValue, for: .normal) button.titleLabel?.adjustsFontSizeToFitWidth = true button.titleLabel?.minimumScaleFactor = 0.85 button.layer.cornerRadius = 8 button.addTarget(self, action: #selector(filterTapped(_:)), for: .touchUpInside) buttons[filter] = button stack.addArrangedSubview(button) } snp.makeConstraints { make in make.height.equalTo(48) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func apply(selectedFilter: WildReportListFilter) { buttons.forEach { filter, button in let selected = filter == selectedFilter button.backgroundColor = selected ? UIColor(hex: 0xEAF3FF) : .clear button.setTitleColor(selected ? AppColor.primary : UIColor(hex: 0x6B7280), for: .normal) button.titleLabel?.font = .systemFont(ofSize: 13, weight: selected ? .bold : .medium) } } @objc private func filterTapped(_ sender: UIButton) { guard let filter = buttons.first(where: { $0.value == sender })?.key else { return } onSelect?(filter) } } /// 我的举报列表卡片,展示一条举报记录并提供详情与分享入口。 private final class WildReportListCardView: UIControl, UIGestureRecognizerDelegate { var onOpen: (() -> Void)? var onShare: (() -> Void)? private let record: WildReportRecord private let scenicName: String private let detailAddress: String private let statusColor: UIColor init(record: WildReportRecord) { self.record = record let location = WildReportLocationParser.split(record.location) self.scenicName = location.scenic self.detailAddress = location.detail self.statusColor = record.status.displayColor super.init(frame: .zero) setupUI() } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override var isHighlighted: Bool { didSet { alpha = isHighlighted ? 0.82 : 1 } } private func setupUI() { backgroundColor = .white layer.cornerRadius = 16 layer.borderWidth = 1 layer.borderColor = AppColor.cardOutline.cgColor let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(openTapped)) tapRecognizer.delegate = self tapRecognizer.cancelsTouchesInView = false addGestureRecognizer(tapRecognizer) let contentStack = UIStackView() contentStack.axis = .vertical contentStack.spacing = 13 addSubview(contentStack) contentStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(15) } contentStack.addArrangedSubview(makeHeader()) contentStack.addArrangedSubview(makeMetaRows()) contentStack.addArrangedSubview(makeLocationRow()) } private func makeHeader() -> UIView { let row = UIStackView() row.axis = .horizontal row.alignment = .top row.spacing = 12 let iconBox = UIView() iconBox.backgroundColor = statusColor.withAlphaComponent(0.12) iconBox.layer.cornerRadius = 12 let icon = UIImageView(image: UIImage(systemName: "camera.fill")) icon.tintColor = statusColor icon.contentMode = .scaleAspectFit iconBox.addSubview(icon) icon.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(20) } iconBox.snp.makeConstraints { make in make.size.equalTo(44) } row.addArrangedSubview(iconBox) let textStack = UIStackView() textStack.axis = .vertical textStack.spacing = 8 let titleStatusRow = UIStackView() titleStatusRow.axis = .horizontal titleStatusRow.alignment = .top titleStatusRow.spacing = 8 let titleLabel = WildReportUI.label(record.title, font: .app(.title), color: AppColor.textPrimary, lines: 2) titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) titleStatusRow.addArrangedSubview(titleLabel) titleStatusRow.addArrangedSubview(makeStatusAndShareGroup()) textStack.addArrangedSubview(titleStatusRow) textStack.addArrangedSubview(makeScenicPill()) row.addArrangedSubview(textStack) return row } private func makeStatusAndShareGroup() -> UIView { let group = UIStackView() group.axis = .horizontal group.alignment = .center group.spacing = 6 group.setContentHuggingPriority(.required, for: .horizontal) let status = PaddingLabel(insets: UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)) status.text = record.status.rawValue status.font = .systemFont(ofSize: 11, weight: .semibold) status.textColor = statusColor status.textAlignment = .center status.backgroundColor = statusColor.withAlphaComponent(0.12) status.layer.cornerRadius = 12 status.clipsToBounds = true status.setContentHuggingPriority(.required, for: .horizontal) status.setContentCompressionResistancePriority(.required, for: .horizontal) status.snp.makeConstraints { make in make.height.equalTo(24) } let shareButton = UIButton(type: .system) shareButton.backgroundColor = AppColor.primaryLight shareButton.layer.cornerRadius = 14 shareButton.tintColor = AppColor.primary shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal) shareButton.accessibilityLabel = "分享举报" shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside) shareButton.snp.makeConstraints { make in make.size.equalTo(28) } group.addArrangedSubview(status) group.addArrangedSubview(shareButton) return group } private func makeScenicPill() -> UIView { let wrapper = UIView() let label = PaddingLabel(insets: UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)) label.text = scenicName label.font = .systemFont(ofSize: 12, weight: .semibold) label.textColor = AppColor.primary label.backgroundColor = AppColor.primary.withAlphaComponent(0.10) label.layer.cornerRadius = 11 label.clipsToBounds = true label.setContentHuggingPriority(.required, for: .horizontal) label.setContentCompressionResistancePriority(.required, for: .horizontal) wrapper.addSubview(label) label.snp.makeConstraints { make in make.top.bottom.leading.equalToSuperview() make.trailing.lessThanOrEqualToSuperview() make.height.equalTo(22) } return wrapper } private func makeMetaRows() -> UIView { let stack = UIStackView() stack.axis = .vertical stack.spacing = 8 stack.addArrangedSubview(WildReportListMetaRowView(title: "举报编号", value: record.id)) stack.addArrangedSubview(WildReportListMetaRowView(title: "举报时间", value: record.submitTime)) return stack } private func makeLocationRow() -> UIView { let row = UIStackView() row.axis = .horizontal row.alignment = .center row.spacing = 6 row.layoutMargins = UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0) row.isLayoutMarginsRelativeArrangement = true let icon = UIImageView(image: UIImage(systemName: "mappin.and.ellipse")) icon.tintColor = UIColor(hex: 0x9CA3AF) icon.contentMode = .scaleAspectFit icon.snp.makeConstraints { make in make.size.equalTo(14) } let addressLabel = WildReportUI.label(detailAddress, font: .app(.caption), color: AppColor.textSecondary) addressLabel.lineBreakMode = .byTruncatingTail let chevron = UIImageView(image: UIImage(systemName: "chevron.right")) chevron.tintColor = UIColor(hex: 0xC4CAD4) chevron.contentMode = .scaleAspectFit chevron.snp.makeConstraints { make in make.size.equalTo(13) } row.addArrangedSubview(icon) row.addArrangedSubview(addressLabel) row.addArrangedSubview(UIView()) row.addArrangedSubview(chevron) return row } @objc private func openTapped() { onOpen?() } @objc private func shareTapped() { onShare?() } func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { var currentView: UIView? = touch.view while let view = currentView { if view is UIButton { return false } currentView = view.superview } return true } } /// 我的举报列表信息行,展示举报编号、举报时间等左右结构内容。 private final class WildReportListMetaRowView: UIView { init(title: String, value: String) { super.init(frame: .zero) let titleLabel = WildReportUI.label(title, font: .app(.caption), color: UIColor(hex: 0x9CA3AF)) let valueLabel = WildReportUI.label(value, font: .systemFont(ofSize: 13, weight: .medium), color: AppColor.textPrimary) valueLabel.textAlignment = .right valueLabel.adjustsFontSizeToFitWidth = true valueLabel.minimumScaleFactor = 0.8 addSubview(titleLabel) addSubview(valueLabel) titleLabel.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview() } valueLabel.snp.makeConstraints { make in make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12) make.trailing.top.bottom.equalToSuperview() } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 我的举报列表空态卡片。 private final class WildReportListEmptyView: UIView { override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .white layer.cornerRadius = 16 let stack = UIStackView() stack.axis = .vertical stack.alignment = .center stack.spacing = 10 let icon = UIImageView(image: UIImage(systemName: "doc.text.magnifyingglass")) icon.tintColor = UIColor(hex: 0xB6BECA) icon.contentMode = .scaleAspectFit icon.snp.makeConstraints { make in make.size.equalTo(36) } let label = WildReportUI.label("暂无相关举报记录", font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textSecondary) stack.addArrangedSubview(icon) stack.addArrangedSubview(label) addSubview(stack) stack.snp.makeConstraints { make in make.top.bottom.equalToSuperview().inset(48) make.leading.trailing.equalToSuperview().inset(16) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 我的举报列表加载态卡片。 private final class WildReportListLoadingView: UIView { init(text: String) { super.init(frame: .zero) backgroundColor = .white layer.cornerRadius = 16 let indicator = UIActivityIndicatorView(style: .medium) indicator.color = AppColor.primary indicator.startAnimating() let label = WildReportUI.label(text, font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textSecondary) let stack = UIStackView(arrangedSubviews: [indicator, label]) stack.axis = .vertical stack.alignment = .center stack.spacing = 12 addSubview(stack) stack.snp.makeConstraints { make in make.top.bottom.equalToSuperview().inset(48) make.leading.trailing.equalToSuperview().inset(16) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 举报详情页。 final class WildPhotographerReportDetailViewController: BaseViewController { private let viewModel: WildPhotographerReportDetailViewModel private let homeViewModel: WildPhotographerReportHomeViewModel private let api: any WildPhotographerReportServing private let scrollView = UIScrollView() private let stack = UIStackView() private let bottomBar = UIView() private let bottomButtonRow = UIStackView() init( record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel, api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI ) { self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel) self.homeViewModel = homeViewModel 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.pageBackgroundSoft scrollView.showsVerticalScrollIndicator = false scrollView.alwaysBounceVertical = true stack.axis = .vertical stack.spacing = 12 bottomBar.backgroundColor = UIColor.white.withAlphaComponent(0.96) bottomButtonRow.axis = .horizontal bottomButtonRow.alignment = .fill bottomButtonRow.distribution = .fillEqually bottomButtonRow.spacing = 12 let supplementButton = makeBottomButton(title: "补充证据", iconName: "doc.badge.plus", isPrimary: false) supplementButton.addTarget(self, action: #selector(supplementTapped), for: .touchUpInside) let mapButton = makeBottomButton(title: "查看附近风险地图", iconName: "map.fill", isPrimary: true) mapButton.addTarget(self, action: #selector(mapTapped), for: .touchUpInside) bottomButtonRow.addArrangedSubview(supplementButton) bottomButtonRow.addArrangedSubview(mapButton) view.addSubview(scrollView) view.addSubview(bottomBar) scrollView.addSubview(stack) bottomBar.addSubview(bottomButtonRow) let topBorder = UIView() topBorder.backgroundColor = AppColor.cardOutline bottomBar.addSubview(topBorder) topBorder.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview() make.height.equalTo(0.5) } rebuildContent() } override func setupConstraints() { scrollView.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.bottom.equalToSuperview() } stack.snp.makeConstraints { make in make.top.equalTo(scrollView.contentLayoutGuide).offset(14) make.leading.equalTo(scrollView.contentLayoutGuide).offset(18) make.trailing.equalTo(scrollView.contentLayoutGuide).inset(18) make.bottom.equalTo(scrollView.contentLayoutGuide).inset(120) make.width.equalTo(scrollView.frameLayoutGuide).offset(-36) } bottomBar.snp.makeConstraints { make in make.leading.trailing.bottom.equalToSuperview() } bottomButtonRow.snp.makeConstraints { make in make.top.equalToSuperview().offset(10) make.leading.trailing.equalToSuperview().inset(16) make.bottom.equalTo(view.safeAreaLayoutGuide).inset(12) make.height.equalTo(52) } } override func viewDidLoad() { super.viewDidLoad() Task { await viewModel.loadDetailIfNeeded(api: api) } } 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) } } homeViewModel.onStateChange = { [weak self] in Task { @MainActor in self?.rebuildContent() } } } @MainActor private func rebuildContent() { stack.arrangedSubviews.forEach { view in stack.removeArrangedSubview(view) view.removeFromSuperview() } stack.addArrangedSubview(makeSummaryCard()) stack.addArrangedSubview(makeReportInfoCard()) if viewModel.showHandlerInfo, let info = viewModel.handlerInfo { stack.addArrangedSubview(makeHandlerCard(info: info)) } stack.addArrangedSubview(makeEvidenceCard()) if !viewModel.supplementaryEvidences.isEmpty { stack.addArrangedSubview(makeSupplementCard()) } stack.addArrangedSubview(makeTimelineCard()) } private func makeSummaryCard() -> UIView { let card = WildReportCardView(spacing: 20, inset: 18) card.layer.cornerRadius = 18 let topRow = UIStackView() topRow.axis = .horizontal topRow.alignment = .center topRow.spacing = 14 let iconBox = UIView() iconBox.backgroundColor = AppColor.primary iconBox.layer.cornerRadius = 16 let icon = UIImageView(image: UIImage(systemName: "exclamationmark.shield.fill")) icon.tintColor = .white icon.contentMode = .scaleAspectFit iconBox.addSubview(icon) icon.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(30) } iconBox.snp.makeConstraints { make in make.size.equalTo(62) } let titleStack = UIStackView() titleStack.axis = .vertical titleStack.spacing = 8 let titleLabel = WildReportUI.label(viewModel.heroTitle, font: .systemFont(ofSize: 23, weight: .heavy), color: AppColor.textPrimary, lines: 1) titleLabel.adjustsFontSizeToFitWidth = true titleLabel.minimumScaleFactor = 0.82 titleStack.addArrangedSubview(titleLabel) titleStack.addArrangedSubview(makeScenicPill()) let shareButton = WildReportDetailIconTextButton(title: "分享", iconName: "square.and.arrow.up") shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside) topRow.addArrangedSubview(iconBox) topRow.addArrangedSubview(titleStack) topRow.addArrangedSubview(shareButton) titleStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) shareButton.snp.makeConstraints { make in make.size.equalTo(48) } card.stack.addArrangedSubview(topRow) card.stack.addArrangedSubview(makeDivider()) let bottomRow = UIStackView() bottomRow.axis = .horizontal bottomRow.alignment = .center bottomRow.spacing = 12 let idStack = UIStackView() idStack.axis = .vertical idStack.spacing = 4 idStack.addArrangedSubview(WildReportUI.label("举报编号", font: .app(.body), color: AppColor.textSecondary)) idStack.addArrangedSubview(WildReportUI.label(viewModel.record.id, font: .systemFont(ofSize: 19, weight: .semibold), color: AppColor.textPrimary)) let statusLabel = PaddingLabel(insets: UIEdgeInsets(top: 0, left: 14, bottom: 0, right: 14)) statusLabel.text = viewModel.displayStatusText statusLabel.font = .systemFont(ofSize: 16, weight: .bold) statusLabel.textColor = viewModel.record.status.displayColor statusLabel.backgroundColor = viewModel.record.status.displayColor.withAlphaComponent(0.12) statusLabel.textAlignment = .center statusLabel.layer.cornerRadius = 19 statusLabel.clipsToBounds = true statusLabel.snp.makeConstraints { make in make.height.equalTo(38) } bottomRow.addArrangedSubview(idStack) bottomRow.addArrangedSubview(UIView()) bottomRow.addArrangedSubview(statusLabel) card.stack.addArrangedSubview(bottomRow) return card } private func makeReportInfoCard() -> UIView { let card = WildReportCardView(spacing: 0, inset: 0) card.layer.cornerRadius = 18 card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报类型", value: viewModel.record.reportType.label)) card.stack.addArrangedSubview(makeInsetDivider()) card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报说明", value: viewModel.descriptionText, multiline: true)) card.stack.addArrangedSubview(makeInsetDivider()) card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "微信号/手机号", value: viewModel.contactText)) if !viewModel.handleRemarkText.isEmpty, !viewModel.showHandlerInfo { card.stack.addArrangedSubview(makeInsetDivider()) card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理备注", value: viewModel.handleRemarkText, multiline: true)) } return card } private func makeEvidenceCard() -> UIView { let card = WildReportCardView(spacing: 16, inset: 18) card.layer.cornerRadius = 18 if !viewModel.reporterMediaItems.isEmpty { card.stack.addArrangedSubview(makeReporterMaterialsSection()) card.stack.addArrangedSubview(makeDivider()) } if !viewModel.paymentScreenshotItems.isEmpty { card.stack.addArrangedSubview(makePaymentScreenshotSection()) card.stack.addArrangedSubview(makeDivider()) } card.stack.addArrangedSubview(makeLocationSection()) return card } private func makeHandlerCard(info: WildReportHandlerInfo) -> UIView { let card = WildReportCardView(spacing: 0, inset: 0) card.layer.cornerRadius = 18 let title = WildReportUI.label("处理信息", font: .systemFont(ofSize: 18, weight: .bold), color: AppColor.textPrimary) let titleWrap = UIView() titleWrap.addSubview(title) title.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets(top: 18, left: 18, bottom: 10, right: 18)) } card.stack.addArrangedSubview(titleWrap) card.stack.addArrangedSubview(WildReportHandlerInfoRowView(title: "处理人", value: info.handlerName)) card.stack.addArrangedSubview(makeInsetDivider()) let phoneRow = WildReportHandlerInfoRowView(title: "处理人电话", value: info.maskedHandlerPhone, trailingIconName: "phone.fill") phoneRow.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handlerPhoneTapped))) phoneRow.accessibilityTraits.insert(.button) card.stack.addArrangedSubview(phoneRow) card.stack.addArrangedSubview(makeInsetDivider()) card.stack.addArrangedSubview(WildReportHandlerInfoRowView(title: "处理时间", value: info.processingAt)) card.stack.addArrangedSubview(makeInsetDivider()) card.stack.addArrangedSubview(WildReportHandlerInfoRowView(title: "处理完成时间", value: info.processedAt)) card.stack.addArrangedSubview(makeInsetDivider()) card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理意见", value: info.processOpinion, multiline: true)) if !info.completionMedia.isEmpty { card.stack.addArrangedSubview(makeInsetDivider()) card.stack.addArrangedSubview(makeCompletionMediaSection(info: info)) } return card } private func makeSupplementCard() -> UIView { let card = WildReportCardView(spacing: 14, inset: 18) card.layer.cornerRadius = 18 card.stack.addArrangedSubview(WildReportUI.label("补充证据记录", font: .systemFont(ofSize: 18, weight: .bold), color: AppColor.textPrimary)) let items = viewModel.supplementaryEvidences items.enumerated().forEach { index, item in let itemStack = UIStackView() itemStack.axis = .vertical itemStack.spacing = 8 let header = UIStackView() header.axis = .horizontal header.alignment = .center header.addArrangedSubview(WildReportUI.label(item.submitTime, font: .app(.captionMedium), color: AppColor.textSecondary)) header.addArrangedSubview(UIView()) header.addArrangedSubview(WildReportUI.label( WildReportSupplementDisplayFormatter.sequenceTitle(forDisplayIndex: index), font: .app(.captionMedium), color: AppColor.primary )) itemStack.addArrangedSubview(header) itemStack.addArrangedSubview(WildReportUI.label(item.summaryText, font: .app(.bodyMedium), color: AppColor.textPrimary, lines: 0)) card.stack.addArrangedSubview(itemStack) if index < items.count - 1 { card.stack.addArrangedSubview(makeDivider()) } } return card } private func makeTimelineCard() -> UIView { let card = WildReportCardView(spacing: 14, inset: 18) card.layer.cornerRadius = 18 card.stack.addArrangedSubview(WildReportUI.label("处理进度", font: .systemFont(ofSize: 18, weight: .bold), color: AppColor.textPrimary)) viewModel.progressSteps.enumerated().forEach { index, step in card.stack.addArrangedSubview(WildReportDetailTimelineRowView(step: step, isLast: index == viewModel.progressSteps.count - 1)) } return card } private func makeReporterMaterialsSection() -> UIView { let section = UIStackView() section.axis = .vertical section.spacing = 12 section.addArrangedSubview(makeCompactMediaSectionHeader( iconName: "photo.on.rectangle.angled", title: "举报人材料", summary: viewModel.reporterMediaSummary, action: #selector(previewTapped) )) let mediaScroll = UIScrollView() mediaScroll.showsHorizontalScrollIndicator = false let mediaStack = UIStackView() mediaStack.axis = .horizontal mediaStack.spacing = 10 mediaScroll.addSubview(mediaStack) mediaStack.snp.makeConstraints { make in make.edges.equalToSuperview() make.height.equalTo(mediaScroll.frameLayoutGuide) } viewModel.reporterMediaItems.forEach { item in let tile = WildReportDetailMediaThumbnailView(reporterItem: item) tile.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reporterMediaTapped(_:)))) tile.snp.makeConstraints { make in make.size.equalTo(108) } mediaStack.addArrangedSubview(tile) } mediaScroll.snp.makeConstraints { make in make.height.equalTo(108) } section.addArrangedSubview(mediaScroll) return section } private func makeCompactMediaSectionHeader( iconName: String, title: String, summary: String, action: Selector ) -> UIView { let control = UIControl() control.addTarget(self, action: action, for: .touchUpInside) let row = UIStackView() row.axis = .horizontal row.alignment = .center row.spacing = 8 let icon = UIImageView(image: UIImage(systemName: iconName)) icon.tintColor = AppColor.primary icon.contentMode = .scaleAspectFit icon.snp.makeConstraints { make in make.size.equalTo(16) } let titleLabel = WildReportUI.label(title, font: .systemFont(ofSize: 15, weight: .bold), color: AppColor.textPrimary) let spacer = UIView() let summaryLabel = WildReportUI.label(summary, font: .systemFont(ofSize: 13, weight: .medium), color: AppColor.textSecondary) summaryLabel.setContentHuggingPriority(.required, for: .horizontal) summaryLabel.setContentCompressionResistancePriority(.required, for: .horizontal) let chevron = UIImageView(image: UIImage(systemName: "chevron.right")) chevron.tintColor = UIColor(hex: 0xB6BECA) chevron.contentMode = .scaleAspectFit chevron.snp.makeConstraints { make in make.size.equalTo(14) } row.addArrangedSubview(icon) row.addArrangedSubview(titleLabel) row.addArrangedSubview(spacer) row.addArrangedSubview(summaryLabel) row.addArrangedSubview(chevron) control.addSubview(row) row.snp.makeConstraints { make in make.edges.equalToSuperview() } control.snp.makeConstraints { make in make.height.equalTo(22) } return control } private func makePaymentScreenshotSection() -> UIView { let section = UIStackView() section.axis = .vertical section.spacing = 12 section.addArrangedSubview(makeCompactMediaSectionHeader( iconName: "creditcard.fill", title: "支付截图", summary: viewModel.paymentScreenshotSummary, action: #selector(paymentPreviewTapped) )) let mediaScroll = UIScrollView() mediaScroll.showsHorizontalScrollIndicator = false let mediaStack = UIStackView() mediaStack.axis = .horizontal mediaStack.spacing = 10 mediaScroll.addSubview(mediaStack) mediaStack.snp.makeConstraints { make in make.edges.equalToSuperview() make.height.equalTo(mediaScroll.frameLayoutGuide) } viewModel.paymentScreenshotItems.forEach { item in let tile = WildReportDetailMediaThumbnailView(reporterItem: item) tile.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(paymentMediaTapped(_:)))) tile.snp.makeConstraints { make in make.size.equalTo(108) } mediaStack.addArrangedSubview(tile) } mediaScroll.snp.makeConstraints { make in make.height.equalTo(108) } section.addArrangedSubview(mediaScroll) return section } private func makeCompletionMediaSection(info: WildReportHandlerInfo) -> UIView { let section = UIStackView() section.axis = .vertical section.spacing = 12 section.layoutMargins = UIEdgeInsets(top: 14, left: 18, bottom: 16, right: 18) section.isLayoutMarginsRelativeArrangement = true section.addArrangedSubview(makeCompactMediaSectionHeader( iconName: "photo.on.rectangle.angled", title: "处理凭证", summary: info.completionMediaSummary, action: #selector(completionPreviewTapped) )) let mediaScroll = UIScrollView() mediaScroll.showsHorizontalScrollIndicator = false let mediaStack = UIStackView() mediaStack.axis = .horizontal mediaStack.spacing = 10 mediaScroll.addSubview(mediaStack) mediaStack.snp.makeConstraints { make in make.edges.equalToSuperview() make.height.equalTo(mediaScroll.frameLayoutGuide) } info.completionMedia.forEach { item in let tile = WildReportDetailMediaThumbnailView(completionItem: item) tile.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(completionMediaTapped(_:)))) tile.snp.makeConstraints { make in make.size.equalTo(108) } mediaStack.addArrangedSubview(tile) } mediaScroll.snp.makeConstraints { make in make.height.equalTo(108) } section.addArrangedSubview(mediaScroll) return section } private func makeLocationSection() -> UIView { let section = UIStackView() section.axis = .vertical section.spacing = 12 section.addArrangedSubview(makeLocationHeader()) let mapPreview = WildReportDetailMapPreviewView( scenicName: viewModel.scenicAreaName, detailAddress: viewModel.detailAddress, coordinate: viewModel.mapCoordinate ) section.addArrangedSubview(mapPreview) mapPreview.snp.makeConstraints { make in make.height.equalTo(98) } return section } private func makeLocationHeader() -> UIView { let control = UIControl() control.addTarget(self, action: #selector(locationTapped), for: .touchUpInside) let row = UIStackView() row.axis = .horizontal row.alignment = .center row.spacing = 14 let iconBox = UIView() iconBox.backgroundColor = AppColor.primaryLight iconBox.layer.cornerRadius = 12 let icon = UIImageView(image: UIImage(systemName: "mappin.circle.fill")) icon.tintColor = AppColor.primary icon.contentMode = .scaleAspectFit iconBox.addSubview(icon) iconBox.snp.makeConstraints { make in make.size.equalTo(50) } icon.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(22) } let titleLabel = WildReportUI.label("位置", font: .systemFont(ofSize: 20, weight: .bold), color: AppColor.textPrimary) let spacer = UIView() let valueLabel = WildReportUI.label(viewModel.detailAddress, font: .systemFont(ofSize: 18, weight: .medium), color: AppColor.textSecondary) valueLabel.lineBreakMode = .byTruncatingTail valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) let chevron = UIImageView(image: UIImage(systemName: "chevron.right")) chevron.tintColor = UIColor(hex: 0xB6BECA) chevron.contentMode = .scaleAspectFit chevron.snp.makeConstraints { make in make.size.equalTo(16) } row.addArrangedSubview(iconBox) row.addArrangedSubview(titleLabel) row.addArrangedSubview(spacer) row.addArrangedSubview(valueLabel) row.addArrangedSubview(chevron) control.addSubview(row) row.snp.makeConstraints { make in make.edges.equalToSuperview() } control.snp.makeConstraints { make in make.height.equalTo(50) } return control } private func makeSectionHeader( iconName: String, title: String, subtitle: String, trailingText: String? = nil, chevron: Bool, action: Selector? ) -> UIView { let control = UIControl() if let action { control.addTarget(self, action: action, for: .touchUpInside) } let iconBox = UIView() iconBox.backgroundColor = AppColor.primaryLight iconBox.layer.cornerRadius = 14 let icon = UIImageView(image: UIImage(systemName: iconName)) icon.tintColor = AppColor.primary icon.contentMode = .scaleAspectFit iconBox.addSubview(icon) control.addSubview(iconBox) let textStack = UIStackView() textStack.axis = .vertical textStack.spacing = 4 textStack.addArrangedSubview(WildReportUI.label(title, font: .systemFont(ofSize: 20, weight: .bold), color: AppColor.textPrimary)) if !subtitle.isEmpty { let subtitleLabel = WildReportUI.label(subtitle, font: .systemFont(ofSize: 18, weight: .medium), color: AppColor.textSecondary) subtitleLabel.adjustsFontSizeToFitWidth = true subtitleLabel.minimumScaleFactor = 0.8 textStack.addArrangedSubview(subtitleLabel) } control.addSubview(textStack) let trailingLabel = WildReportUI.label(trailingText ?? "", font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textSecondary) trailingLabel.textAlignment = .right trailingLabel.setContentHuggingPriority(.required, for: .horizontal) trailingLabel.setContentCompressionResistancePriority(.required, for: .horizontal) trailingLabel.isHidden = trailingText?.isEmpty != false control.addSubview(trailingLabel) let chevronView = UIImageView(image: UIImage(systemName: "chevron.right")) chevronView.tintColor = AppColor.textTertiary chevronView.isHidden = !chevron control.addSubview(chevronView) iconBox.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview() make.size.equalTo(50) } icon.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(24) } chevronView.snp.makeConstraints { make in make.trailing.centerY.equalToSuperview() make.size.equalTo(18) } trailingLabel.snp.makeConstraints { make in make.centerY.equalToSuperview() make.trailing.equalTo(chevronView.snp.leading).offset(-8) } textStack.snp.makeConstraints { make in make.leading.equalTo(iconBox.snp.trailing).offset(12) make.centerY.equalToSuperview() if trailingText?.isEmpty == false { make.trailing.lessThanOrEqualTo(trailingLabel.snp.leading).offset(-10) } else { make.trailing.equalTo(chevronView.snp.leading).offset(-10) } } control.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(50) } return control } private func makeScenicPill() -> UIView { let wrapper = UIView() let pill = UIStackView() pill.axis = .horizontal pill.alignment = .center pill.spacing = 4 pill.layoutMargins = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 10) pill.isLayoutMarginsRelativeArrangement = true pill.backgroundColor = AppColor.primaryLight pill.layer.cornerRadius = 13 pill.clipsToBounds = true pill.setContentHuggingPriority(.required, for: .horizontal) pill.setContentCompressionResistancePriority(.required, for: .horizontal) let icon = UIImageView(image: UIImage(systemName: "mappin.circle.fill")) icon.tintColor = AppColor.primary icon.snp.makeConstraints { make in make.size.equalTo(14) } let label = WildReportUI.label(viewModel.scenicAreaName, font: .app(.captionMedium), color: AppColor.primary) pill.addArrangedSubview(icon) pill.addArrangedSubview(label) wrapper.addSubview(pill) pill.snp.makeConstraints { make in make.top.bottom.leading.equalToSuperview() make.trailing.lessThanOrEqualToSuperview() } return wrapper } private func makeBottomButton(title: String, iconName: String, isPrimary: Bool) -> UIButton { let button = UIButton(type: .system) button.backgroundColor = isPrimary ? AppColor.primary : .white button.layer.cornerRadius = 14 button.layer.borderWidth = isPrimary ? 0 : 1.2 button.layer.borderColor = AppColor.primary.cgColor button.tintColor = isPrimary ? .white : AppColor.primary button.setTitleColor(isPrimary ? .white : AppColor.primary, for: .normal) button.setTitle(title, for: .normal) button.setImage(UIImage(systemName: iconName), for: .normal) button.titleLabel?.font = .systemFont(ofSize: 16, weight: .bold) button.titleLabel?.adjustsFontSizeToFitWidth = true button.titleLabel?.minimumScaleFactor = 0.82 button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4) button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) return button } private func makeDivider() -> UIView { let divider = UIView() divider.backgroundColor = AppColor.cardOutline divider.snp.makeConstraints { make in make.height.equalTo(1) } return divider } private func makeInsetDivider() -> UIView { let wrapper = UIView() let divider = UIView() divider.backgroundColor = AppColor.cardOutline wrapper.addSubview(divider) wrapper.snp.makeConstraints { make in make.height.equalTo(1) } divider.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(18) make.top.bottom.equalToSuperview() } return wrapper } @objc private func shareTapped() { showLoading() Task { [weak self] in guard let self else { return } let payload = await self.viewModel.shareReport(api: self.api) await MainActor.run { self.hideLoading() guard let payload else { return } WeChatShareService.shareMiniProgram(payload) { [weak self] result in self?.showWildReportShareResult(result) } } } } @objc private func previewTapped() { presentReporterMediaPreview(selectedKey: nil) } @objc private func paymentPreviewTapped() { presentPaymentMediaPreview(selectedKey: nil) } @objc private func completionPreviewTapped() { presentCompletionMediaPreview(selectedKey: nil) } @objc private func locationTapped() { viewModel.showLocationPreview() } @objc private func handlerPhoneTapped() { guard let phone = viewModel.handlerInfo?.handlerPhone.trimmingCharacters(in: .whitespacesAndNewlines), !phone.isEmpty, let url = URL(string: "tel://\(phone)") else { showToast("暂无处理人电话") return } UIApplication.shared.open(url) } @objc private func reporterMediaTapped(_ recognizer: UITapGestureRecognizer) { guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return } 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 } 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() { navigationController?.pushViewController( WildReportSupplementEvidenceViewController( record: viewModel.record, homeViewModel: homeViewModel, api: api, onSupplementSuccess: { [weak self] in guard let self else { return } Task { await self.viewModel.loadDetailIfNeeded(api: self.api, force: true) } } ), animated: true ) } @objc private func mapTapped() { navigationController?.pushViewController( WildReportRiskMapViewController(), animated: true ) } } /// 举报详情页图文竖排按钮,用于 Summary 卡片中的分享入口。 private final class WildReportDetailIconTextButton: UIControl { private let iconView = UIImageView() private let titleLabel = UILabel() init(title: String, iconName: String) { super.init(frame: .zero) backgroundColor = AppColor.primaryLight layer.cornerRadius = 14 iconView.image = UIImage(systemName: iconName) iconView.tintColor = AppColor.primary iconView.contentMode = .scaleAspectFit titleLabel.text = title titleLabel.font = .systemFont(ofSize: 10, weight: .bold) titleLabel.textColor = AppColor.primary titleLabel.textAlignment = .center addSubview(iconView) addSubview(titleLabel) iconView.snp.makeConstraints { make in make.top.equalToSuperview().offset(8) make.centerX.equalToSuperview() make.size.equalTo(18) } titleLabel.snp.makeConstraints { make in make.top.equalTo(iconView.snp.bottom).offset(2) make.leading.trailing.equalToSuperview().inset(4) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 举报详情页信息行,适配短文本与多行说明。 private final class WildReportDetailInfoRowView: UIView { init(title: String, value: String, multiline: Bool = false, trailingIconName: String? = nil) { super.init(frame: .zero) let titleLabel = WildReportUI.label(title, font: .app(.captionMedium), color: AppColor.textSecondary) let valueLabel = WildReportUI.label(value, font: .app(.subtitle), color: AppColor.textPrimary, lines: multiline ? 0 : 1) valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) addSubview(titleLabel) addSubview(valueLabel) if let trailingIconName { let icon = UIImageView(image: UIImage(systemName: trailingIconName)) icon.tintColor = AppColor.primary icon.contentMode = .scaleAspectFit addSubview(icon) icon.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(18) make.centerY.equalTo(valueLabel) make.size.equalTo(16) } valueLabel.snp.makeConstraints { make in make.trailing.equalTo(icon.snp.leading).offset(-6) } } titleLabel.snp.makeConstraints { make in make.top.leading.trailing.equalToSuperview().inset(UIEdgeInsets(top: 14, left: 18, bottom: 0, right: 18)) } valueLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(7) make.leading.equalToSuperview().offset(18) if trailingIconName == nil { make.trailing.equalToSuperview().inset(18) } make.bottom.equalToSuperview().inset(14) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 举报详情页处理信息单行,左侧字段名、右侧内容。 private final class WildReportHandlerInfoRowView: UIView { init(title: String, value: String, trailingIconName: String? = nil) { super.init(frame: .zero) let titleLabel = WildReportUI.label(title, font: .app(.captionMedium), color: AppColor.textSecondary) titleLabel.setContentHuggingPriority(.required, for: .horizontal) titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) let valueLabel = WildReportUI.label(value, font: .app(.subtitle), color: AppColor.textPrimary, lines: 1) valueLabel.textAlignment = .right valueLabel.lineBreakMode = .byTruncatingTail valueLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) addSubview(titleLabel) addSubview(valueLabel) if let trailingIconName { let icon = UIImageView(image: UIImage(systemName: trailingIconName)) icon.tintColor = AppColor.primary icon.contentMode = .scaleAspectFit addSubview(icon) icon.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(18) make.centerY.equalToSuperview() make.size.equalTo(16) } valueLabel.snp.makeConstraints { make in make.trailing.equalTo(icon.snp.leading).offset(-6) } } titleLabel.snp.makeConstraints { make in make.leading.equalToSuperview().offset(18) make.centerY.equalToSuperview() } valueLabel.snp.makeConstraints { make in make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(14) make.centerY.equalToSuperview() if trailingIconName == nil { make.trailing.equalToSuperview().inset(18) } } snp.makeConstraints { make in make.height.equalTo(48) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } 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 BaseViewController { @MainActor func showWildReportShareResult(_ result: WeChatShareResult) { switch result { case .success: showToast("分享成功") case .cancelled: showToast("已取消分享") case .notInstalled: showToast("请先安装微信") case .unsupported: showToast("当前微信版本不支持分享") case .invalidPayload(let message): showToast(message) case .sendFailed: showToast("微信分享发送失败") case .unknown: showToast("微信分享失败") } } } 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)张举报图片" super.init(frame: .zero) configure(kind: .image(index: index), footerText: fileTypeText ?? "图片 \(index + 1)", imageURL: url, showsFooter: false) case .video(let index, _, let fileName, let fileTypeText): previewKindText = fileName?.isEmpty == false ? fileName! : "第\(index + 1)段举报视频" super.init(frame: .zero) configure(kind: .video(index: index, duration: fileTypeText ?? "00:32"), footerText: fileTypeText ?? "视频 \(index + 1)", showsFooter: false) } } init(completionItem: WildReportCompletionMediaItem) { previewKey = completionItem.previewKey switch completionItem.kind { case .image: previewKindText = "处理凭证图片" super.init(frame: .zero) configure(kind: .image(index: 0), footerText: "凭证", imageURL: completionItem.coverURL) case .video: previewKindText = "处理凭证视频" super.init(frame: .zero) configure(kind: .video(index: 0, duration: completionItem.duration ?? "00:32"), footerText: completionItem.duration ?? "视频") } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() gradient.frame = bounds } private func configure(kind: Kind, footerText: String, imageURL: String? = nil, showsFooter: Bool = true) { clipsToBounds = true layer.cornerRadius = 10 gradient.colors = kind.gradientColors gradient.startPoint = CGPoint(x: 0, y: 0) gradient.endPoint = CGPoint(x: 1, y: 1) layer.insertSublayer(gradient, at: 0) if let imageURL, let url = URL(string: imageURL) { remoteImageView.contentMode = .scaleAspectFill remoteImageView.clipsToBounds = true remoteImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo")) addSubview(remoteImageView) remoteImageView.snp.makeConstraints { make in make.edges.equalToSuperview() } } if imageURL == nil { addPlaceholderArtwork(for: kind) } footerLabel.text = footerText footerLabel.font = .systemFont(ofSize: 11, weight: .bold) footerLabel.textColor = .white footerLabel.backgroundColor = UIColor.black.withAlphaComponent(0.24) footerLabel.layer.cornerRadius = 9 footerLabel.clipsToBounds = true footerLabel.isHidden = !showsFooter addSubview(footerLabel) footerLabel.snp.makeConstraints { make in make.leading.bottom.equalToSuperview().inset(8) } if case .video(_, let duration) = kind { let playCircle = UIView() playCircle.backgroundColor = UIColor.black.withAlphaComponent(0.08) playCircle.layer.cornerRadius = 21 playCircle.layer.borderWidth = 2.5 playCircle.layer.borderColor = UIColor.white.cgColor addSubview(playCircle) playView.image = UIImage(systemName: "play.fill") playView.tintColor = .white addSubview(playView) let durationLabel = PaddingLabel(insets: UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6)) durationLabel.text = duration durationLabel.font = .systemFont(ofSize: 14, weight: .semibold) durationLabel.textColor = .white durationLabel.backgroundColor = .clear durationLabel.layer.shadowColor = UIColor.black.cgColor durationLabel.layer.shadowOpacity = 0.25 durationLabel.layer.shadowRadius = 4 durationLabel.layer.shadowOffset = CGSize(width: 0, height: 2) addSubview(durationLabel) playCircle.snp.makeConstraints { make in make.center.equalToSuperview() make.size.equalTo(42) } playView.snp.makeConstraints { make in make.centerX.equalToSuperview().offset(2) make.centerY.equalToSuperview() make.size.equalTo(18) } durationLabel.snp.makeConstraints { make in make.trailing.bottom.equalToSuperview().inset(10) } } } private func addPlaceholderArtwork(for kind: Kind) { let index = min(kind.artworkIndex, 2) let mountain = UIImageView(image: UIImage(systemName: "mountain.2.fill")) mountain.tintColor = UIColor.white.withAlphaComponent(0.32) mountain.contentMode = .scaleAspectFit addSubview(mountain) let camera = UIImageView(image: UIImage(systemName: "camera.fill")) camera.tintColor = UIColor.white.withAlphaComponent(0.90) camera.contentMode = .scaleAspectFit addSubview(camera) let person = UIImageView(image: UIImage(systemName: "person.fill")) person.tintColor = UIColor(hex: 0x101827).withAlphaComponent(0.78) person.contentMode = .scaleAspectFit addSubview(person) mountain.snp.makeConstraints { make in make.width.height.equalTo(52) make.centerX.equalToSuperview().offset(-16) make.centerY.equalToSuperview().offset(-8) } camera.snp.makeConstraints { make in make.width.height.equalTo(22) make.trailing.equalToSuperview().inset(10) make.centerY.equalToSuperview().offset(-18) } person.snp.makeConstraints { make in make.width.height.equalTo(44) make.trailing.equalToSuperview().inset(24 - index * 12) make.bottom.equalToSuperview().offset(10) } } /// 举报详情页缩略图类型。 private enum Kind { case image(index: Int) case video(index: Int, duration: String) var artworkIndex: Int { switch self { case .image(let index), .video(let index, _): return index } } var gradientColors: [CGColor] { switch self { case .image(let index): switch index { case 1: return [UIColor(hex: 0xD7E8F8).cgColor, UIColor(hex: 0x7E9CB4).cgColor] case 2: return [UIColor(hex: 0xBFD9EE).cgColor, UIColor(hex: 0x435B70).cgColor] default: return [UIColor(hex: 0xC7DFEC).cgColor, UIColor(hex: 0x2D3D4B).cgColor] } case .video: return [UIColor(hex: 0xBFD9EE).cgColor, UIColor(hex: 0x435B70).cgColor] } } } } /// 举报详情页地图预览,绘制简化道路、路径和定位点。 private final class WildReportDetailMapPreviewView: UIView { private let mapView: MAMapView init(scenicName: String, detailAddress: String, coordinate: CLLocationCoordinate2D?) { _ = AMapBootstrap.configureIfNeeded() mapView = MAMapView(frame: .zero) super.init(frame: .zero) backgroundColor = AppColor.pageBackgroundSoft layer.cornerRadius = 10 clipsToBounds = true 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") } } /// 举报详情页处理进度时间线单行。 private final class WildReportDetailTimelineRowView: UIView { init(step: WildReportProgressStep, isLast: Bool) { super.init(frame: .zero) let leftColumn = UIView() let node = WildReportDetailTimelineNodeView(state: step.state) let line = UIView() line.backgroundColor = step.state == .pending ? AppColor.cardOutline : AppColor.primary.withAlphaComponent(0.45) line.isHidden = isLast leftColumn.addSubview(line) leftColumn.addSubview(node) addSubview(leftColumn) let textStack = UIStackView() textStack.axis = .vertical textStack.spacing = 5 let isPending = step.state == .pending let title = WildReportUI.label( step.title, font: .systemFont(ofSize: 16, weight: step.state == .current ? .bold : .semibold), color: isPending ? AppColor.textTertiary : AppColor.textPrimary ) textStack.addArrangedSubview(title) if !isPending || !step.timeText.isEmpty { textStack.addArrangedSubview(WildReportUI.label( step.timeText, font: .app(.captionMedium), color: step.state == .current ? AppColor.primary : AppColor.textSecondary, lines: 0 )) } addSubview(textStack) leftColumn.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview() make.width.equalTo(24) } node.snp.makeConstraints { make in make.top.centerX.equalToSuperview() make.size.equalTo(24) } line.snp.makeConstraints { make in make.top.equalTo(node.snp.bottom).offset(4) make.centerX.equalTo(node) make.bottom.equalToSuperview().inset(-8) make.width.equalTo(2) } textStack.snp.makeConstraints { make in make.top.equalToSuperview().offset(1) make.leading.equalTo(leftColumn.snp.trailing).offset(12) make.trailing.equalToSuperview() make.bottom.equalToSuperview().inset(16) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } /// 举报详情页时间线节点,区分完成、当前和待处理状态。 private final class WildReportDetailTimelineNodeView: UIView { private let state: WildReportProgressStepState init(state: WildReportProgressStepState) { self.state = state super.init(frame: .zero) backgroundColor = .clear } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func draw(_ rect: CGRect) { let circle = UIBezierPath(ovalIn: rect.insetBy(dx: 1.5, dy: 1.5)) switch state { case .completed: AppColor.primary.setFill() circle.fill() let check = UIBezierPath() check.move(to: CGPoint(x: rect.width * 0.30, y: rect.height * 0.52)) check.addLine(to: CGPoint(x: rect.width * 0.45, y: rect.height * 0.66)) check.addLine(to: CGPoint(x: rect.width * 0.72, y: rect.height * 0.36)) UIColor.white.setStroke() check.lineWidth = 2.2 check.lineCapStyle = .round check.lineJoinStyle = .round check.stroke() case .current: AppColor.primary.setStroke() circle.lineWidth = 2 circle.stroke() AppColor.primary.setFill() UIBezierPath(ovalIn: rect.insetBy(dx: 7, dy: 7)).fill() case .pending: AppColor.cardOutline.setStroke() circle.lineWidth = 2 circle.stroke() } } }