Files
suixinkan_uikit/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift
汉秋 efb3925257 完善首页权限申请流程与队列播报体验。
对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:46:45 +08:00

503 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// WildPhotographerReportHomeViewController.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class WildPhotographerReportHomeViewController: BaseViewController {
private let viewModel: WildPhotographerReportHomeViewModel
private let api: any WildPhotographerReportServing
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
init(
viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel(),
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
) {
self.viewModel = viewModel
self.api = api
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "举报摄影师"
navigationItem.largeTitleDisplayMode = .never
}
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() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
}
override func viewDidLoad() {
super.viewDidLoad()
Task {
await viewModel.loadReportCopy(api: api)
}
}
@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())
if viewModel.shouldShowNoticeModule {
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))
case .error:
contentStack.addArrangedSubview(makeStateCard(icon: "wifi.exclamationmark", title: "加载失败", message: "举报服务暂时不可用,请稍后重试。"))
}
}
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)
header.addArrangedSubview(headerIcon)
header.addArrangedSubview(title)
header.addArrangedSubview(UIView())
if viewModel.shouldShowRuleButton {
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.setConfigurationContentInsets(
NSDirectionalEdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12)
)
ruleButton.addTarget(self, action: #selector(ruleTapped), for: .touchUpInside)
ruleButton.snp.makeConstraints { make in
make.height.equalTo(36)
}
header.addArrangedSubview(ruleButton)
}
card.stack.addArrangedSubview(header)
viewModel.noticeItems.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.xs
[
("实名", "身份核验 真实可靠", "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() {
navigationController?.pushViewController(
WildReportRiskMapViewController(),
animated: true
)
}
@objc private func ruleTapped() {
navigationController?.pushViewController(WildReportRuleViewController(ruleGroups: viewModel.ruleGroups), 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 scrollView = UIScrollView()
private let stack = UIStackView()
private let ruleGroups: [WildReportRuleGroup]
init(ruleGroups: [WildReportRuleGroup]) {
self.ruleGroups = ruleGroups
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.alwaysBounceVertical = true
scrollView.showsVerticalScrollIndicator = false
stack.axis = .vertical
stack.spacing = AppSpacing.sm
view.addSubview(scrollView)
scrollView.addSubview(stack)
ruleGroups.forEach { group in
addSection(title: group.title, items: group.items)
}
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
stack.snp.makeConstraints { make in
make.edges.equalTo(scrollView.contentLayoutGuide).inset(AppSpacing.md)
make.width.equalTo(scrollView.frameLayoutGuide).offset(-AppSpacing.md * 2)
}
}
private func addSection(title: String, items: [String]) {
let card = WildReportCardView(spacing: AppSpacing.sm)
if !title.isEmpty {
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)
}
}