From 8ca8bcf94803d074815578d5dbd954672b145f85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Wed, 8 Jul 2026 14:02:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=B8=BE=E6=8A=A5=E6=91=84?= =?UTF-8?q?=E5=BD=B1=E5=B8=88=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WildPhotographerReportViewModels.swift | 7 + ...PhotographerReportListViewController.swift | 887 ++++++++++++++++-- ...otographerReportSubmitViewController.swift | 631 +++++++++++-- 3 files changed, 1357 insertions(+), 168 deletions(-) diff --git a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift index f2e130f..f78b8db 100644 --- a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift +++ b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift @@ -388,6 +388,13 @@ final class WildPhotographerReportDetailViewModel { var supplementaryEvidences: [WildReportSupplementaryEvidence] { homeViewModel?.supplementaryEvidences(for: record.id) ?? [] } + var reporterMediaItems: [WildReportReporterMediaItem] { + let images = (0.. UIView { - let card = WildReportCardView(spacing: AppSpacing.sm) - let header = UIStackView() - header.axis = .horizontal - header.alignment = .center - header.addArrangedSubview(WildReportUI.label(viewModel.heroTitle, font: .app(.metric), color: AppColor.textPrimary)) - header.addArrangedSubview(UIView()) - let status = WildReportStatusView() - status.apply(status: viewModel.record.status) - status.snp.makeConstraints { make in - make.width.equalTo(64) - make.height.equalTo(24) + 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) } - header.addArrangedSubview(status) - card.stack.addArrangedSubview(header) - card.stack.addArrangedSubview(WildReportInfoRowView(title: "举报编号", value: viewModel.record.id)) - card.stack.addArrangedSubview(WildReportInfoRowView(title: "景区", value: viewModel.scenicAreaName)) - card.stack.addArrangedSubview(WildReportInfoRowView(title: "位置", value: viewModel.detailAddress)) - card.stack.addArrangedSubview(WildReportInfoRowView(title: "联系方式", value: viewModel.contactText)) + 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 makeProgressCard() -> UIView { - let card = WildReportCardView(spacing: AppSpacing.sm) - card.stack.addArrangedSubview(WildReportUI.label("处理进度", font: .app(.title), color: AppColor.textPrimary)) - viewModel.progressSteps.forEach { step in - let prefix: String - switch step.state { - case .completed: prefix = "✓" - case .current: prefix = "●" - case .pending: prefix = "○" - } - card.stack.addArrangedSubview(WildReportUI.label("\(prefix) \(step.title) \(step.timeText)", font: .app(.body), color: AppColor.textSecondary, lines: 0)) - } + private func makeReportInfoCard() -> UIView { + let card = WildReportCardView(spacing: 0, inset: 0) + card.layer.cornerRadius = 18 + card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报类型", value: viewModel.record.reportType.rawValue)) + 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)) return card } private func makeEvidenceCard() -> UIView { - let card = WildReportCardView(spacing: AppSpacing.sm) - card.stack.addArrangedSubview(WildReportUI.label("举报证据", font: .app(.title), color: AppColor.textPrimary)) - card.stack.addArrangedSubview(WildReportUI.label(viewModel.descriptionText, font: .app(.body), color: AppColor.textSecondary, lines: 0)) - card.stack.addArrangedSubview(WildReportInfoRowView(title: "举报类型", value: viewModel.record.reportType.rawValue)) - card.stack.addArrangedSubview(WildReportInfoRowView(title: "举报人材料", value: viewModel.reporterMediaSummary)) - let previewButton = WildReportUI.iconButton(title: "预览材料", imageName: "photo.on.rectangle", style: .secondary) - previewButton.addTarget(self, action: #selector(previewTapped), for: .touchUpInside) - card.stack.addArrangedSubview(previewButton) + let card = WildReportCardView(spacing: 16, inset: 18) + card.layer.cornerRadius = 18 + if !viewModel.reporterMediaItems.isEmpty { + card.stack.addArrangedSubview(makeReporterMaterialsSection()) + card.stack.addArrangedSubview(makeDivider()) + } + card.stack.addArrangedSubview(makeLocationSection()) return card } - private func makeHandlerCard() -> UIView { - let card = WildReportCardView(spacing: AppSpacing.sm) - guard let info = viewModel.handlerInfo else { return card } - card.stack.addArrangedSubview(WildReportUI.label("处理信息", font: .app(.title), color: AppColor.textPrimary)) - card.stack.addArrangedSubview(WildReportInfoRowView(title: "处理人", value: info.handlerName)) - card.stack.addArrangedSubview(WildReportInfoRowView(title: "处理电话", value: info.maskedHandlerPhone)) - card.stack.addArrangedSubview(WildReportInfoRowView(title: "处理时间", value: info.processedAt)) - card.stack.addArrangedSubview(WildReportUI.label(info.processOpinion, font: .app(.body), color: AppColor.textSecondary, lines: 0)) - card.stack.addArrangedSubview(WildReportInfoRowView(title: "处理凭证", value: info.completionMediaSummary)) + 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(WildReportDetailInfoRowView(title: "处理人", value: info.handlerName)) + card.stack.addArrangedSubview(makeInsetDivider()) + card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理人电话", value: info.maskedHandlerPhone, trailingIconName: "phone.fill")) + card.stack.addArrangedSubview(makeInsetDivider()) + card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理时间", value: info.processingAt)) + card.stack.addArrangedSubview(makeInsetDivider()) + card.stack.addArrangedSubview(WildReportDetailInfoRowView(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: AppSpacing.sm) - card.stack.addArrangedSubview(WildReportUI.label("补充证据记录", font: .app(.title), color: AppColor.textPrimary)) + 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 - if items.isEmpty { - card.stack.addArrangedSubview(WildReportUI.label("暂无补充证据", font: .app(.body), color: AppColor.textSecondary)) - } else { - items.enumerated().forEach { index, item in - card.stack.addArrangedSubview(WildReportUI.label("第\(items.count - index)次补充 · \(item.submitTime)", font: .app(.bodyMedium), color: AppColor.textPrimary, lines: 0)) - card.stack.addArrangedSubview(WildReportUI.label(item.summaryText, font: .app(.body), color: AppColor.textSecondary, lines: 0)) + 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("第\(items.count - 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 makeActionButtons() -> UIView { - let row = UIStackView() - row.axis = .vertical - row.spacing = AppSpacing.sm - let supplement = AppButton(title: "补充证据") - supplement.addTarget(self, action: #selector(supplementTapped), for: .touchUpInside) - let map = AppButton(title: "查看附近风险地图", style: .secondary) - map.addTarget(self, action: #selector(mapTapped), for: .touchUpInside) - row.addArrangedSubview(supplement) - row.addArrangedSubview(map) - return row + 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(makeSectionHeader( + iconName: "photo.on.rectangle.angled", + title: "举报人材料", + subtitle: "\(viewModel.imageCountText)图片 \(viewModel.videoCountText)视频", + chevron: true, + 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 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(makeSectionHeader( + iconName: "photo.on.rectangle.angled", + title: "处理凭证", + subtitle: info.completionMediaSummary, + chevron: false, + action: nil + )) + + 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(makeSectionHeader( + iconName: "mappin.circle.fill", + title: "位置", + subtitle: viewModel.detailAddress, + chevron: true, + action: #selector(locationTapped) + )) + let mapPreview = WildReportDetailMapPreviewView(locationText: viewModel.detailAddress) + section.addArrangedSubview(mapPreview) + mapPreview.snp.makeConstraints { make in + make.height.equalTo(98) + } + return section + } + + private func makeSectionHeader(iconName: String, title: String, subtitle: String, 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)) + 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 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) + } + textStack.snp.makeConstraints { make in + make.leading.equalTo(iconBox.snp.trailing).offset(12) + make.centerY.equalToSuperview() + make.trailing.equalTo(chevronView.snp.leading).offset(-10) + } + control.snp.makeConstraints { make in + make.height.greaterThanOrEqualTo(50) + } + return control + } + + private func makeScenicPill() -> 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 + + 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) + return pill + } + + 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() { viewModel.shareReport() } @objc private func previewTapped() { viewModel.showMediaPreview(kind: "图片/视频") } + @objc private func locationTapped() { viewModel.showLocationPreview() } + + @objc private func reporterMediaTapped(_ recognizer: UITapGestureRecognizer) { + guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return } + viewModel.showMediaPreview(kind: tile.previewKindText) + } + + @objc private func completionMediaTapped(_ recognizer: UITapGestureRecognizer) { + guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return } + viewModel.showMediaPreview(kind: tile.previewKindText) + } @objc private func supplementTapped() { navigationController?.pushViewController( @@ -354,3 +688,390 @@ final class WildPhotographerReportDetailViewController: BaseViewController { ) } } + +/// 举报详情页图文竖排按钮,用于 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.textAlignment = multiline ? .left : .right + 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 WildReportDetailMediaThumbnailView: UIView { + let previewKindText: String + private let gradient = CAGradientLayer() + private let iconView = 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) { + switch reporterItem { + case .image(let index): + previewKindText = "第\(index + 1)张举报图片" + super.init(frame: .zero) + configure(kind: .image(index: index), footerText: "图片 \(index + 1)") + case .video(let index): + previewKindText = "第\(index + 1)段举报视频" + super.init(frame: .zero) + configure(kind: .video(index: index, duration: "00:32"), footerText: "视频 \(index + 1)") + } + } + + init(completionItem: WildReportCompletionMediaItem) { + switch completionItem.kind { + case .image: + previewKindText = "处理凭证图片" + super.init(frame: .zero) + configure(kind: .image(index: 0), footerText: "凭证") + 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) { + clipsToBounds = true + layer.cornerRadius = 14 + gradient.colors = kind.gradientColors + gradient.startPoint = CGPoint(x: 0, y: 0) + gradient.endPoint = CGPoint(x: 1, y: 1) + layer.insertSublayer(gradient, at: 0) + + iconView.image = UIImage(systemName: kind.iconName) + iconView.tintColor = UIColor.white.withAlphaComponent(0.92) + iconView.contentMode = .scaleAspectFit + addSubview(iconView) + + 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 + addSubview(footerLabel) + + iconView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(36) + } + footerLabel.snp.makeConstraints { make in + make.leading.bottom.equalToSuperview().inset(8) + } + + if case .video(_, let duration) = kind { + 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: 10, weight: .bold) + durationLabel.textColor = .white + durationLabel.backgroundColor = UIColor.black.withAlphaComponent(0.34) + durationLabel.layer.cornerRadius = 8 + durationLabel.clipsToBounds = true + addSubview(durationLabel) + playView.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(34) + } + durationLabel.snp.makeConstraints { make in + make.trailing.top.equalToSuperview().inset(8) + } + } + } + + /// 举报详情页缩略图类型。 + private enum Kind { + case image(index: Int) + case video(index: Int, duration: String) + + var iconName: String { + switch self { + case .image(let index): + return index.isMultiple(of: 2) ? "person.crop.rectangle.fill" : "camera.fill" + case .video: + return "video.fill" + } + } + + var gradientColors: [CGColor] { + switch self { + case .image(let index): + return index.isMultiple(of: 2) + ? [UIColor(hex: 0x75C7FF).cgColor, UIColor(hex: 0x0073FF).cgColor] + : [UIColor(hex: 0xA6E3B8).cgColor, UIColor(hex: 0x10B981).cgColor] + case .video: + return [UIColor(hex: 0x8CA8FF).cgColor, UIColor(hex: 0x4F46E5).cgColor] + } + } + } +} + +/// 举报详情页地图预览,绘制简化道路、路径和定位点。 +private final class WildReportDetailMapPreviewView: UIView { + private let locationLabel = UILabel() + private let pinView = UIImageView(image: UIImage(systemName: "mappin.circle.fill")) + + init(locationText: String) { + super.init(frame: .zero) + backgroundColor = UIColor(hex: 0xEAF7EA) + layer.cornerRadius = 14 + clipsToBounds = true + + locationLabel.text = locationText + locationLabel.font = .systemFont(ofSize: 12, weight: .bold) + locationLabel.textColor = AppColor.textPrimary + locationLabel.backgroundColor = UIColor.white.withAlphaComponent(0.86) + locationLabel.layer.cornerRadius = 10 + locationLabel.clipsToBounds = true + locationLabel.textAlignment = .center + pinView.tintColor = AppColor.danger + pinView.contentMode = .scaleAspectFit + + addSubview(locationLabel) + addSubview(pinView) + locationLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.bottom.equalToSuperview().inset(10) + make.trailing.lessThanOrEqualToSuperview().inset(12) + make.height.equalTo(24) + } + pinView.snp.makeConstraints { make in + make.centerX.equalToSuperview().offset(34) + make.centerY.equalToSuperview().offset(-2) + make.size.equalTo(32) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func draw(_ rect: CGRect) { + super.draw(rect) + guard let context = UIGraphicsGetCurrentContext() else { return } + + context.setFillColor(UIColor(hex: 0xD9F1D8).cgColor) + context.fill(CGRect(x: rect.width * 0.56, y: 0, width: rect.width * 0.44, height: rect.height)) + + let road = UIBezierPath() + road.move(to: CGPoint(x: 0, y: rect.height * 0.62)) + road.addCurve( + to: CGPoint(x: rect.width, y: rect.height * 0.24), + controlPoint1: CGPoint(x: rect.width * 0.28, y: rect.height * 0.26), + controlPoint2: CGPoint(x: rect.width * 0.64, y: rect.height * 0.88) + ) + UIColor.white.withAlphaComponent(0.86).setStroke() + road.lineWidth = 10 + road.stroke() + UIColor(hex: 0xBFDDBD).setStroke() + road.lineWidth = 2 + road.stroke() + + let path = UIBezierPath() + path.move(to: CGPoint(x: rect.width * 0.16, y: rect.height * 0.24)) + path.addCurve( + to: CGPoint(x: rect.width * 0.76, y: rect.height * 0.70), + controlPoint1: CGPoint(x: rect.width * 0.38, y: rect.height * 0.34), + controlPoint2: CGPoint(x: rect.width * 0.48, y: rect.height * 0.76) + ) + AppColor.primary.setStroke() + path.lineWidth = 2 + path.setLineDash([6, 5], count: 2, phase: 0) + path.stroke() + } +} + +/// 举报详情页处理进度时间线单行。 +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() + } + } +} diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift index 6d0088c..0c8aff2 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift @@ -14,7 +14,7 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { private let contentStack = UIStackView() private let descriptionView = UITextView() private let contactField = UITextField() - private let submitButton = AppButton(title: "提交举报") + private let submitButton = UIButton(type: .system) init( homeViewModel: WildPhotographerReportHomeViewModel, @@ -40,45 +40,50 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { override func setupUI() { view.backgroundColor = AppColor.pageBackgroundSoft scrollView.keyboardDismissMode = .interactive + scrollView.showsVerticalScrollIndicator = false contentStack.axis = .vertical contentStack.spacing = AppSpacing.sm - descriptionView.font = .app(.body) + descriptionView.font = .systemFont(ofSize: 15) descriptionView.textColor = AppColor.textPrimary - descriptionView.backgroundColor = AppColor.inputBackground - descriptionView.layer.cornerRadius = AppRadius.md - descriptionView.textContainerInset = UIEdgeInsets(top: 12, left: 10, bottom: 12, right: 10) + descriptionView.backgroundColor = .clear + descriptionView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8) descriptionView.delegate = self - contactField.placeholder = "微信号或手机号(选填)" + contactField.placeholder = "请输入摄影师微信号或手机号(选填)" contactField.font = .app(.body) contactField.textColor = AppColor.textPrimary - contactField.backgroundColor = AppColor.inputBackground - contactField.layer.cornerRadius = AppRadius.md + 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.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) - view.addSubview(submitButton) rebuildContent() } override func setupConstraints() { - submitButton.snp.makeConstraints { make in - make.leading.trailing.equalToSuperview().inset(AppSpacing.md) - make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.sm) - } scrollView.snp.makeConstraints { make in - make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) - make.bottom.equalTo(submitButton.snp.top).offset(-AppSpacing.sm) + make.edges.equalTo(view.safeAreaLayoutGuide) } contentStack.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(UIEdgeInsets(top: AppSpacing.sm, left: AppSpacing.md, bottom: AppSpacing.lg, right: AppSpacing.md)) - make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2) + make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 14, left: 18, bottom: 26, right: 18)) + make.width.equalTo(scrollView.frameLayoutGuide).offset(-36) } } @@ -114,37 +119,68 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { contentStack.removeArrangedSubview(view) view.removeFromSuperview() } - contentStack.addArrangedSubview(makeTipsCard()) + contentStack.addArrangedSubview(makeRealNameBanner()) contentStack.addArrangedSubview(makeTypeCard()) contentStack.addArrangedSubview(makeEvidenceCard()) contentStack.addArrangedSubview(makeDescriptionCard()) contentStack.addArrangedSubview(makeLocationCard()) contentStack.addArrangedSubview(makeContactCard()) - contentStack.addArrangedSubview(makePaymentCard()) + let paymentCard = makePaymentCard() + contentStack.addArrangedSubview(paymentCard) + contentStack.setCustomSpacing(16, after: paymentCard) + contentStack.addArrangedSubview(submitButton) + submitButton.snp.remakeConstraints { make in + make.height.equalTo(56) + } } - private func makeTipsCard() -> UIView { - let card = WildReportCardView(spacing: AppSpacing.xs) - card.stack.addArrangedSubview(WildReportUI.label("实名登录后提交,景区公安将接收并处理", font: .app(.bodyMedium), color: AppColor.textPrimary, lines: 0)) - card.stack.addArrangedSubview(WildReportUI.label("请描述摄影师位置、外貌特征和具体行为。", font: .app(.caption), color: AppColor.textSecondary, lines: 0)) - return card + 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: "选择举报类型") + let card = sectionCard(index: 1, title: "举报类型") let row = UIStackView() - row.axis = .vertical - row.spacing = AppSpacing.xs + row.axis = .horizontal + row.spacing = 8 + row.distribution = .fillEqually WildReportType.allCases.forEach { type in - let button = UIButton(type: .system) - button.contentHorizontalAlignment = .left - button.titleLabel?.font = .app(.bodyMedium) - button.setTitle(" \(type.rawValue)", for: .normal) - button.setImage(UIImage(systemName: type == viewModel.selectedReportType ? "largecircle.fill.circle" : "circle"), for: .normal) - button.tintColor = AppColor.primary - button.setTitleColor(AppColor.textPrimary, for: .normal) + let button = WildReportTypeOptionButton() + button.apply(type: type, isSelected: type == viewModel.selectedReportType) button.addAction(UIAction { [weak self] _ in self?.viewModel.selectReportType(type) }, for: .touchUpInside) - button.snp.makeConstraints { make in make.height.equalTo(38) } + button.snp.makeConstraints { make in make.height.equalTo(72) } row.addArrangedSubview(button) } card.stack.addArrangedSubview(row) @@ -153,84 +189,306 @@ final class WildPhotographerReportSubmitViewController: BaseViewController { private func makeEvidenceCard() -> UIView { let card = sectionCard(index: 2, title: "上传现场证据") - let buttonRow = UIStackView() - buttonRow.axis = .horizontal - buttonRow.spacing = AppSpacing.sm - buttonRow.distribution = .fillEqually - let imageButton = WildReportUI.iconButton(title: "添加图片", imageName: "photo", style: .secondary) - imageButton.addTarget(self, action: #selector(addImageTapped), for: .touchUpInside) - let videoButton = WildReportUI.iconButton(title: "添加视频", imageName: "video", style: .secondary) - videoButton.addTarget(self, action: #selector(addVideoTapped), for: .touchUpInside) - [imageButton, videoButton].forEach { button in - button.snp.makeConstraints { make in make.height.equalTo(44) } - buttonRow.addArrangedSubview(button) - } - card.stack.addArrangedSubview(buttonRow) - addAttachments(viewModel.images + viewModel.videos, to: card) + 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 - card.stack.addArrangedSubview(descriptionView) + 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) } - let count = WildReportUI.label("\(viewModel.description.count)/500", font: .app(.caption), color: AppColor.textTertiary) - count.textAlignment = .right - card.stack.addArrangedSubview(count) + 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 title = WildReportUI.label(viewModel.locationTitleText, font: .app(.bodyMedium), color: AppColor.textPrimary, lines: 0) - let subtitle = WildReportUI.label(viewModel.locationSubtitleText, font: .app(.caption), color: AppColor.textSecondary, lines: 0) - let button = WildReportUI.iconButton(title: viewModel.isUpdatingLocation ? "定位中" : "更新定位", imageName: "location.fill", style: .secondary) + 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) - button.snp.makeConstraints { make in make.height.equalTo(42) } - card.stack.addArrangedSubview(title) - card.stack.addArrangedSubview(subtitle) - card.stack.addArrangedSubview(button) + + 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: "联系方式(选填)") + let card = sectionCard(index: 5, title: "摄影师微信号/手机号", optional: true) contactField.text = viewModel.contact card.stack.addArrangedSubview(contactField) - contactField.snp.makeConstraints { make in make.height.equalTo(46) } + contactField.snp.makeConstraints { make in make.height.equalTo(48) } return card } private func makePaymentCard() -> UIView { - let card = sectionCard(index: 6, title: "支付截图(选填)") - card.stack.addArrangedSubview(WildReportUI.label("上传线下微信/支付宝支付截图,最多 3 张。", font: .app(.caption), color: AppColor.textSecondary, lines: 0)) - let button = WildReportUI.iconButton(title: "添加截图", imageName: "creditcard", style: .secondary) - button.addTarget(self, action: #selector(addPaymentTapped), for: .touchUpInside) - button.snp.makeConstraints { make in make.height.equalTo(42) } - card.stack.addArrangedSubview(button) - addAttachments(viewModel.paymentImages, to: card) - return card - } + let card = sectionCard(index: 6, title: "线下微信/支付宝支付截图", optional: true) + let row = UIView() + row.backgroundColor = UIColor(hex: 0xFBFCFE) + row.layer.cornerRadius = 12 + row.layer.borderWidth = 1 + row.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor - private func sectionCard(index: Int, title: String) -> WildReportCardView { - let card = WildReportCardView(spacing: AppSpacing.sm) - card.stack.addArrangedSubview(WildReportUI.label("\(index). \(title)", font: .app(.title), color: AppColor.textPrimary)) - return card - } - - private func addAttachments(_ attachments: [WildReportAttachment], to card: WildReportCardView) { - guard !attachments.isEmpty else { return } - attachments.forEach { attachment in - let chip = WildReportAttachmentChipView(attachment: attachment) - chip.onRemove = { [weak self] in - self?.viewModel.removeAttachment(attachment) - } - card.stack.addArrangedSubview(chip) + let iconWrap = UIView() + iconWrap.backgroundColor = AppColor.primaryLight + iconWrap.layer.cornerRadius = 12 + let icon = UIImageView(image: UIImage(systemName: "creditcard.fill")) + icon.tintColor = AppColor.primary + iconWrap.addSubview(icon) + icon.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(24) } + + let textStack = UIStackView() + textStack.axis = .vertical + textStack.spacing = 4 + textStack.addArrangedSubview(WildReportUI.label("上传线下微信/支付宝支付截图(选填)", font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textPrimary, lines: 0)) + textStack.addArrangedSubview(WildReportUI.label("支持 JPG、PNG 格式,最多 3 张", font: .systemFont(ofSize: 12), color: AppColor.textSecondary)) + + let trailingView: UIView + if let first = viewModel.paymentImages.first { + trailingView = WildReportAttachmentTileView(attachment: first) { [weak self] in + self?.viewModel.removeAttachment(first) + } + } else { + trailingView = WildReportAddAttachmentTileView { [weak self] in + self?.viewModel.addPaymentImage() + } + } + + row.addSubview(iconWrap) + row.addSubview(textStack) + row.addSubview(trailingView) + iconWrap.snp.makeConstraints { make in + make.leading.top.bottom.equalToSuperview().inset(12) + make.size.equalTo(44) + } + trailingView.snp.makeConstraints { make in + make.trailing.equalToSuperview().inset(12) + make.centerY.equalToSuperview() + make.size.equalTo(58) + } + textStack.snp.makeConstraints { make in + make.leading.equalTo(iconWrap.snp.trailing).offset(12) + make.trailing.lessThanOrEqualTo(trailingView.snp.leading).offset(-10) + make.centerY.equalToSuperview() + make.top.greaterThanOrEqualToSuperview().offset(12) + make.bottom.lessThanOrEqualToSuperview().inset(12) + } + card.stack.addArrangedSubview(row) + 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.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) { [weak self] in + self?.viewModel.removeAttachment(attachment) + }) + } + let addCount = max(0, min(3, maxCount - attachments.count)) + 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 } @objc private func addImageTapped() { viewModel.addImage() } @@ -247,6 +505,209 @@ extension WildPhotographerReportSubmitViewController: UITextViewDelegate { } } +/// 提交页步骤标题,复刻参考页左侧蓝色竖条与选填标识。 +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 iconView = UIImageView() + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + layer.cornerRadius = 12 + layer.borderWidth = 1 + + let stack = UIStackView() + stack.axis = .vertical + stack.alignment = .center + stack.spacing = 6 + stack.isUserInteractionEnabled = false + + iconView.contentMode = .scaleAspectFit + titleLabel.font = .systemFont(ofSize: 13, weight: .bold) + titleLabel.textAlignment = .center + titleLabel.adjustsFontSizeToFitWidth = true + titleLabel.minimumScaleFactor = 0.8 + titleLabel.lineBreakMode = .byClipping + + addSubview(stack) + stack.addArrangedSubview(iconView) + stack.addArrangedSubview(titleLabel) + iconView.snp.makeConstraints { make in + make.size.equalTo(22) + } + stack.snp.makeConstraints { make in + make.center.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) { + iconView.image = UIImage(systemName: type.iconName) + titleLabel.text = type.rawValue + backgroundColor = isSelected ? AppColor.primary : UIColor(hex: 0xF6FAFF) + layer.borderColor = (isSelected ? AppColor.primary : UIColor(hex: 0xC8DDFF)).cgColor + iconView.tintColor = isSelected ? .white : AppColor.textPrimary + titleLabel.textColor = isSelected ? .white : AppColor.textPrimary + } +} + +/// 提交页附件缩略 tile,使用渐变和删除角标模拟本地 Mock 附件。 +private final class WildReportAttachmentTileView: UIControl { + private let gradientLayer = CAGradientLayer() + private let removeAction: () -> Void + + init(attachment: WildReportAttachment, onRemove: @escaping () -> Void) { + self.removeAction = onRemove + super.init(frame: .zero) + layer.cornerRadius = 10 + clipsToBounds = false + + gradientLayer.cornerRadius = 10 + let colors: [UIColor] = attachment.kind == .video + ? [UIColor(hex: 0xB7D8FF), UIColor(hex: 0x4C8FEF)] + : [UIColor(hex: 0xCFE6FF), UIColor(hex: 0x7EB4FF)] + gradientLayer.colors = colors.map(\.cgColor) + gradientLayer.startPoint = CGPoint(x: 0, y: 0) + gradientLayer.endPoint = CGPoint(x: 1, y: 1) + layer.insertSublayer(gradientLayer, at: 0) + + let icon = UIImageView(image: UIImage(systemName: attachment.kind == .video ? "play.circle.fill" : "mountain.2.fill")) + icon.tintColor = UIColor.white.withAlphaComponent(0.95) + icon.contentMode = .scaleAspectFit + addSubview(icon) + icon.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(attachment.kind == .video ? 30 : 26) + } + + let removeButton = UIButton(type: .system) + removeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + removeButton.tintColor = UIColor(hex: 0x8D93A1) + removeButton.backgroundColor = .white + removeButton.layer.cornerRadius = 9 + removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside) + addSubview(removeButton) + removeButton.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(-6) + make.size.equalTo(18) + } + + snp.makeConstraints { make in + make.size.equalTo(58) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } + + @objc private func removeTapped() { + removeAction() + } +} + +/// 提交页添加附件 tile,白底虚线加号。 +private final class WildReportAddAttachmentTileView: UIControl { + private let tapAction: () -> Void + private let borderLayer = CAShapeLayer() + + init(action: @escaping () -> Void) { + self.tapAction = action + super.init(frame: .zero) + backgroundColor = .white + layer.cornerRadius = 10 + + borderLayer.strokeColor = UIColor(hex: 0xCDD5E1).cgColor + borderLayer.fillColor = UIColor.clear.cgColor + borderLayer.lineWidth = 1 + borderLayer.lineDashPattern = [5, 4] + layer.addSublayer(borderLayer) + + let icon = UIImageView(image: UIImage(systemName: "plus")) + icon.tintColor = UIColor(hex: 0x8D93A1) + icon.contentMode = .scaleAspectFit + addSubview(icon) + icon.snp.makeConstraints { make in + make.center.equalToSuperview() + make.size.equalTo(30) + } + + addTarget(self, action: #selector(tapped), for: .touchUpInside) + snp.makeConstraints { make in + make.size.equalTo(58) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + borderLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath + } + + @objc private func tapped() { + tapAction() + } +} + /// 举报成功页面,展示编号、上传摘要和处理进度。 final class WildPhotographerReportSuccessViewController: BaseViewController { private let viewModel: WildPhotographerReportSuccessViewModel