feat: add wild photographer report mock flow

This commit is contained in:
2026-07-08 12:06:13 +08:00
parent 87fba3d5d6
commit 8a65d3c104
14 changed files with 3542 additions and 0 deletions

View File

@ -0,0 +1,209 @@
//
// WildReportCommonViews.swift
// suixinkan
//
import SnapKit
import UIKit
/// UI
enum WildReportUI {
static func label(_ text: String, font: UIFont, color: UIColor, lines: Int = 1) -> UILabel {
let label = UILabel()
label.text = text
label.font = font
label.textColor = color
label.numberOfLines = lines
return label
}
static func iconButton(title: String, imageName: String, style: AppButton.Style = .primary) -> UIButton {
let button = UIButton(type: .system)
button.titleLabel?.font = .app(.subtitle)
button.setTitle(title, for: .normal)
button.setImage(UIImage(systemName: imageName), for: .normal)
button.tintColor = style == .primary ? .white : AppColor.primary
button.setTitleColor(style == .primary ? .white : AppColor.primary, for: .normal)
button.backgroundColor = style == .primary ? AppColor.primary : AppColor.primaryLight
button.layer.cornerRadius = AppRadius.md
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -4, bottom: 0, right: 4)
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
return button
}
}
///
final class WildReportCardView: UIView {
let stack = UIStackView()
init(spacing: CGFloat = AppSpacing.sm, inset: CGFloat = AppSpacing.md) {
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = AppRadius.lg
layer.borderWidth = 1
layer.borderColor = AppColor.cardOutline.cgColor
stack.axis = .vertical
stack.spacing = max(0, spacing - AppSpacing.xxs)
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(inset)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
final class WildReportStatusView: UILabel {
override init(frame: CGRect) {
super.init(frame: frame)
font = .app(.captionMedium)
textAlignment = .center
layer.cornerRadius = 12
clipsToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(status: WildReportStatus) {
text = status.rawValue
textColor = status.displayColor
backgroundColor = status.backgroundColor
}
}
///
final class WildReportInfoRowView: UIView {
private let titleLabel = UILabel()
private let valueLabel = UILabel()
init(title: String, value: String) {
super.init(frame: .zero)
titleLabel.font = .app(.body)
titleLabel.textColor = AppColor.textSecondary
titleLabel.text = title
valueLabel.font = .app(.bodyMedium)
valueLabel.textColor = AppColor.textPrimary
valueLabel.textAlignment = .right
valueLabel.numberOfLines = 0
valueLabel.text = value
addSubview(titleLabel)
addSubview(valueLabel)
titleLabel.snp.makeConstraints { make in
make.leading.top.equalToSuperview()
make.width.greaterThanOrEqualTo(76)
make.bottom.lessThanOrEqualToSuperview()
}
valueLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(AppSpacing.sm)
make.trailing.top.bottom.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
/// chip
final class WildReportAttachmentChipView: UIView {
private let titleLabel = UILabel()
private let iconView = UIImageView()
var onRemove: (() -> Void)?
init(attachment: WildReportAttachment, removable: Bool = true) {
super.init(frame: .zero)
backgroundColor = AppColor.pageBackgroundSoft
layer.cornerRadius = AppRadius.md
layer.borderWidth = 1
layer.borderColor = AppColor.cardOutline.cgColor
iconView.image = UIImage(systemName: iconName(for: attachment.kind))
iconView.tintColor = AppColor.primary
titleLabel.text = attachment.title
titleLabel.font = .app(.captionMedium)
titleLabel.textColor = AppColor.textPrimary
addSubview(iconView)
addSubview(titleLabel)
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(AppSpacing.sm)
make.centerY.equalToSuperview()
make.size.equalTo(18)
}
titleLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(AppSpacing.xs)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualToSuperview().inset(removable ? 34 : AppSpacing.sm)
}
if removable {
let button = UIButton(type: .system)
button.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
button.tintColor = AppColor.textTertiary
button.addTarget(self, action: #selector(removeTapped), for: .touchUpInside)
addSubview(button)
button.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(AppSpacing.xs)
make.centerY.equalToSuperview()
make.size.equalTo(28)
}
}
snp.makeConstraints { make in
make.height.equalTo(40)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func iconName(for kind: WildReportAttachmentKind) -> String {
switch kind {
case .image: return "photo.fill"
case .video: return "video.fill"
case .payment: return "creditcard.fill"
}
}
@objc private func removeTapped() {
onRemove?()
}
}
///
final class WildReportEmptyView: UIView {
init(text: String) {
super.init(frame: .zero)
let icon = UIImageView(image: UIImage(systemName: "tray"))
icon.tintColor = AppColor.textTertiary
let label = WildReportUI.label(text, font: .app(.body), color: AppColor.textSecondary, lines: 0)
label.textAlignment = .center
addSubview(icon)
addSubview(label)
icon.snp.makeConstraints { make in
make.top.centerX.equalToSuperview()
make.size.equalTo(42)
}
label.snp.makeConstraints { make in
make.top.equalTo(icon.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.bottom.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,489 @@
//
// WildPhotographerReportHomeViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class WildPhotographerReportHomeViewController: BaseViewController {
private let viewModel: WildPhotographerReportHomeViewModel
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
init(viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel()) {
self.viewModel = viewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "举报摄影师"
navigationItem.largeTitleDisplayMode = .never
let backButton = UIButton(type: .system)
backButton.backgroundColor = .white
backButton.tintColor = AppColor.textPrimary
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
backButton.layer.cornerRadius = 25
backButton.layer.shadowColor = UIColor.black.cgColor
backButton.layer.shadowOpacity = 0.05
backButton.layer.shadowOffset = CGSize(width: 0, height: 6)
backButton.layer.shadowRadius = 16
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
backButton.snp.makeConstraints { make in
make.size.equalTo(50)
}
navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backButton)
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackgroundSoft
scrollView.alwaysBounceVertical = true
scrollView.showsVerticalScrollIndicator = false
scrollView.contentInsetAdjustmentBehavior = .never
contentStack.axis = .vertical
contentStack.spacing = 14
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
rebuildContent()
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalToSuperview()
}
contentStack.snp.makeConstraints { make in
make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: AppSpacing.md, left: 20, bottom: AppSpacing.md, right: 20))
make.width.equalTo(scrollView.frameLayoutGuide).offset(-40)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.rebuildContent() }
}
}
@MainActor
private func rebuildContent() {
contentStack.arrangedSubviews.forEach { view in
contentStack.removeArrangedSubview(view)
view.removeFromSuperview()
}
switch viewModel.pageState {
case .normal:
contentStack.addArrangedSubview(makeHeroCard())
contentStack.addArrangedSubview(makeRiskMapEntry())
contentStack.addArrangedSubview(makeNoticeCard())
contentStack.addArrangedSubview(makeTrustCard())
case .loading:
contentStack.addArrangedSubview(makeStateCard(icon: "clock", title: "加载中", message: "正在加载举报服务"))
case .empty:
contentStack.addArrangedSubview(makeStateCard(icon: "exclamationmark.shield", title: viewModel.emptyTitle, message: viewModel.emptyMessage, buttonTitle: "恢复演示数据") { [weak self] in
self?.viewModel.restoreDemoData()
})
case .error:
contentStack.addArrangedSubview(makeStateCard(icon: "wifi.exclamationmark", title: "加载失败", message: "举报服务暂时不可用,请稍后重试。", buttonTitle: "重新加载") { [weak self] in
self?.viewModel.retryLoadAfterError()
})
}
}
private func makeHeroCard() -> UIView {
let card = WildReportCardView(spacing: 22, inset: 22)
card.backgroundColor = UIColor(hex: 0xFBFDFF)
card.layer.cornerRadius = 26
card.layer.borderColor = UIColor(hex: 0xBFD7FF).cgColor
let topRow = UIStackView()
topRow.axis = .horizontal
topRow.alignment = .top
topRow.spacing = AppSpacing.md
let titleStack = UIStackView()
titleStack.axis = .vertical
titleStack.spacing = 10
let title = WildReportUI.label("实名举报", font: .systemFont(ofSize: 30, weight: .heavy), color: AppColor.textPrimary)
let subtitle = WildReportUI.label("摄影师", font: .systemFont(ofSize: 30, weight: .heavy), color: AppColor.primary)
titleStack.addArrangedSubview(title)
titleStack.addArrangedSubview(subtitle)
topRow.addArrangedSubview(titleStack)
topRow.addArrangedSubview(UIView())
topRow.addArrangedSubview(WildReportHeroIllustrationView())
let desc = WildReportUI.label("可上传图片、视频、文字和位置,景区公安将接收并现场处理。", font: .systemFont(ofSize: 17, weight: .regular), color: UIColor(hex: 0x687484), lines: 0)
desc.setContentCompressionResistancePriority(.required, for: .vertical)
let buttonRow = UIStackView()
buttonRow.axis = .horizontal
buttonRow.spacing = 14
buttonRow.distribution = .fillEqually
let submitButton = WildReportUI.iconButton(title: "立即举报", imageName: "checkmark.shield.fill")
submitButton.titleLabel?.font = .systemFont(ofSize: 18, weight: .bold)
submitButton.layer.cornerRadius = 14
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
let listButton = WildReportUI.iconButton(title: "我的举报", imageName: "doc.text.fill", style: .secondary)
listButton.titleLabel?.font = .systemFont(ofSize: 18, weight: .bold)
listButton.layer.cornerRadius = 14
listButton.backgroundColor = .white
listButton.layer.borderWidth = 1
listButton.layer.borderColor = UIColor(hex: 0xBFD7FF).cgColor
listButton.addTarget(self, action: #selector(listTapped), for: .touchUpInside)
[submitButton, listButton].forEach { button in
button.snp.makeConstraints { make in make.height.equalTo(58) }
buttonRow.addArrangedSubview(button)
}
card.stack.addArrangedSubview(topRow)
card.stack.addArrangedSubview(desc)
card.stack.addArrangedSubview(buttonRow)
return card
}
private func makeRiskMapEntry() -> UIView {
let card = WildReportCardView(spacing: 0, inset: 18)
card.layer.cornerRadius = 20
let row = makeEntryRow(icon: "map.fill", title: "附近风险地图", subtitle: "查看附近摄影师风险点位")
let button = UIButton(type: .system)
button.addTarget(self, action: #selector(mapTapped), for: .touchUpInside)
card.stack.addArrangedSubview(row)
card.addSubview(button)
button.snp.makeConstraints { make in make.edges.equalToSuperview() }
return card
}
private func makeNoticeCard() -> UIView {
let card = WildReportCardView(spacing: 18, inset: 18)
card.layer.cornerRadius = 20
let header = UIStackView()
header.axis = .horizontal
header.alignment = .center
header.spacing = AppSpacing.sm
let headerIcon = UIImageView(image: UIImage(systemName: "list.clipboard.fill"))
headerIcon.tintColor = AppColor.primary
headerIcon.snp.makeConstraints { make in make.size.equalTo(24) }
let title = WildReportUI.label("举报须知", font: .systemFont(ofSize: 18, weight: .bold), color: AppColor.textPrimary)
let ruleButton = UIButton(type: .system)
ruleButton.setTitle("举报规则", for: .normal)
ruleButton.setImage(UIImage(systemName: "shield.lefthalf.filled"), for: .normal)
ruleButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
ruleButton.tintColor = AppColor.primary
ruleButton.setTitleColor(AppColor.primary, for: .normal)
ruleButton.backgroundColor = AppColor.primaryLight
ruleButton.layer.cornerRadius = 18
ruleButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
ruleButton.addTarget(self, action: #selector(ruleTapped), for: .touchUpInside)
ruleButton.snp.makeConstraints { make in
make.height.equalTo(36)
}
header.addArrangedSubview(headerIcon)
header.addArrangedSubview(title)
header.addArrangedSubview(UIView())
header.addArrangedSubview(ruleButton)
let items = [
"需实名登录",
"请尽量上传清晰证据",
"恶意举报将被追责"
]
card.stack.addArrangedSubview(header)
items.forEach { item in
card.stack.addArrangedSubview(makeBullet(item))
}
return card
}
private func makeTrustCard() -> UIView {
let badges = UIStackView()
badges.axis = .horizontal
badges.distribution = .fillEqually
badges.spacing = AppSpacing.sm
[
("实名", "身份核验 真实可靠", "person.crop.circle.badge.checkmark", AppColor.primary, AppColor.primaryLight),
("安全", "数据加密 隐私保护", "checkmark.shield.fill", AppColor.success, AppColor.successBackground),
("景区公安处理", "专业受理 依法处置", "person.text.rectangle.fill", AppColor.primary, AppColor.primaryLight)
].forEach { title, subtitle, icon, tint, bg in
badges.addArrangedSubview(WildReportTrustItemView(title: title, subtitle: subtitle, iconName: icon, tintColor: tint, backgroundColor: bg))
}
return badges
}
private func makeEntryRow(icon: String, title: String, subtitle: String) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 18
let iconWrap = UIView()
iconWrap.backgroundColor = AppColor.primaryLight
iconWrap.layer.cornerRadius = 18
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(34)
}
iconWrap.snp.makeConstraints { make in make.size.equalTo(64) }
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = 6
textStack.addArrangedSubview(WildReportUI.label(title, font: .systemFont(ofSize: 19, weight: .bold), color: AppColor.textPrimary))
textStack.addArrangedSubview(WildReportUI.label(subtitle, font: .systemFont(ofSize: 15, weight: .regular), color: AppColor.textSecondary))
let chevron = UIImageView(image: UIImage(systemName: "chevron.right"))
chevron.tintColor = AppColor.textTertiary
row.addArrangedSubview(iconWrap)
row.addArrangedSubview(textStack)
row.addArrangedSubview(UIView())
row.addArrangedSubview(chevron)
return row
}
private func makeBullet(_ text: String) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 14
let dot = UIView()
dot.backgroundColor = AppColor.primary
dot.layer.cornerRadius = 4
dot.snp.makeConstraints { make in
make.size.equalTo(8)
}
let label = WildReportUI.label(text, font: .systemFont(ofSize: 16, weight: .regular), color: UIColor(hex: 0x667085), lines: 0)
row.addArrangedSubview(dot)
row.addArrangedSubview(label)
return row
}
private func makeStateCard(
icon: String,
title: String,
message: String,
buttonTitle: String? = nil,
action: (() -> Void)? = nil
) -> UIView {
let card = WildReportCardView(spacing: AppSpacing.md)
card.stack.alignment = .center
let image = UIImageView(image: UIImage(systemName: icon))
image.tintColor = AppColor.primary
image.snp.makeConstraints { make in make.size.equalTo(48) }
let titleLabel = WildReportUI.label(title, font: .app(.metric), color: AppColor.textPrimary)
let messageLabel = WildReportUI.label(message, font: .app(.body), color: AppColor.textSecondary, lines: 0)
messageLabel.textAlignment = .center
card.stack.addArrangedSubview(image)
card.stack.addArrangedSubview(titleLabel)
card.stack.addArrangedSubview(messageLabel)
if let buttonTitle {
let button = AppButton(title: buttonTitle)
button.addAction(UIAction { _ in action?() }, for: .touchUpInside)
card.stack.addArrangedSubview(button)
button.snp.makeConstraints { make in make.width.equalTo(180) }
}
return card
}
@objc private func submitTapped() {
navigationController?.pushViewController(
WildPhotographerReportSubmitViewController(homeViewModel: viewModel),
animated: true
)
}
@objc private func listTapped() {
navigationController?.pushViewController(
WildPhotographerReportListViewController(homeViewModel: viewModel),
animated: true
)
}
@objc private func mapTapped() {
let record = viewModel.reports.first ?? WildPhotographerReportMockStore.seedReports[0]
navigationController?.pushViewController(
WildReportRiskMapViewController(record: record, riskPoints: viewModel.riskPoints),
animated: true
)
}
@objc private func ruleTapped() {
navigationController?.pushViewController(WildReportRuleViewController(), animated: true)
}
@objc private func backTapped() {
navigationController?.popViewController(animated: true)
}
}
/// Hero
private final class WildReportHeroIllustrationView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
let ring = UIView()
ring.backgroundColor = UIColor(hex: 0xD9EAFF)
ring.layer.cornerRadius = 58
let center = UIView()
center.backgroundColor = .white
center.layer.cornerRadius = 28
center.layer.shadowColor = UIColor.black.cgColor
center.layer.shadowOpacity = 0.04
center.layer.shadowRadius = 10
center.layer.shadowOffset = CGSize(width: 0, height: 4)
let shield = UIImageView(image: UIImage(systemName: "exclamationmark.shield.fill"))
shield.tintColor = AppColor.primary
shield.contentMode = .scaleAspectFit
let cameraBadge = UIView()
cameraBadge.backgroundColor = UIColor(hex: 0xE8F3FF)
cameraBadge.layer.cornerRadius = 14
let camera = UIImageView(image: UIImage(systemName: "camera.fill"))
camera.tintColor = UIColor(hex: 0x195591)
camera.contentMode = .scaleAspectFit
addSubview(ring)
addSubview(center)
center.addSubview(shield)
addSubview(cameraBadge)
cameraBadge.addSubview(camera)
ring.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(116)
}
center.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(88)
}
shield.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(52)
}
cameraBadge.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-8)
make.bottom.equalToSuperview().offset(-10)
make.size.equalTo(38)
}
camera.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(22)
}
snp.makeConstraints { make in
make.size.equalTo(116)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
private final class WildReportTrustItemView: UIView {
init(title: String, subtitle: String, iconName: String, tintColor: UIColor, backgroundColor: UIColor) {
super.init(frame: .zero)
self.backgroundColor = .white
layer.cornerRadius = 16
layer.borderWidth = 1
layer.borderColor = AppColor.cardOutline.cgColor
let iconWrap = UIView()
iconWrap.backgroundColor = backgroundColor
iconWrap.layer.cornerRadius = 22
let icon = UIImageView(image: UIImage(systemName: iconName))
icon.tintColor = tintColor
icon.contentMode = .scaleAspectFit
iconWrap.addSubview(icon)
icon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(26)
}
let titleLabel = WildReportUI.label(title, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary)
titleLabel.textAlignment = .center
titleLabel.adjustsFontSizeToFitWidth = true
titleLabel.minimumScaleFactor = 0.78
titleLabel.lineBreakMode = .byClipping
titleLabel.setContentCompressionResistancePriority(.required, for: .vertical)
let subtitleLabel = WildReportUI.label(subtitle, font: .systemFont(ofSize: 12, weight: .regular), color: AppColor.textSecondary)
subtitleLabel.textAlignment = .center
subtitleLabel.adjustsFontSizeToFitWidth = true
subtitleLabel.minimumScaleFactor = 0.74
subtitleLabel.lineBreakMode = .byClipping
subtitleLabel.setContentCompressionResistancePriority(.required, for: .vertical)
addSubview(iconWrap)
addSubview(titleLabel)
addSubview(subtitleLabel)
iconWrap.snp.makeConstraints { make in
make.top.equalToSuperview().offset(14)
make.centerX.equalToSuperview()
make.size.equalTo(44)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(iconWrap.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(6)
make.height.equalTo(20)
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview().inset(6)
make.height.equalTo(16)
}
snp.makeConstraints { make in
make.height.equalTo(118)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
final class WildReportRuleViewController: BaseViewController {
private let stack = UIStackView()
override func setupNavigationBar() {
title = "举报规则"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackgroundSoft
stack.axis = .vertical
stack.spacing = AppSpacing.sm
view.addSubview(stack)
addSection(title: "可举报行为", items: ["疑似打野摄影师主动揽客", "未佩戴工牌开展摄影服务", "引导游客线下付款或私下交易"])
addSection(title: "材料要求", items: ["尽量提供现场图片、视频、位置和文字说明", "联系方式可选填,用于景区工作人员核实"])
addSection(title: "处理说明", items: ["举报提交后景区公安会接收线索", "处理进度可在“我的举报”中查看", "请勿提交虚假或恶意举报"])
}
override func setupConstraints() {
stack.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
}
private func addSection(title: String, items: [String]) {
let card = WildReportCardView(spacing: AppSpacing.sm)
card.stack.addArrangedSubview(WildReportUI.label(title, font: .app(.title), color: AppColor.textPrimary))
items.forEach { item in
card.stack.addArrangedSubview(WildReportUI.label("· \(item)", font: .app(.body), color: AppColor.textSecondary, lines: 0))
}
stack.addArrangedSubview(card)
}
}

View File

@ -0,0 +1,356 @@
//
// WildPhotographerReportListViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class WildPhotographerReportListViewController: BaseViewController {
private let viewModel: WildPhotographerReportListViewModel
private let segmentedControl = UISegmentedControl(items: WildReportListFilter.allCases.map(\.rawValue))
private let tableView = UITableView(frame: .zero, style: .plain)
init(homeViewModel: WildPhotographerReportHomeViewModel) {
self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "我的举报"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackgroundSoft
segmentedControl.selectedSegmentIndex = 0
segmentedControl.addTarget(self, action: #selector(filterChanged), for: .valueChanged)
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.delegate = self
tableView.dataSource = self
tableView.register(WildReportListCell.self, forCellReuseIdentifier: WildReportListCell.reuseIdentifier)
tableView.contentInset = UIEdgeInsets(top: AppSpacing.sm, left: 0, bottom: AppSpacing.lg, right: 0)
view.addSubview(segmentedControl)
view.addSubview(tableView)
}
override func setupConstraints() {
segmentedControl.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.height.equalTo(36)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(segmentedControl.snp.bottom).offset(AppSpacing.xs)
make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.tableView.reloadData() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
}
@objc private func filterChanged() {
let filter = WildReportListFilter.allCases[segmentedControl.selectedSegmentIndex]
viewModel.selectFilter(filter)
}
}
extension WildPhotographerReportListViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let count = viewModel.filteredReports.count
tableView.backgroundView = count == 0 ? WildReportEmptyView(text: "暂无相关举报记录") : nil
return count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: WildReportListCell.reuseIdentifier, for: indexPath) as! WildReportListCell
let record = viewModel.filteredReports[indexPath.row]
cell.apply(record: record)
cell.onShare = { [weak self] in self?.viewModel.shareReport(record) }
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let record = viewModel.filteredReports[indexPath.row]
navigationController?.pushViewController(
WildPhotographerReportDetailViewController(record: record, homeViewModel: viewModel.homeViewModel),
animated: true
)
}
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
UITableView.automaticDimension
}
}
/// Cell
final class WildReportListCell: UITableViewCell {
static let reuseIdentifier = "WildReportListCell"
private let card = WildReportCardView(spacing: AppSpacing.sm)
private let titleLabel = UILabel()
private let statusView = WildReportStatusView()
private let typeLabel = UILabel()
private let locationLabel = UILabel()
private let timeLabel = UILabel()
private let descLabel = UILabel()
private let materialLabel = UILabel()
var onShare: (() -> Void)?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.addSubview(card)
card.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: AppSpacing.xs, left: AppSpacing.md, bottom: AppSpacing.xs, right: AppSpacing.md))
}
let header = UIStackView()
header.axis = .horizontal
header.alignment = .center
header.spacing = AppSpacing.sm
titleLabel.font = .app(.title)
titleLabel.textColor = AppColor.textPrimary
header.addArrangedSubview(titleLabel)
header.addArrangedSubview(UIView())
statusView.snp.makeConstraints { make in
make.width.equalTo(64)
make.height.equalTo(24)
}
header.addArrangedSubview(statusView)
[typeLabel, locationLabel, timeLabel, descLabel, materialLabel].forEach { label in
label.font = .app(.body)
label.textColor = AppColor.textSecondary
label.numberOfLines = 0
}
let shareButton = UIButton(type: .system)
shareButton.setTitle("分享", for: .normal)
shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal)
shareButton.titleLabel?.font = .app(.captionMedium)
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
card.stack.addArrangedSubview(header)
card.stack.addArrangedSubview(typeLabel)
card.stack.addArrangedSubview(locationLabel)
card.stack.addArrangedSubview(timeLabel)
card.stack.addArrangedSubview(descLabel)
card.stack.addArrangedSubview(materialLabel)
card.stack.addArrangedSubview(shareButton)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(record: WildReportRecord) {
titleLabel.text = record.title
statusView.apply(status: record.status)
typeLabel.text = "举报类型:\(record.reportType.rawValue)"
locationLabel.text = "位置:\(record.location)"
timeLabel.text = "提交时间:\(record.submitTime)"
descLabel.text = record.description
materialLabel.text = "材料:\(record.imageCount)张图片 \(record.videoCount)段视频"
}
@objc private func shareTapped() {
onShare?()
}
}
///
final class WildPhotographerReportDetailViewController: BaseViewController {
private let viewModel: WildPhotographerReportDetailViewModel
private let homeViewModel: WildPhotographerReportHomeViewModel
private let scrollView = UIScrollView()
private let stack = UIStackView()
init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel) {
self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel)
self.homeViewModel = homeViewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "举报详情"
navigationItem.rightBarButtonItem = UIBarButtonItem(
image: UIImage(systemName: "square.and.arrow.up"),
style: .plain,
target: self,
action: #selector(shareTapped)
)
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackgroundSoft
stack.axis = .vertical
stack.spacing = AppSpacing.sm
view.addSubview(scrollView)
scrollView.addSubview(stack)
rebuildContent()
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
stack.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)
}
}
override func bindActions() {
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(makeHeaderCard())
stack.addArrangedSubview(makeProgressCard())
stack.addArrangedSubview(makeEvidenceCard())
if viewModel.showHandlerInfo {
stack.addArrangedSubview(makeHandlerCard())
}
stack.addArrangedSubview(makeSupplementCard())
stack.addArrangedSubview(makeActionButtons())
}
private func makeHeaderCard() -> 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)
}
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))
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))
}
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)
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))
return card
}
private func makeSupplementCard() -> UIView {
let card = WildReportCardView(spacing: AppSpacing.sm)
card.stack.addArrangedSubview(WildReportUI.label("补充证据记录", font: .app(.title), 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))
}
}
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
}
@objc private func shareTapped() { viewModel.shareReport() }
@objc private func previewTapped() { viewModel.showMediaPreview(kind: "图片/视频") }
@objc private func supplementTapped() {
navigationController?.pushViewController(
WildReportSupplementEvidenceViewController(reportID: viewModel.record.id, homeViewModel: homeViewModel),
animated: true
)
}
@objc private func mapTapped() {
navigationController?.pushViewController(
WildReportRiskMapViewController(record: viewModel.record, riskPoints: homeViewModel.riskPoints),
animated: true
)
}
}

View File

@ -0,0 +1,330 @@
//
// WildPhotographerReportSubmitViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class WildPhotographerReportSubmitViewController: BaseViewController {
private let viewModel: WildPhotographerReportSubmitViewModel
private let homeViewModel: WildPhotographerReportHomeViewModel
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let descriptionView = UITextView()
private let contactField = UITextField()
private let submitButton = AppButton(title: "提交举报")
init(
homeViewModel: WildPhotographerReportHomeViewModel,
locationProvider: any LocationProviding = LocationProvider.shared
) {
self.homeViewModel = homeViewModel
self.viewModel = WildPhotographerReportSubmitViewModel(
homeViewModel: homeViewModel,
locationProvider: locationProvider
)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "提交举报"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackgroundSoft
scrollView.keyboardDismissMode = .interactive
contentStack.axis = .vertical
contentStack.spacing = AppSpacing.sm
descriptionView.font = .app(.body)
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.delegate = self
contactField.placeholder = "微信号或手机号(选填)"
contactField.font = .app(.body)
contactField.textColor = AppColor.textPrimary
contactField.backgroundColor = AppColor.inputBackground
contactField.layer.cornerRadius = AppRadius.md
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.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)
}
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)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.rebuildContent() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onSubmitSuccess = { [weak self] result in
Task { @MainActor in
guard let self else { return }
self.navigationController?.pushViewController(
WildPhotographerReportSuccessViewController(
result: result,
homeViewModel: self.homeViewModel
),
animated: true
)
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
viewModel.loadInitialLocationIfNeeded()
}
@MainActor
private func rebuildContent() {
contentStack.arrangedSubviews.forEach { view in
contentStack.removeArrangedSubview(view)
view.removeFromSuperview()
}
contentStack.addArrangedSubview(makeTipsCard())
contentStack.addArrangedSubview(makeTypeCard())
contentStack.addArrangedSubview(makeEvidenceCard())
contentStack.addArrangedSubview(makeDescriptionCard())
contentStack.addArrangedSubview(makeLocationCard())
contentStack.addArrangedSubview(makeContactCard())
contentStack.addArrangedSubview(makePaymentCard())
}
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 makeTypeCard() -> UIView {
let card = sectionCard(index: 1, title: "选择举报类型")
let row = UIStackView()
row.axis = .vertical
row.spacing = AppSpacing.xs
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)
button.addAction(UIAction { [weak self] _ in self?.viewModel.selectReportType(type) }, for: .touchUpInside)
button.snp.makeConstraints { make in make.height.equalTo(38) }
row.addArrangedSubview(button)
}
card.stack.addArrangedSubview(row)
return card
}
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)
return card
}
private func makeDescriptionCard() -> UIView {
let card = sectionCard(index: 3, title: "举报说明")
descriptionView.text = viewModel.description
card.stack.addArrangedSubview(descriptionView)
descriptionView.snp.makeConstraints { make in
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)
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)
button.isEnabled = !viewModel.isUpdatingLocation
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)
return card
}
private func makeContactCard() -> UIView {
let card = sectionCard(index: 5, title: "联系方式(选填)")
contactField.text = viewModel.contact
card.stack.addArrangedSubview(contactField)
contactField.snp.makeConstraints { make in make.height.equalTo(46) }
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
}
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)
}
}
@objc private func addImageTapped() { viewModel.addImage() }
@objc private func addVideoTapped() { viewModel.addVideo() }
@objc private func addPaymentTapped() { viewModel.addPaymentImage() }
@objc private func refreshLocationTapped() { viewModel.refreshCurrentLocation() }
@objc private func submitTapped() { viewModel.submitReport() }
@objc private func contactChanged() { viewModel.updateContact(contactField.text ?? "") }
}
extension WildPhotographerReportSubmitViewController: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
viewModel.updateDescription(textView.text)
}
}
///
final class WildPhotographerReportSuccessViewController: BaseViewController {
private let viewModel: WildPhotographerReportSuccessViewModel
private let homeViewModel: WildPhotographerReportHomeViewModel
private let stack = UIStackView()
init(result: WildReportSubmitResult, homeViewModel: WildPhotographerReportHomeViewModel) {
self.viewModel = WildPhotographerReportSuccessViewModel(result: result)
self.homeViewModel = homeViewModel
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "举报成功"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackgroundSoft
stack.axis = .vertical
stack.spacing = AppSpacing.sm
view.addSubview(stack)
let hero = WildReportCardView(spacing: AppSpacing.md)
hero.stack.alignment = .center
let icon = UIImageView(image: UIImage(systemName: "checkmark.circle.fill"))
icon.tintColor = AppColor.success
icon.snp.makeConstraints { make in make.size.equalTo(58) }
hero.stack.addArrangedSubview(icon)
hero.stack.addArrangedSubview(WildReportUI.label("举报已提交", font: .app(.metric), color: AppColor.textPrimary))
let desc = WildReportUI.label("景区公安已收到线索,将尽快前往现场核实处理", font: .app(.body), color: AppColor.textSecondary, lines: 0)
desc.textAlignment = .center
hero.stack.addArrangedSubview(desc)
stack.addArrangedSubview(hero)
let info = WildReportCardView(spacing: AppSpacing.sm)
info.stack.addArrangedSubview(WildReportInfoRowView(title: "举报编号", value: viewModel.result.record.id))
info.stack.addArrangedSubview(WildReportInfoRowView(title: "上传材料", value: viewModel.uploadSummary))
info.stack.addArrangedSubview(WildReportInfoRowView(title: "提交时间", value: viewModel.result.record.submitTime))
stack.addArrangedSubview(info)
let progress = WildReportCardView(spacing: AppSpacing.sm)
progress.stack.addArrangedSubview(WildReportUI.label("处理进度", font: .app(.title), color: AppColor.textPrimary))
viewModel.progressSteps.enumerated().forEach { index, title in
progress.stack.addArrangedSubview(WildReportUI.label("\(index + 1). \(title)", font: .app(.body), color: AppColor.textSecondary))
}
stack.addArrangedSubview(progress)
let detailButton = AppButton(title: "查看处理进度")
detailButton.addTarget(self, action: #selector(detailTapped), for: .touchUpInside)
let homeButton = AppButton(title: "返回首页", style: .secondary)
homeButton.addTarget(self, action: #selector(homeTapped), for: .touchUpInside)
stack.addArrangedSubview(detailButton)
stack.addArrangedSubview(homeButton)
}
override func setupConstraints() {
stack.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
}
@objc private func detailTapped() {
navigationController?.pushViewController(
WildPhotographerReportDetailViewController(record: viewModel.result.record, homeViewModel: homeViewModel),
animated: true
)
}
@objc private func homeTapped() {
navigationController?.popToViewController(
navigationController?.viewControllers.first(where: { $0 is WildPhotographerReportHomeViewController }) ?? self,
animated: true
)
}
}

View File

@ -0,0 +1,237 @@
//
// WildReportRiskMapViewController.swift
// suixinkan
//
import MapKit
import SnapKit
import UIKit
/// 线
final class WildReportRiskMapViewController: BaseViewController {
private let viewModel: WildReportRiskMapViewModel
private let mapView = MKMapView()
private let panel = WildReportCardView(spacing: AppSpacing.sm)
private var annotationMap: [String: WildReportMapMarker] = [:]
init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) {
self.viewModel = WildReportRiskMapViewModel(record: record, riskPoints: riskPoints)
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
mapView.delegate = self
mapView.showsCompass = false
mapView.showsScale = true
view.addSubview(mapView)
view.addSubview(panel)
addLegend()
applyMap()
rebuildPanel()
}
override func setupConstraints() {
mapView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.height.equalTo(340)
}
panel.snp.makeConstraints { make in
make.top.equalTo(mapView.snp.bottom).offset(AppSpacing.sm)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).inset(AppSpacing.sm)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in
self?.mapView.setRegion(self?.viewModel.region ?? MKCoordinateRegion(), animated: true)
self?.rebuildPanel()
}
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
}
private func applyMap() {
mapView.setRegion(viewModel.region, animated: false)
annotationMap.removeAll()
let annotations = viewModel.markers.map { marker in
annotationMap[marker.id] = marker
let annotation = WildReportMapAnnotation(marker: marker)
return annotation
}
mapView.addAnnotations(annotations)
}
private func addLegend() {
let legend = UIStackView()
legend.axis = .horizontal
legend.distribution = .fillEqually
legend.backgroundColor = UIColor.white.withAlphaComponent(0.94)
legend.layer.cornerRadius = AppRadius.md
legend.clipsToBounds = true
[
WildReportMapMarkerKind.selfLocation,
.wildPhotographer,
.processingClue,
.peerPhotographer
].forEach { kind in
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 4
let dot = UIView()
dot.backgroundColor = kind.color
dot.layer.cornerRadius = 4
dot.snp.makeConstraints { make in make.size.equalTo(8) }
let label = WildReportUI.label(kind.title, font: .app(.caption), color: AppColor.textSecondary)
row.addArrangedSubview(dot)
row.addArrangedSubview(label)
legend.addArrangedSubview(row)
}
view.addSubview(legend)
legend.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
make.height.equalTo(36)
}
}
@MainActor
private func rebuildPanel() {
panel.stack.arrangedSubviews.forEach { view in
panel.stack.removeArrangedSubview(view)
view.removeFromSuperview()
}
guard let marker = viewModel.selectedMarker,
case .activeClue(let clue) = marker.detail else {
panel.stack.addArrangedSubview(WildReportUI.label("点击地图标记查看详情", font: .app(.body), color: AppColor.textSecondary))
return
}
let header = UIStackView()
header.axis = .horizontal
header.alignment = .center
let title = WildReportUI.label(clue.displayTitle, font: .app(.title), color: AppColor.textPrimary, lines: 0)
let badge = UILabel()
badge.text = clue.statusText
badge.font = .app(.captionMedium)
badge.textColor = marker.kind.color
badge.textAlignment = .center
badge.backgroundColor = marker.kind.color.withAlphaComponent(0.12)
badge.layer.cornerRadius = 12
badge.clipsToBounds = true
badge.snp.makeConstraints { make in
make.width.greaterThanOrEqualTo(74)
make.height.equalTo(24)
}
header.addArrangedSubview(title)
header.addArrangedSubview(UIView())
header.addArrangedSubview(badge)
panel.stack.addArrangedSubview(header)
if let nearest = clue.nearestAbnormalText {
panel.stack.addArrangedSubview(WildReportUI.label(nearest, font: .app(.bodyMedium), color: AppColor.danger, lines: 0))
}
if let name = clue.name {
panel.stack.addArrangedSubview(WildReportInfoRowView(title: "点位", value: name))
}
if let store = clue.storeName {
panel.stack.addArrangedSubview(WildReportInfoRowView(title: "门店", value: store))
}
if let distance = clue.distanceText ?? clue.distance {
panel.stack.addArrangedSubview(WildReportInfoRowView(title: "距离", value: distance))
}
if let updatedAt = clue.updatedAt {
panel.stack.addArrangedSubview(WildReportInfoRowView(title: "时间", value: updatedAt))
}
if let summary = clue.summary ?? clue.detail {
panel.stack.addArrangedSubview(WildReportUI.label(summary, font: .app(.body), color: AppColor.textSecondary, lines: 0))
}
clue.reportContents.prefix(2).forEach { content in
panel.stack.addArrangedSubview(WildReportUI.label("\(content.time) \(content.content)", font: .app(.caption), color: AppColor.textSecondary, lines: 0))
}
if let record = clue.record {
panel.stack.addArrangedSubview(WildReportInfoRowView(title: "处理人", value: record.handlerName))
panel.stack.addArrangedSubview(WildReportUI.label(record.processResult, font: .app(.body), color: AppColor.textSecondary, lines: 0))
}
let buttonRow = UIStackView()
buttonRow.axis = .horizontal
buttonRow.spacing = AppSpacing.sm
buttonRow.distribution = .fillEqually
let navButton = WildReportUI.iconButton(title: clue.actionText, imageName: "location.fill", style: .secondary)
navButton.addTarget(self, action: #selector(navigateTapped), for: .touchUpInside)
let shareButton = WildReportUI.iconButton(title: "分享", imageName: "square.and.arrow.up", style: .secondary)
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
[navButton, shareButton].forEach { button in
button.snp.makeConstraints { make in make.height.equalTo(42) }
buttonRow.addArrangedSubview(button)
}
panel.stack.addArrangedSubview(buttonRow)
}
@objc private func navigateTapped() {
viewModel.navigateToSelectedMarker()
}
@objc private func shareTapped() {
viewModel.shareSelectedClue()
}
}
extension WildReportRiskMapViewController: MKMapViewDelegate {
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard let annotation = annotation as? WildReportMapAnnotation else { return nil }
let identifier = "WildReportMapAnnotationView"
let view = mapView.dequeueReusableAnnotationView(withIdentifier: identifier)
?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: identifier)
view.annotation = annotation
if let markerView = view as? MKMarkerAnnotationView {
markerView.markerTintColor = annotation.marker.kind.color
markerView.glyphImage = UIImage(systemName: glyphName(for: annotation.marker.kind))
markerView.glyphTintColor = .white
markerView.canShowCallout = false
markerView.animatesWhenAdded = true
}
return view
}
func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) {
guard let annotation = annotation as? WildReportMapAnnotation else { return }
viewModel.selectMarker(annotation.marker)
}
private func glyphName(for kind: WildReportMapMarkerKind) -> String {
switch kind {
case .selfLocation: return "location.fill"
case .wildPhotographer: return "exclamationmark"
case .processingClue: return "clock.fill"
case .peerPhotographer: return "camera.fill"
}
}
}
/// MKAnnotation
final class WildReportMapAnnotation: NSObject, MKAnnotation {
let marker: WildReportMapMarker
var coordinate: CLLocationCoordinate2D { marker.coordinate }
var title: String? { marker.title }
init(marker: WildReportMapMarker) {
self.marker = marker
super.init()
}
}

View File

@ -0,0 +1,128 @@
//
// WildReportSupplementEvidenceViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Mock
final class WildReportSupplementEvidenceViewController: BaseViewController {
private let viewModel: WildReportSupplementEvidenceViewModel
private let scrollView = UIScrollView()
private let stack = UIStackView()
private let textView = UITextView()
private let submitButton = AppButton(title: "提交补充证据")
init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) {
self.viewModel = WildReportSupplementEvidenceViewModel(reportID: reportID, homeViewModel: homeViewModel)
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "补充证据"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackgroundSoft
scrollView.keyboardDismissMode = .interactive
stack.axis = .vertical
stack.spacing = AppSpacing.sm
textView.font = .app(.body)
textView.textColor = AppColor.textPrimary
textView.backgroundColor = AppColor.inputBackground
textView.layer.cornerRadius = AppRadius.md
textView.textContainerInset = UIEdgeInsets(top: 12, left: 10, bottom: 12, right: 10)
textView.delegate = self
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
view.addSubview(scrollView)
scrollView.addSubview(stack)
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)
}
stack.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)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.rebuildContent() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onSubmitSuccess = { [weak self] in
Task { @MainActor in
self?.showToast("补充证据已提交")
self?.navigationController?.popViewController(animated: true)
}
}
}
@MainActor
private func rebuildContent() {
stack.arrangedSubviews.forEach { view in
stack.removeArrangedSubview(view)
view.removeFromSuperview()
}
let textCard = WildReportCardView(spacing: AppSpacing.sm)
textCard.stack.addArrangedSubview(WildReportUI.label("补充说明", font: .app(.title), color: AppColor.textPrimary))
textView.text = viewModel.text
textCard.stack.addArrangedSubview(textView)
textView.snp.makeConstraints { make in make.height.equalTo(150) }
let count = WildReportUI.label("\(viewModel.text.count)/500", font: .app(.caption), color: AppColor.textTertiary)
count.textAlignment = .right
textCard.stack.addArrangedSubview(count)
stack.addArrangedSubview(textCard)
let attachmentCard = WildReportCardView(spacing: AppSpacing.sm)
attachmentCard.stack.addArrangedSubview(WildReportUI.label("补充材料", font: .app(.title), color: AppColor.textPrimary))
let row = UIStackView()
row.axis = .horizontal
row.spacing = AppSpacing.sm
row.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) }
row.addArrangedSubview(button)
}
attachmentCard.stack.addArrangedSubview(row)
(viewModel.images + viewModel.videos).forEach { attachment in
let chip = WildReportAttachmentChipView(attachment: attachment)
chip.onRemove = { [weak self] in self?.viewModel.removeAttachment(attachment) }
attachmentCard.stack.addArrangedSubview(chip)
}
stack.addArrangedSubview(attachmentCard)
}
@objc private func addImageTapped() { viewModel.addImage() }
@objc private func addVideoTapped() { viewModel.addVideo() }
@objc private func submitTapped() { viewModel.submit() }
}
extension WildReportSupplementEvidenceViewController: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
viewModel.updateText(textView.text)
}
}