feat: add wild photographer report mock flow
This commit is contained in:
@ -28,6 +28,7 @@ enum HomeMenuCatalog {
|
||||
HomeMenuItem(uri: "pm", title: "项目管理", iconName: "folder"),
|
||||
HomeMenuItem(uri: "pm_manager", title: "项目管理", iconName: "folder"),
|
||||
HomeMenuItem(uri: "location_report", title: "位置上报", iconName: "location"),
|
||||
HomeMenuItem(uri: "report_photographer", title: "举报摄影师", iconName: "exclamationmark.shield"),
|
||||
HomeMenuItem(uri: "registration_invitation", title: "注册邀请", iconName: "person.badge.plus"),
|
||||
HomeMenuItem(uri: "photographer_invite", title: "邀请摄影师", iconName: "person.badge.plus"),
|
||||
HomeMenuItem(uri: "invite_record", title: "邀请记录", iconName: "calendar.badge.clock"),
|
||||
|
||||
@ -50,6 +50,8 @@ enum HomeRouteHandler {
|
||||
viewController.navigationController?.pushViewController(CooperationOrderListViewController(), animated: true)
|
||||
case "location_report":
|
||||
viewController.navigationController?.pushViewController(LocationReportViewController(), animated: true)
|
||||
case "report_photographer":
|
||||
viewController.navigationController?.pushViewController(WildPhotographerReportHomeViewController(), animated: true)
|
||||
case "registration_invitation", "photographer_invite":
|
||||
viewController.navigationController?.pushViewController(PhotographerInviteViewController(), animated: true)
|
||||
case "invite_record":
|
||||
|
||||
@ -0,0 +1,349 @@
|
||||
//
|
||||
// WildPhotographerReportModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import MapKit
|
||||
import UIKit
|
||||
|
||||
/// 举报摄影师页面状态,用于演示加载、空态和异常态。
|
||||
enum WildReportPageState: String, CaseIterable, Hashable {
|
||||
case normal
|
||||
case loading
|
||||
case empty
|
||||
case error
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .normal: return "正常"
|
||||
case .loading: return "加载"
|
||||
case .empty: return "空状态"
|
||||
case .error: return "异常"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报附件类型,区分现场图片、视频和支付截图。
|
||||
enum WildReportAttachmentKind: Hashable {
|
||||
case image
|
||||
case video
|
||||
case payment
|
||||
}
|
||||
|
||||
/// 举报类型,用于提交页单选并写入 Mock 举报记录。
|
||||
enum WildReportType: String, CaseIterable, Hashable {
|
||||
case suspectedWild = "疑似打野"
|
||||
case noWorkBadge = "未戴工牌"
|
||||
case other = "其他"
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .suspectedWild: return "person.crop.circle.badge.exclamationmark"
|
||||
case .noWorkBadge: return "lanyardcard"
|
||||
case .other: return "ellipsis.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报附件实体,表示一条演示用的上传项。
|
||||
struct WildReportAttachment: Hashable {
|
||||
let id: UUID
|
||||
let kind: WildReportAttachmentKind
|
||||
let title: String
|
||||
|
||||
init(id: UUID = UUID(), kind: WildReportAttachmentKind, title: String) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.title = title
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报处理状态。
|
||||
enum WildReportStatus: String, Hashable, CaseIterable {
|
||||
case pending = "待处理"
|
||||
case processing = "处理中"
|
||||
case processed = "已处理"
|
||||
|
||||
var displayColor: UIColor {
|
||||
switch self {
|
||||
case .pending: return AppColor.warning
|
||||
case .processing: return AppColor.primary
|
||||
case .processed: return AppColor.success
|
||||
}
|
||||
}
|
||||
|
||||
var backgroundColor: UIColor {
|
||||
switch self {
|
||||
case .pending: return AppColor.warningBackground
|
||||
case .processing: return AppColor.infoBackground
|
||||
case .processed: return AppColor.successBackground
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 我的举报列表筛选条件。
|
||||
enum WildReportListFilter: String, CaseIterable, Hashable {
|
||||
case all = "全部"
|
||||
case pending = "待处理"
|
||||
case processing = "处理中"
|
||||
case processed = "已处理"
|
||||
}
|
||||
|
||||
/// 补充证据记录实体。
|
||||
struct WildReportSupplementaryEvidence: Hashable {
|
||||
let id: String
|
||||
let submitTime: String
|
||||
let text: String
|
||||
let imageCount: Int
|
||||
let videoCount: Int
|
||||
|
||||
var summaryText: String {
|
||||
var parts: [String] = []
|
||||
if !text.isEmpty { parts.append(text) }
|
||||
if imageCount > 0 { parts.append("\(imageCount)张图片") }
|
||||
if videoCount > 0 { parts.append("\(videoCount)段视频") }
|
||||
return parts.isEmpty ? "补充证据" : parts.joined(separator: " · ")
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报位置解析工具。
|
||||
enum WildReportLocationParser {
|
||||
/// 将「景区 · 详细地址」拆成两段展示文案。
|
||||
static func split(_ location: String) -> (scenic: String, detail: String) {
|
||||
let parts = location
|
||||
.components(separatedBy: " · ")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
if parts.count >= 2 {
|
||||
return (parts[0], parts[1])
|
||||
}
|
||||
return (location, location)
|
||||
}
|
||||
}
|
||||
|
||||
/// 附近风险点实体。
|
||||
struct WildReportRiskPoint: Hashable {
|
||||
let id: Int
|
||||
let title: String
|
||||
let location: String
|
||||
let distance: String
|
||||
let riskLevel: String
|
||||
let evidenceCount: Int
|
||||
let latestTime: String
|
||||
let description: String
|
||||
}
|
||||
|
||||
/// 举报记录实体。
|
||||
struct WildReportRecord: Hashable {
|
||||
let id: String
|
||||
let title: String
|
||||
let reportType: WildReportType
|
||||
let status: WildReportStatus
|
||||
let location: String
|
||||
let submitTime: String
|
||||
let description: String
|
||||
let wechatID: String
|
||||
let phoneNumber: String
|
||||
let imageCount: Int
|
||||
let videoCount: Int
|
||||
let handlerInfo: WildReportHandlerInfo?
|
||||
|
||||
var contactDisplayText: String {
|
||||
switch (wechatID.isEmpty, phoneNumber.isEmpty) {
|
||||
case (true, true): return "未填写"
|
||||
case (false, false): return "\(wechatID) / \(phoneNumber)"
|
||||
case (false, true): return wechatID
|
||||
case (true, false): return phoneNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理完成后的媒体项。
|
||||
struct WildReportCompletionMediaItem: Hashable {
|
||||
enum Kind: Hashable {
|
||||
case image
|
||||
case video
|
||||
}
|
||||
|
||||
let id: String
|
||||
let kind: Kind
|
||||
let coverURL: String
|
||||
let duration: String?
|
||||
}
|
||||
|
||||
/// 举报处理人信息实体。
|
||||
struct WildReportHandlerInfo: Hashable {
|
||||
let handlerName: String
|
||||
let handlerPhone: String
|
||||
let processingAt: String
|
||||
let processedAt: String
|
||||
let processOpinion: String
|
||||
let completionMedia: [WildReportCompletionMediaItem]
|
||||
|
||||
var maskedHandlerPhone: String {
|
||||
guard handlerPhone.count == 11, handlerPhone.allSatisfy(\.isNumber) else { return handlerPhone }
|
||||
return "\(handlerPhone.prefix(3))****\(handlerPhone.suffix(4))"
|
||||
}
|
||||
|
||||
var completionMediaSummary: String {
|
||||
let imageCount = completionMedia.filter { $0.kind == .image }.count
|
||||
let videoCount = completionMedia.filter { $0.kind == .video }.count
|
||||
switch (imageCount, videoCount) {
|
||||
case (0, 0): return "0项"
|
||||
case (_, 0): return "\(imageCount)张"
|
||||
case (0, _): return "\(videoCount)段"
|
||||
default: return "\(imageCount)张 \(videoCount)段"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报进度节点状态。
|
||||
enum WildReportProgressStepState: Hashable {
|
||||
case completed
|
||||
case current
|
||||
case pending
|
||||
}
|
||||
|
||||
/// 举报进度时间轴节点。
|
||||
struct WildReportProgressStep: Hashable {
|
||||
let id: String
|
||||
let title: String
|
||||
let timeText: String
|
||||
let state: WildReportProgressStepState
|
||||
}
|
||||
|
||||
/// 举报人材料预览项。
|
||||
enum WildReportReporterMediaItem: Hashable {
|
||||
case image(index: Int)
|
||||
case video(index: Int)
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .image(let index): return "reporter-image-\(index)"
|
||||
case .video(let index): return "reporter-video-\(index)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报提交结果,用于成功页展示。
|
||||
struct WildReportSubmitResult: Hashable {
|
||||
let record: WildReportRecord
|
||||
let imageCount: Int
|
||||
let videoCount: Int
|
||||
}
|
||||
|
||||
/// 风险地图标记类型。
|
||||
enum WildReportMapMarkerKind: Hashable {
|
||||
case selfLocation
|
||||
case wildPhotographer
|
||||
case processingClue
|
||||
case peerPhotographer
|
||||
|
||||
var color: UIColor {
|
||||
switch self {
|
||||
case .selfLocation: return AppColor.primary
|
||||
case .wildPhotographer: return AppColor.danger
|
||||
case .processingClue: return AppColor.warning
|
||||
case .peerPhotographer: return AppColor.success
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .selfLocation: return "我的位置"
|
||||
case .wildPhotographer: return "疑似打野"
|
||||
case .processingClue: return "处理中线索"
|
||||
case .peerPhotographer: return "内部摄影师"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 雷达地图标记颜色类型。
|
||||
enum WildReportRadarMarkerType: String, Hashable {
|
||||
case blue
|
||||
case green
|
||||
case red
|
||||
}
|
||||
|
||||
/// 雷达线索举报内容条目。
|
||||
struct WildReportRadarReportContent: Hashable {
|
||||
let time: String
|
||||
let content: String
|
||||
}
|
||||
|
||||
/// 雷达线索处理记录。
|
||||
struct WildReportRadarProcessRecord: Hashable {
|
||||
let handlerName: String
|
||||
let phone: String
|
||||
let maskedPhone: String
|
||||
let statusText: String
|
||||
let processResult: String
|
||||
let processedAt: String?
|
||||
let processingAt: String?
|
||||
}
|
||||
|
||||
/// 雷达线索处理时间轴节点。
|
||||
struct WildReportRadarProcessingTimelineItem: Hashable {
|
||||
let title: String
|
||||
let time: String
|
||||
let status: String
|
||||
}
|
||||
|
||||
/// 雷达地图活动线索详情。
|
||||
struct WildReportRadarActiveClue: Hashable {
|
||||
let id: String
|
||||
let type: WildReportRadarMarkerType
|
||||
let displayTitle: String
|
||||
let statusText: String
|
||||
let actionText: String
|
||||
let nearestAbnormalText: String?
|
||||
let distance: String?
|
||||
let direction: String?
|
||||
let updatedAt: String?
|
||||
let name: String?
|
||||
let storeName: String?
|
||||
let avatar: String?
|
||||
let summary: String?
|
||||
let detail: String?
|
||||
let distanceText: String?
|
||||
let reporterName: String?
|
||||
let reporterPhone: String?
|
||||
let maskedReporterPhone: String?
|
||||
let reportContents: [WildReportRadarReportContent]
|
||||
let images: [String]
|
||||
let processingStarted: Bool
|
||||
let completed: Bool
|
||||
let record: WildReportRadarProcessRecord?
|
||||
let processingTimeline: [WildReportRadarProcessingTimelineItem]
|
||||
let completionPhoto: String?
|
||||
}
|
||||
|
||||
/// 风险地图标记详情。
|
||||
enum WildReportMapMarkerDetail: Hashable {
|
||||
case activeClue(WildReportRadarActiveClue)
|
||||
}
|
||||
|
||||
/// 风险地图标记实体。
|
||||
struct WildReportMapMarker: Hashable {
|
||||
let id: String
|
||||
let title: String
|
||||
let coordinate: CLLocationCoordinate2D
|
||||
let kind: WildReportMapMarkerKind
|
||||
let detail: WildReportMapMarkerDetail
|
||||
|
||||
static func == (lhs: WildReportMapMarker, rhs: WildReportMapMarker) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
}
|
||||
|
||||
extension CLLocationCoordinate2D: @retroactive Equatable {
|
||||
public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool {
|
||||
lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,604 @@
|
||||
//
|
||||
// WildPhotographerReportMockStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import MapKit
|
||||
|
||||
/// 举报摄影师 Mock 数据仓库,统一管理演示种子数据和地图标记构建。
|
||||
enum WildPhotographerReportMockStore {
|
||||
static let fallbackLocation = "黄山风景区 · 光明顶景区"
|
||||
|
||||
static let initialImages = [
|
||||
WildReportAttachment(kind: .image, title: "现场图片1")
|
||||
]
|
||||
|
||||
static let initialVideos = [
|
||||
WildReportAttachment(kind: .video, title: "现场视频1")
|
||||
]
|
||||
|
||||
static let seedRiskPoints = [
|
||||
WildReportRiskPoint(
|
||||
id: 1,
|
||||
title: "光明顶观景台附近",
|
||||
location: "黄山风景区 · 光明顶景区",
|
||||
distance: "120m",
|
||||
riskLevel: "高",
|
||||
evidenceCount: 6,
|
||||
latestTime: "10分钟前",
|
||||
description: "多名游客反馈有疑似未备案摄影师主动揽客,集中在观景台东侧步道附近。"
|
||||
),
|
||||
WildReportRiskPoint(
|
||||
id: 2,
|
||||
title: "迎客松游客出口",
|
||||
location: "黄山风景区 · 玉屏景区",
|
||||
distance: "860m",
|
||||
riskLevel: "中",
|
||||
evidenceCount: 3,
|
||||
latestTime: "32分钟前",
|
||||
description: "疑似人员以低价加急出片为话术引导游客线下付款,建议现场核查。"
|
||||
),
|
||||
WildReportRiskPoint(
|
||||
id: 3,
|
||||
title: "云谷索道排队区",
|
||||
location: "黄山风景区 · 云谷景区",
|
||||
distance: "1.4km",
|
||||
riskLevel: "低",
|
||||
evidenceCount: 2,
|
||||
latestTime: "1小时前",
|
||||
description: "排队区附近存在疑似私下拉客行为,已收到图片和位置线索。"
|
||||
)
|
||||
]
|
||||
|
||||
static let seedReports = [
|
||||
WildReportRecord(
|
||||
id: "JB20260618001",
|
||||
title: "摄影师举报",
|
||||
reportType: .suspectedWild,
|
||||
status: .processing,
|
||||
location: "那拉提景区 · 空中草原入口附近",
|
||||
submitTime: "2026-06-18 10:24",
|
||||
description: "疑似人员在观景台附近主动向游客售卖拍摄服务,并引导线下转账。",
|
||||
wechatID: "wx_photographer_01",
|
||||
phoneNumber: "13812345678",
|
||||
imageCount: 3,
|
||||
videoCount: 1,
|
||||
handlerInfo: nil
|
||||
),
|
||||
WildReportRecord(
|
||||
id: "JB20260617008",
|
||||
title: "摄影师举报",
|
||||
reportType: .suspectedWild,
|
||||
status: .processed,
|
||||
location: "那拉提景区 · 河谷草原游客中心",
|
||||
submitTime: "2026-06-17 16:42",
|
||||
description: "现场发现疑似未备案摄影师持续向游客推销旅拍套餐。",
|
||||
wechatID: "nalati_photo",
|
||||
phoneNumber: "",
|
||||
imageCount: 2,
|
||||
videoCount: 1,
|
||||
handlerInfo: processedHandlerInfo(
|
||||
handlerName: "赵强",
|
||||
handlerPhone: "15938267120",
|
||||
processingAt: "2026-06-17 17:10",
|
||||
processedAt: "2026-06-17 18:26",
|
||||
processOpinion: "已到场核查并完成劝离,处理结果已同步景区公安。",
|
||||
imageSeedPrefix: "handler-jb008"
|
||||
)
|
||||
),
|
||||
WildReportRecord(
|
||||
id: "JB20260616003",
|
||||
title: "摄影师举报",
|
||||
reportType: .noWorkBadge,
|
||||
status: .pending,
|
||||
location: "那拉提景区 · 盘龙古道停车区",
|
||||
submitTime: "2026-06-16 09:15",
|
||||
description: "停车区附近有摄影师未佩戴工牌开展拍摄服务,已收到图片和位置线索。",
|
||||
wechatID: "",
|
||||
phoneNumber: "13987654321",
|
||||
imageCount: 1,
|
||||
videoCount: 0,
|
||||
handlerInfo: nil
|
||||
),
|
||||
WildReportRecord(
|
||||
id: "JB20260615002",
|
||||
title: "摄影师举报",
|
||||
reportType: .other,
|
||||
status: .processed,
|
||||
location: "那拉提景区 · 雪莲谷步道",
|
||||
submitTime: "2026-06-15 14:30",
|
||||
description: "举报线索经核查未发现异常摄影师服务行为,已归档。",
|
||||
wechatID: "grassland_cam",
|
||||
phoneNumber: "13600001111",
|
||||
imageCount: 1,
|
||||
videoCount: 0,
|
||||
handlerInfo: processedHandlerInfo(
|
||||
handlerName: "李娜",
|
||||
handlerPhone: "18674598210",
|
||||
processingAt: "2026-06-15 15:05",
|
||||
processedAt: "2026-06-15 16:55",
|
||||
processOpinion: "现场核查未发现未备案摄影师,已归档处理。",
|
||||
imageSeedPrefix: "handler-jb002",
|
||||
includeVideo: false
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
static let seedSupplementaryEvidences: [String: [WildReportSupplementaryEvidence]] = [
|
||||
"JB20260618001": [
|
||||
WildReportSupplementaryEvidence(
|
||||
id: "SE001",
|
||||
submitTime: "2026-06-18 14:20",
|
||||
text: "补充现场视频,对方仍在揽客。",
|
||||
imageCount: 1,
|
||||
videoCount: 1
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
static let reportDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static let submitTimeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static let nalatiCenter = CLLocationCoordinate2D(latitude: 43.382, longitude: 84.032)
|
||||
|
||||
private static let clueID1 = "2026年6月22日-0001"
|
||||
private static let clueID2 = "2026年6月22日-0002"
|
||||
private static let clueID3 = "2026年6月22日-0003"
|
||||
private static let initialProcessingClueIDs: Set<String> = [clueID2]
|
||||
|
||||
/// 构建已处理举报的处理人信息 Mock 数据。
|
||||
static func processedHandlerInfo(
|
||||
handlerName: String,
|
||||
handlerPhone: String,
|
||||
processingAt: String,
|
||||
processedAt: String,
|
||||
processOpinion: String,
|
||||
imageSeedPrefix: String,
|
||||
includeVideo: Bool = true
|
||||
) -> WildReportHandlerInfo {
|
||||
var media: [WildReportCompletionMediaItem] = [
|
||||
WildReportCompletionMediaItem(
|
||||
id: "\(imageSeedPrefix)-img-1",
|
||||
kind: .image,
|
||||
coverURL: "https://picsum.photos/seed/\(imageSeedPrefix)-1/640/480",
|
||||
duration: nil
|
||||
),
|
||||
WildReportCompletionMediaItem(
|
||||
id: "\(imageSeedPrefix)-img-2",
|
||||
kind: .image,
|
||||
coverURL: "https://picsum.photos/seed/\(imageSeedPrefix)-2/640/480",
|
||||
duration: nil
|
||||
)
|
||||
]
|
||||
if includeVideo {
|
||||
media.append(
|
||||
WildReportCompletionMediaItem(
|
||||
id: "\(imageSeedPrefix)-vid-1",
|
||||
kind: .video,
|
||||
coverURL: "https://picsum.photos/seed/\(imageSeedPrefix)-video/640/480",
|
||||
duration: "00:18"
|
||||
)
|
||||
)
|
||||
}
|
||||
return WildReportHandlerInfo(
|
||||
handlerName: handlerName,
|
||||
handlerPhone: handlerPhone,
|
||||
processingAt: processingAt,
|
||||
processedAt: processedAt,
|
||||
processOpinion: processOpinion,
|
||||
completionMedia: media
|
||||
)
|
||||
}
|
||||
|
||||
/// 从已有举报编号中提取最大序号,供新编号递增。
|
||||
static func maxSequence(from reports: [WildReportRecord]) -> Int {
|
||||
reports.compactMap { record in
|
||||
guard record.id.count >= 3 else { return nil }
|
||||
return Int(record.id.suffix(3))
|
||||
}.max() ?? 0
|
||||
}
|
||||
|
||||
/// 解析联系方式,区分微信号和手机号。
|
||||
static func parseContact(_ contact: String?) -> (wechat: String, phone: String) {
|
||||
guard let contact else { return ("", "") }
|
||||
let trimmed = contact.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return ("", "") }
|
||||
|
||||
let digits = trimmed.filter(\.isNumber)
|
||||
if digits.count >= 7, trimmed.filter({ $0.isNumber || "+-() ".contains($0) }).count == trimmed.count {
|
||||
return ("", trimmed)
|
||||
}
|
||||
return (trimmed, "")
|
||||
}
|
||||
|
||||
/// 根据线索状态返回地图标记类型。
|
||||
static func markerKind(for clue: WildReportRadarActiveClue) -> WildReportMapMarkerKind {
|
||||
switch clue.type {
|
||||
case .blue:
|
||||
return .selfLocation
|
||||
case .green:
|
||||
return .peerPhotographer
|
||||
case .red:
|
||||
if clue.processingStarted && !clue.completed {
|
||||
return .processingClue
|
||||
}
|
||||
return .wildPhotographer
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建风险地图全部标记点。
|
||||
static func buildMarkers(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) -> [WildReportMapMarker] {
|
||||
_ = record
|
||||
_ = riskPoints
|
||||
|
||||
let blueCoordinate = CLLocationCoordinate2D(latitude: 43.3779, longitude: 84.0217)
|
||||
let redSeeds: [(id: String, name: String, coordinate: CLLocationCoordinate2D, clue: WildReportRadarActiveClue)] = [
|
||||
redSeed(
|
||||
id: clueID1,
|
||||
name: "空中草原观景道",
|
||||
coordinate: CLLocationCoordinate2D(latitude: 43.4068, longitude: 84.0582),
|
||||
updatedAt: "5分钟前",
|
||||
distanceText: "320米",
|
||||
reporterName: "张三",
|
||||
reporterPhone: "13333333333",
|
||||
contents: [
|
||||
"多人结伴揽客,疑似无资质摄影团队,携带专业器材在观景道附近活动。",
|
||||
"补充证据:对方仍在向游客推销拍照套餐,并拒绝出示相关资质。"
|
||||
],
|
||||
imageSeeds: ["nalati-radar-1", "nalati-radar-2", "nalati-radar-3"]
|
||||
),
|
||||
redSeed(
|
||||
id: clueID2,
|
||||
name: "塔吾萨尼步道",
|
||||
coordinate: CLLocationCoordinate2D(latitude: 43.3652, longitude: 84.0385),
|
||||
updatedAt: "18分钟前",
|
||||
distanceText: "780米",
|
||||
reporterName: "李四",
|
||||
reporterPhone: "18674598210",
|
||||
contents: [
|
||||
"疑似二次转移,仍在塔吾萨尼步道附近揽客拍照。",
|
||||
"补充:对方穿黑色外套,携带三脚架,在步道入口反复搭讪游客。"
|
||||
],
|
||||
imageSeeds: ["nalati-radar-4", "nalati-radar-5"]
|
||||
),
|
||||
redSeed(
|
||||
id: clueID3,
|
||||
name: "河谷草原西南侧",
|
||||
coordinate: CLLocationCoordinate2D(latitude: 43.3546, longitude: 84.0136),
|
||||
updatedAt: "昨天17:45",
|
||||
distanceText: "910米",
|
||||
reporterName: "王五",
|
||||
reporterPhone: "15938267120",
|
||||
contents: ["游客反馈有人在步道旁揽客拍照,行为较为可疑。"],
|
||||
imageSeeds: ["nalati-radar-8"]
|
||||
)
|
||||
]
|
||||
|
||||
let nearestText = nearestAbnormalText(
|
||||
blueCoordinate: blueCoordinate,
|
||||
redCoordinates: redSeeds.map(\.coordinate)
|
||||
)
|
||||
|
||||
var markers: [WildReportMapMarker] = [
|
||||
WildReportMapMarker(
|
||||
id: "ME",
|
||||
title: "河谷草原入口",
|
||||
coordinate: blueCoordinate,
|
||||
kind: .selfLocation,
|
||||
detail: .activeClue(
|
||||
WildReportRadarActiveClue(
|
||||
id: "ME",
|
||||
type: .blue,
|
||||
displayTitle: "当前位置:河谷草原入口",
|
||||
statusText: "当前位置",
|
||||
actionText: "刷新定位",
|
||||
nearestAbnormalText: nearestText,
|
||||
distance: "0 米",
|
||||
direction: "蓝色基准点",
|
||||
updatedAt: "实时定位",
|
||||
name: "河谷草原入口",
|
||||
storeName: nil,
|
||||
avatar: nil,
|
||||
summary: "蓝色标记为当前位置,可对比周边摄影师风险点位距离。",
|
||||
detail: "当前定位在河谷草原入口附近,建议优先关注 800 米内的异常点位。",
|
||||
distanceText: nil,
|
||||
reporterName: nil,
|
||||
reporterPhone: nil,
|
||||
maskedReporterPhone: nil,
|
||||
reportContents: [],
|
||||
images: [],
|
||||
processingStarted: false,
|
||||
completed: false,
|
||||
record: nil,
|
||||
processingTimeline: [],
|
||||
completionPhoto: nil
|
||||
)
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
for seed in redSeeds {
|
||||
let processingStarted = initialProcessingClueIDs.contains(seed.id)
|
||||
let clue = enrichClue(seed.clue, processingStarted: processingStarted)
|
||||
markers.append(
|
||||
WildReportMapMarker(
|
||||
id: seed.id,
|
||||
title: seed.name,
|
||||
coordinate: seed.coordinate,
|
||||
kind: markerKind(for: clue),
|
||||
detail: .activeClue(clue)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
markers.append(contentsOf: greenMarkers())
|
||||
return markers
|
||||
}
|
||||
|
||||
/// 根据全部标记计算地图可视区域。
|
||||
static func region(for markers: [WildReportMapMarker]) -> MKCoordinateRegion {
|
||||
guard let first = markers.first else {
|
||||
return MKCoordinateRegion(
|
||||
center: nalatiCenter,
|
||||
span: MKCoordinateSpan(latitudeDelta: 0.025, longitudeDelta: 0.025)
|
||||
)
|
||||
}
|
||||
|
||||
var minLat = first.coordinate.latitude
|
||||
var maxLat = first.coordinate.latitude
|
||||
var minLon = first.coordinate.longitude
|
||||
var maxLon = first.coordinate.longitude
|
||||
|
||||
for marker in markers {
|
||||
minLat = min(minLat, marker.coordinate.latitude)
|
||||
maxLat = max(maxLat, marker.coordinate.latitude)
|
||||
minLon = min(minLon, marker.coordinate.longitude)
|
||||
maxLon = max(maxLon, marker.coordinate.longitude)
|
||||
}
|
||||
|
||||
let center = CLLocationCoordinate2D(
|
||||
latitude: (minLat + maxLat) / 2,
|
||||
longitude: (minLon + maxLon) / 2
|
||||
)
|
||||
let span = MKCoordinateSpan(
|
||||
latitudeDelta: max(0.022, (maxLat - minLat) * 1.8),
|
||||
longitudeDelta: max(0.022, (maxLon - minLon) * 1.8)
|
||||
)
|
||||
return MKCoordinateRegion(center: center, span: span)
|
||||
}
|
||||
|
||||
private static func redSeed(
|
||||
id: String,
|
||||
name: String,
|
||||
coordinate: CLLocationCoordinate2D,
|
||||
updatedAt: String,
|
||||
distanceText: String,
|
||||
reporterName: String,
|
||||
reporterPhone: String,
|
||||
contents: [String],
|
||||
imageSeeds: [String]
|
||||
) -> (id: String, name: String, coordinate: CLLocationCoordinate2D, clue: WildReportRadarActiveClue) {
|
||||
let reportContents = contents.enumerated().map { index, content in
|
||||
WildReportRadarReportContent(time: index == 0 ? "14:32:18" : "14:45:06", content: content)
|
||||
}
|
||||
return (
|
||||
id,
|
||||
name,
|
||||
coordinate,
|
||||
WildReportRadarActiveClue(
|
||||
id: id,
|
||||
type: .red,
|
||||
displayTitle: "线索 ID:\(id)",
|
||||
statusText: "疑似打野",
|
||||
actionText: "导航到此",
|
||||
nearestAbnormalText: nil,
|
||||
distance: nil,
|
||||
direction: nil,
|
||||
updatedAt: updatedAt,
|
||||
name: name,
|
||||
storeName: nil,
|
||||
avatar: nil,
|
||||
summary: nil,
|
||||
detail: nil,
|
||||
distanceText: distanceText,
|
||||
reporterName: reporterName,
|
||||
reporterPhone: reporterPhone,
|
||||
maskedReporterPhone: maskPhone(reporterPhone),
|
||||
reportContents: reportContents,
|
||||
images: imageSeeds.map { "https://picsum.photos/seed/\($0)/640/480" },
|
||||
processingStarted: false,
|
||||
completed: false,
|
||||
record: nil,
|
||||
processingTimeline: [],
|
||||
completionPhoto: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func greenMarkers() -> [WildReportMapMarker] {
|
||||
let seeds: [(id: String, coordinate: CLLocationCoordinate2D, name: String, store: String, detail: String)] = [
|
||||
("PG20240618001", CLLocationCoordinate2D(latitude: 43.4042, longitude: 84.0186), "阿依达娜", "空中草原旅拍店", "当前在空中草原区域服务游客拍摄。"),
|
||||
("PG20240618002", CLLocationCoordinate2D(latitude: 43.3985, longitude: 84.0408), "波拉提", "天界台摄影服务点", "当前在天界台附近接待预约游客。"),
|
||||
("PG20240618003", CLLocationCoordinate2D(latitude: 43.3828, longitude: 84.0794), "米热丽", "塔吾萨尼影像馆", "当前在塔吾萨尼步道附近提供旅拍服务。")
|
||||
]
|
||||
|
||||
return seeds.map { seed in
|
||||
let clue = WildReportRadarActiveClue(
|
||||
id: seed.id,
|
||||
type: .green,
|
||||
displayTitle: "认证摄影师",
|
||||
statusText: "认证摄影师",
|
||||
actionText: "导航到此",
|
||||
nearestAbnormalText: nil,
|
||||
distance: "430 米",
|
||||
direction: "景区内",
|
||||
updatedAt: "在线",
|
||||
name: seed.name,
|
||||
storeName: seed.store,
|
||||
avatar: "person.crop.circle.fill",
|
||||
summary: "绿色标记为已认证摄影师,可与红色摄影师风险点位区分。",
|
||||
detail: seed.detail,
|
||||
distanceText: nil,
|
||||
reporterName: nil,
|
||||
reporterPhone: nil,
|
||||
maskedReporterPhone: nil,
|
||||
reportContents: [],
|
||||
images: [],
|
||||
processingStarted: false,
|
||||
completed: false,
|
||||
record: nil,
|
||||
processingTimeline: [],
|
||||
completionPhoto: nil
|
||||
)
|
||||
return WildReportMapMarker(
|
||||
id: seed.id,
|
||||
title: seed.name,
|
||||
coordinate: seed.coordinate,
|
||||
kind: .peerPhotographer,
|
||||
detail: .activeClue(clue)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func processRecord(for clueID: String) -> WildReportRadarProcessRecord? {
|
||||
switch clueID {
|
||||
case clueID1:
|
||||
return WildReportRadarProcessRecord(
|
||||
handlerName: "王磊",
|
||||
phone: "13812346721",
|
||||
maskedPhone: maskPhone("13812346721"),
|
||||
statusText: "待处理",
|
||||
processResult: "待现场核查,请前往空中草原观景道确认摄影师线索。",
|
||||
processedAt: nil,
|
||||
processingAt: nil
|
||||
)
|
||||
case clueID2:
|
||||
return WildReportRadarProcessRecord(
|
||||
handlerName: "李娜",
|
||||
phone: "18674598210",
|
||||
maskedPhone: maskPhone("18674598210"),
|
||||
statusText: "处理中",
|
||||
processResult: "待现场核查,请前往塔吾萨尼步道确认摄影师线索。",
|
||||
processedAt: nil,
|
||||
processingAt: "2024-05-18 10:35"
|
||||
)
|
||||
case clueID3:
|
||||
return WildReportRadarProcessRecord(
|
||||
handlerName: "赵强",
|
||||
phone: "15938267120",
|
||||
maskedPhone: maskPhone("15938267120"),
|
||||
statusText: "已处理",
|
||||
processResult: "已到场核查并完成劝离,处理结果已同步景区公安。",
|
||||
processedAt: "2024-05-17 18:26",
|
||||
processingAt: "2024-05-17 17:45"
|
||||
)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func buildProcessingTimeline(
|
||||
record: WildReportRadarProcessRecord,
|
||||
completed: Bool
|
||||
) -> [WildReportRadarProcessingTimelineItem] {
|
||||
let startedAt = record.processingAt ?? "2024-05-18 10:35"
|
||||
return [
|
||||
WildReportRadarProcessingTimelineItem(title: "处理时间", time: startedAt, status: "done"),
|
||||
WildReportRadarProcessingTimelineItem(
|
||||
title: "处理完成时间",
|
||||
time: completed ? (record.processedAt ?? "刚刚") : "待完成",
|
||||
status: completed ? "done" : "current"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private static func enrichClue(
|
||||
_ base: WildReportRadarActiveClue,
|
||||
processingStarted: Bool,
|
||||
completed: Bool = false,
|
||||
completionPhoto: String? = nil
|
||||
) -> WildReportRadarActiveClue {
|
||||
let record = processRecord(for: base.id)
|
||||
let isProcessing = processingStarted && !completed
|
||||
let timeline = (isProcessing || completed) && record != nil
|
||||
? buildProcessingTimeline(record: record!, completed: completed)
|
||||
: []
|
||||
|
||||
return WildReportRadarActiveClue(
|
||||
id: base.id,
|
||||
type: base.type,
|
||||
displayTitle: base.displayTitle,
|
||||
statusText: completed ? "已处理" : (isProcessing ? "正在处理中" : base.statusText),
|
||||
actionText: base.actionText,
|
||||
nearestAbnormalText: base.nearestAbnormalText,
|
||||
distance: base.distance,
|
||||
direction: base.direction,
|
||||
updatedAt: completed ? (record?.processedAt ?? "刚刚") : base.updatedAt,
|
||||
name: base.name,
|
||||
storeName: base.storeName,
|
||||
avatar: base.avatar,
|
||||
summary: base.summary,
|
||||
detail: base.detail,
|
||||
distanceText: base.distanceText,
|
||||
reporterName: base.reporterName,
|
||||
reporterPhone: base.reporterPhone,
|
||||
maskedReporterPhone: base.maskedReporterPhone,
|
||||
reportContents: base.reportContents,
|
||||
images: base.images,
|
||||
processingStarted: processingStarted || completed,
|
||||
completed: completed,
|
||||
record: (isProcessing || completed) ? record : nil,
|
||||
processingTimeline: timeline,
|
||||
completionPhoto: completionPhoto
|
||||
)
|
||||
}
|
||||
|
||||
private static func maskPhone(_ phone: String) -> String {
|
||||
guard phone.count == 11, phone.allSatisfy(\.isNumber) else { return phone }
|
||||
return "\(phone.prefix(3))****\(phone.suffix(4))"
|
||||
}
|
||||
|
||||
private static func nearestAbnormalText(
|
||||
blueCoordinate: CLLocationCoordinate2D,
|
||||
redCoordinates: [CLLocationCoordinate2D]
|
||||
) -> String {
|
||||
guard !redCoordinates.isEmpty else { return "距离最近的异常点位0.00KM" }
|
||||
let minKm = redCoordinates.map {
|
||||
calcDistanceKm(
|
||||
lat1: blueCoordinate.latitude,
|
||||
lon1: blueCoordinate.longitude,
|
||||
lat2: $0.latitude,
|
||||
lon2: $0.longitude
|
||||
)
|
||||
}.min() ?? 0
|
||||
return String(format: "距离最近的异常点位%.2fKM", minKm)
|
||||
}
|
||||
|
||||
private static func calcDistanceKm(
|
||||
lat1: Double,
|
||||
lon1: Double,
|
||||
lat2: Double,
|
||||
lon2: Double
|
||||
) -> Double {
|
||||
let earthRadiusKm = 6371.0
|
||||
let toRad = { (deg: Double) in deg * .pi / 180 }
|
||||
let dLat = toRad(lat2 - lat1)
|
||||
let dLon = toRad(lon2 - lon1)
|
||||
let a = sin(dLat / 2) * sin(dLat / 2)
|
||||
+ cos(toRad(lat1)) * cos(toRad(lat2)) * sin(dLon / 2) * sin(dLon / 2)
|
||||
return earthRadiusKm * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,685 @@
|
||||
//
|
||||
// WildPhotographerReportViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MapKit
|
||||
import UIKit
|
||||
|
||||
/// 举报摄影师首页 ViewModel,持有 Mock 举报数据并供子页面共享。
|
||||
final class WildPhotographerReportHomeViewModel {
|
||||
private(set) var pageState: WildReportPageState = .normal
|
||||
private(set) var reports: [WildReportRecord] = []
|
||||
private(set) var riskPoints: [WildReportRiskPoint] = []
|
||||
private(set) var supplementaryEvidenceMap: [String: [WildReportSupplementaryEvidence]] = [:]
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
private var nextSequence = 0
|
||||
private var nextSupplementSequence = 0
|
||||
|
||||
init() {
|
||||
resetDemoData(notify: false)
|
||||
}
|
||||
|
||||
var emptyTitle: String { "暂无举报服务" }
|
||||
var emptyMessage: String { "当前景区暂未开放举报摄影师演示入口。" }
|
||||
|
||||
/// 恢复演示数据并回到正常状态。
|
||||
func restoreDemoData() {
|
||||
resetDemoData(notify: false)
|
||||
pageState = .normal
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 模拟错误后重新加载。
|
||||
func retryLoadAfterError() {
|
||||
pageState = .loading
|
||||
notifyStateChange()
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 450_000_000)
|
||||
pageState = .normal
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换页面演示状态。
|
||||
func setPageState(_ state: WildReportPageState) {
|
||||
pageState = state
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 提交举报并写入 Mock 列表,返回新记录。
|
||||
@discardableResult
|
||||
func submitReport(
|
||||
reportType: WildReportType,
|
||||
description: String,
|
||||
location: String,
|
||||
contact: String?,
|
||||
imageCount: Int,
|
||||
videoCount: Int
|
||||
) -> WildReportRecord {
|
||||
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let parsedContact = WildPhotographerReportMockStore.parseContact(contact)
|
||||
let record = WildReportRecord(
|
||||
id: nextReportID(),
|
||||
title: "摄影师举报",
|
||||
reportType: reportType,
|
||||
status: .pending,
|
||||
location: location,
|
||||
submitTime: WildPhotographerReportMockStore.submitTimeFormatter.string(from: Date()),
|
||||
description: trimmedDescription,
|
||||
wechatID: parsedContact.wechat,
|
||||
phoneNumber: parsedContact.phone,
|
||||
imageCount: imageCount,
|
||||
videoCount: videoCount,
|
||||
handlerInfo: nil
|
||||
)
|
||||
reports.insert(record, at: 0)
|
||||
notifyStateChange()
|
||||
return record
|
||||
}
|
||||
|
||||
/// 读取指定举报的补充证据列表。
|
||||
func supplementaryEvidences(for reportID: String) -> [WildReportSupplementaryEvidence] {
|
||||
supplementaryEvidenceMap[reportID] ?? []
|
||||
}
|
||||
|
||||
/// 追加一条补充证据记录。
|
||||
func appendSupplementaryEvidence(
|
||||
reportID: String,
|
||||
text: String,
|
||||
imageCount: Int,
|
||||
videoCount: Int
|
||||
) {
|
||||
let item = WildReportSupplementaryEvidence(
|
||||
id: nextSupplementID(),
|
||||
submitTime: WildPhotographerReportMockStore.submitTimeFormatter.string(from: Date()),
|
||||
text: text.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
imageCount: imageCount,
|
||||
videoCount: videoCount
|
||||
)
|
||||
var items = supplementaryEvidenceMap[reportID] ?? []
|
||||
items.insert(item, at: 0)
|
||||
supplementaryEvidenceMap[reportID] = items
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 重置为种子 Mock 数据。
|
||||
func resetDemoData(notify: Bool = true) {
|
||||
reports = WildPhotographerReportMockStore.seedReports
|
||||
riskPoints = WildPhotographerReportMockStore.seedRiskPoints
|
||||
supplementaryEvidenceMap = WildPhotographerReportMockStore.seedSupplementaryEvidences
|
||||
nextSequence = WildPhotographerReportMockStore.maxSequence(from: WildPhotographerReportMockStore.seedReports) + 1
|
||||
nextSupplementSequence = 100
|
||||
if notify { notifyStateChange() }
|
||||
}
|
||||
|
||||
private func nextSupplementID() -> String {
|
||||
defer { nextSupplementSequence += 1 }
|
||||
return String(format: "SE%03d", nextSupplementSequence)
|
||||
}
|
||||
|
||||
private func nextReportID() -> String {
|
||||
let datePart = WildPhotographerReportMockStore.reportDateFormatter.string(from: Date())
|
||||
defer { nextSequence += 1 }
|
||||
return String(format: "JB\(datePart)%03d", nextSequence)
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交举报页 ViewModel,负责表单校验和前台定位。
|
||||
final class WildPhotographerReportSubmitViewModel {
|
||||
private(set) var pageState: WildReportPageState = .normal
|
||||
private(set) var images: [WildReportAttachment]
|
||||
private(set) var videos: [WildReportAttachment]
|
||||
private(set) var paymentImages: [WildReportAttachment] = []
|
||||
private(set) var selectedReportType: WildReportType = .suspectedWild
|
||||
private(set) var description = ""
|
||||
private(set) var contact = ""
|
||||
private(set) var reportLocation = ""
|
||||
private(set) var isUpdatingLocation = false
|
||||
private(set) var locationConfirmed = false
|
||||
private(set) var submitResult: WildReportSubmitResult?
|
||||
private(set) var validationMessage: String?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onSubmitSuccess: ((WildReportSubmitResult) -> Void)?
|
||||
|
||||
private let homeViewModel: WildPhotographerReportHomeViewModel
|
||||
private let locationProvider: any LocationProviding
|
||||
private var hasRequestedInitialLocation = false
|
||||
|
||||
var locationTitleText: String {
|
||||
if reportLocation.isEmpty || isUpdatingLocation {
|
||||
return "正在获取当前位置..."
|
||||
}
|
||||
return "当前定位:\(reportLocation)"
|
||||
}
|
||||
|
||||
var locationSubtitleText: String {
|
||||
if isUpdatingLocation {
|
||||
return "正在更新定位,请稍候"
|
||||
}
|
||||
if locationConfirmed {
|
||||
return "已获取最新定位,如不准确请点击更新定位"
|
||||
}
|
||||
return "如位置不准确,请点击更新定位"
|
||||
}
|
||||
|
||||
init(
|
||||
homeViewModel: WildPhotographerReportHomeViewModel,
|
||||
locationProvider: any LocationProviding = LocationProvider.shared
|
||||
) {
|
||||
self.homeViewModel = homeViewModel
|
||||
self.locationProvider = locationProvider
|
||||
self.images = WildPhotographerReportMockStore.initialImages
|
||||
self.videos = WildPhotographerReportMockStore.initialVideos
|
||||
}
|
||||
|
||||
/// 首次进入页面时自动请求定位。
|
||||
func loadInitialLocationIfNeeded() {
|
||||
guard !hasRequestedInitialLocation else { return }
|
||||
hasRequestedInitialLocation = true
|
||||
refreshCurrentLocation()
|
||||
}
|
||||
|
||||
/// 选择举报类型。
|
||||
func selectReportType(_ type: WildReportType) {
|
||||
selectedReportType = type
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 限制举报说明最多 500 字。
|
||||
func updateDescription(_ newValue: String) {
|
||||
description = String(newValue.prefix(500))
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 更新联系方式。
|
||||
func updateContact(_ value: String) {
|
||||
contact = String(value.prefix(80))
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 添加演示用现场图片。
|
||||
func addImage() {
|
||||
images.append(WildReportAttachment(kind: .image, title: "现场图片\(images.count + 1)"))
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 添加演示用现场视频。
|
||||
func addVideo() {
|
||||
videos.append(WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)"))
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 添加演示用支付截图。
|
||||
func addPaymentImage() {
|
||||
guard paymentImages.count < 3 else {
|
||||
onShowMessage?("支付截图最多上传3张")
|
||||
return
|
||||
}
|
||||
paymentImages.append(WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)"))
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 删除指定附件。
|
||||
func removeAttachment(_ attachment: WildReportAttachment) {
|
||||
images.removeAll { $0.id == attachment.id }
|
||||
videos.removeAll { $0.id == attachment.id }
|
||||
paymentImages.removeAll { $0.id == attachment.id }
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 使用前台定位 Provider 刷新当前位置。
|
||||
func refreshCurrentLocation() {
|
||||
guard !isUpdatingLocation else { return }
|
||||
isUpdatingLocation = true
|
||||
locationConfirmed = false
|
||||
reportLocation = "正在获取当前位置..."
|
||||
notifyStateChange()
|
||||
|
||||
Task {
|
||||
do {
|
||||
let snapshot = try await locationProvider.requestSnapshot()
|
||||
let address = snapshot.address.isEmpty
|
||||
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
|
||||
: snapshot.address
|
||||
applyResolvedLocation(address)
|
||||
} catch {
|
||||
applyResolvedLocation(WildPhotographerReportMockStore.fallbackLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除提交结果,用于导航返回。
|
||||
func clearSubmitResult() {
|
||||
submitResult = nil
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 校验表单并提交举报。
|
||||
func submitReport() {
|
||||
validationMessage = nil
|
||||
guard !images.isEmpty || !videos.isEmpty else {
|
||||
setValidationMessage("请至少上传一项现场证据。")
|
||||
return
|
||||
}
|
||||
guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
setValidationMessage("请填写举报说明。")
|
||||
return
|
||||
}
|
||||
guard !isUpdatingLocation else {
|
||||
setValidationMessage("正在获取定位,请稍后再提交。")
|
||||
return
|
||||
}
|
||||
guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else {
|
||||
setValidationMessage("请先更新举报位置后再提交举报。")
|
||||
return
|
||||
}
|
||||
|
||||
let record = homeViewModel.submitReport(
|
||||
reportType: selectedReportType,
|
||||
description: description,
|
||||
location: reportLocation,
|
||||
contact: contact.isEmpty ? nil : contact,
|
||||
imageCount: images.count,
|
||||
videoCount: videos.count
|
||||
)
|
||||
let result = WildReportSubmitResult(
|
||||
record: record,
|
||||
imageCount: images.count,
|
||||
videoCount: videos.count
|
||||
)
|
||||
submitResult = result
|
||||
notifyStateChange()
|
||||
onSubmitSuccess?(result)
|
||||
}
|
||||
|
||||
/// 重置表单到初始状态。
|
||||
func resetForm() {
|
||||
images = WildPhotographerReportMockStore.initialImages
|
||||
videos = WildPhotographerReportMockStore.initialVideos
|
||||
paymentImages = []
|
||||
selectedReportType = .suspectedWild
|
||||
description = ""
|
||||
contact = ""
|
||||
reportLocation = ""
|
||||
locationConfirmed = false
|
||||
isUpdatingLocation = false
|
||||
hasRequestedInitialLocation = false
|
||||
submitResult = nil
|
||||
validationMessage = nil
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func applyResolvedLocation(_ location: String) {
|
||||
reportLocation = location
|
||||
locationConfirmed = true
|
||||
isUpdatingLocation = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func setValidationMessage(_ message: String) {
|
||||
validationMessage = message
|
||||
notifyStateChange()
|
||||
onShowMessage?(message)
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报成功页 ViewModel。
|
||||
final class WildPhotographerReportSuccessViewModel {
|
||||
let result: WildReportSubmitResult
|
||||
let progressSteps = ["已提交", "现场核查中", "处理结果反馈"]
|
||||
|
||||
init(result: WildReportSubmitResult) {
|
||||
self.result = result
|
||||
}
|
||||
|
||||
var uploadSummary: String {
|
||||
var parts: [String] = []
|
||||
if result.imageCount > 0 { parts.append("\(result.imageCount)张图片") }
|
||||
if result.videoCount > 0 { parts.append("\(result.videoCount)段视频") }
|
||||
return parts.isEmpty ? "暂无" : parts.joined(separator: " ")
|
||||
}
|
||||
|
||||
/// 复制举报编号到剪贴板。
|
||||
@MainActor
|
||||
func copyReportID() -> String {
|
||||
UIPasteboard.general.string = result.record.id
|
||||
return result.record.id
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报详情页 ViewModel。
|
||||
final class WildPhotographerReportDetailViewModel {
|
||||
let record: WildReportRecord
|
||||
private let homeViewModel: WildPhotographerReportHomeViewModel?
|
||||
|
||||
private(set) var actionMessage: String?
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel? = nil) {
|
||||
self.record = record
|
||||
self.homeViewModel = homeViewModel
|
||||
}
|
||||
|
||||
var heroTitle: String { record.title.isEmpty ? "摄影师举报" : record.title }
|
||||
var scenicAreaName: String { WildReportLocationParser.split(record.location).scenic }
|
||||
var detailAddress: String {
|
||||
let detail = WildReportLocationParser.split(record.location).detail
|
||||
return detail.contains("附近") ? detail : "\(detail)附近"
|
||||
}
|
||||
|
||||
var displayStatusText: String { record.status.rawValue }
|
||||
var descriptionText: String { record.description }
|
||||
var contactText: String { record.contactDisplayText }
|
||||
var supplementaryEvidences: [WildReportSupplementaryEvidence] {
|
||||
homeViewModel?.supplementaryEvidences(for: record.id) ?? []
|
||||
}
|
||||
var reporterMediaSummary: String {
|
||||
switch (record.imageCount, record.videoCount) {
|
||||
case (0, 0): return "0项"
|
||||
case (_, 0): return "\(record.imageCount)张"
|
||||
case (0, _): return "\(record.videoCount)段"
|
||||
default: return "\(record.imageCount)张 \(record.videoCount)段"
|
||||
}
|
||||
}
|
||||
var progressSteps: [WildReportProgressStep] { Self.buildProgressSteps(for: record) }
|
||||
var showHandlerInfo: Bool { record.status == .processed && record.handlerInfo != nil }
|
||||
var handlerInfo: WildReportHandlerInfo? { record.handlerInfo }
|
||||
|
||||
/// 展示媒体预览提示。
|
||||
func showMediaPreview(kind: String) {
|
||||
showActionMessage("演示环境暂不支持预览\(kind),可在提交页查看上传内容。")
|
||||
}
|
||||
|
||||
/// 展示位置预览提示。
|
||||
func showLocationPreview() {
|
||||
showActionMessage("举报位置:\(record.location)")
|
||||
}
|
||||
|
||||
/// 生成分享提示文案。
|
||||
func shareReport() {
|
||||
showActionMessage("已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。")
|
||||
}
|
||||
|
||||
private func showActionMessage(_ message: String) {
|
||||
actionMessage = message
|
||||
onShowMessage?(message)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private static func buildProgressSteps(for record: WildReportRecord) -> [WildReportProgressStep] {
|
||||
let baseDate = submitDate(from: record.submitTime) ?? Date()
|
||||
let expectedFeedback = formatDate(baseDate.addingTimeInterval(8 * 3_600))
|
||||
|
||||
switch record.status {
|
||||
case .pending, .processing:
|
||||
return [
|
||||
makeStep(id: "1", title: "已提交", date: baseDate, state: .completed),
|
||||
makeStep(id: "2", title: "已派工作人员前往", date: baseDate.addingTimeInterval(22 * 60), state: .completed),
|
||||
makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .current),
|
||||
makeStep(id: "4", title: "待结果反馈", date: nil, state: .pending, pendingText: "预计 \(expectedFeedback) 前")
|
||||
]
|
||||
case .processed:
|
||||
return [
|
||||
makeStep(id: "1", title: "已提交", date: baseDate, state: .completed),
|
||||
makeStep(id: "2", title: "已派工作人员前往", date: baseDate.addingTimeInterval(22 * 60), state: .completed),
|
||||
makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .completed),
|
||||
makeStep(id: "4", title: "已反馈核查结果", date: baseDate.addingTimeInterval(3 * 3_600), state: .completed)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeStep(
|
||||
id: String,
|
||||
title: String,
|
||||
date: Date?,
|
||||
state: WildReportProgressStepState,
|
||||
pendingText: String? = nil
|
||||
) -> WildReportProgressStep {
|
||||
WildReportProgressStep(
|
||||
id: id,
|
||||
title: title,
|
||||
timeText: pendingText ?? (date.map(formatDate) ?? ""),
|
||||
state: state
|
||||
)
|
||||
}
|
||||
|
||||
private static func submitDate(from text: String) -> Date? {
|
||||
WildPhotographerReportMockStore.submitTimeFormatter.date(from: text)
|
||||
}
|
||||
|
||||
private static func formatDate(_ date: Date) -> String {
|
||||
WildPhotographerReportMockStore.submitTimeFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 我的举报列表 ViewModel。
|
||||
final class WildPhotographerReportListViewModel {
|
||||
private(set) var selectedFilter: WildReportListFilter = .all
|
||||
private(set) var shareMessage: String?
|
||||
|
||||
let homeViewModel: WildPhotographerReportHomeViewModel
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
init(homeViewModel: WildPhotographerReportHomeViewModel) {
|
||||
self.homeViewModel = homeViewModel
|
||||
}
|
||||
|
||||
var filteredReports: [WildReportRecord] {
|
||||
let reports = homeViewModel.reports
|
||||
switch selectedFilter {
|
||||
case .all: return reports
|
||||
case .pending: return reports.filter { $0.status == .pending }
|
||||
case .processing: return reports.filter { $0.status == .processing }
|
||||
case .processed: return reports.filter { $0.status == .processed }
|
||||
}
|
||||
}
|
||||
|
||||
var footerTip: String { "温馨提示:为保障处理效率,请如实举报并提供有效证据" }
|
||||
|
||||
/// 切换列表筛选标签。
|
||||
func selectFilter(_ filter: WildReportListFilter) {
|
||||
selectedFilter = filter
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 生成分享提示文案。
|
||||
func shareReport(_ record: WildReportRecord) {
|
||||
shareMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。"
|
||||
onShowMessage?(shareMessage ?? "")
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 补充证据页 ViewModel。
|
||||
final class WildReportSupplementEvidenceViewModel {
|
||||
private(set) var text = ""
|
||||
private(set) var images: [WildReportAttachment] = []
|
||||
private(set) var videos: [WildReportAttachment] = []
|
||||
private(set) var validationMessage: String?
|
||||
private(set) var didSubmit = false
|
||||
|
||||
let reportID: String
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onSubmitSuccess: (() -> Void)?
|
||||
|
||||
private let homeViewModel: WildPhotographerReportHomeViewModel
|
||||
|
||||
init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) {
|
||||
self.reportID = reportID
|
||||
self.homeViewModel = homeViewModel
|
||||
}
|
||||
|
||||
/// 限制补充说明最多 500 字。
|
||||
func updateText(_ newValue: String) {
|
||||
text = String(newValue.prefix(500))
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 添加演示用补充图片。
|
||||
func addImage() {
|
||||
guard images.count < 9 else {
|
||||
onShowMessage?("补充图片最多上传9张")
|
||||
return
|
||||
}
|
||||
images.append(WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)"))
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 添加演示用补充视频。
|
||||
func addVideo() {
|
||||
guard videos.count < 3 else {
|
||||
onShowMessage?("补充视频最多上传3段")
|
||||
return
|
||||
}
|
||||
videos.append(WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)"))
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 删除指定附件。
|
||||
func removeAttachment(_ attachment: WildReportAttachment) {
|
||||
images.removeAll { $0.id == attachment.id }
|
||||
videos.removeAll { $0.id == attachment.id }
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 校验并提交补充证据。
|
||||
func submit() {
|
||||
validationMessage = nil
|
||||
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedText.isEmpty || !images.isEmpty || !videos.isEmpty else {
|
||||
validationMessage = "请至少填写补充说明或上传一项证据。"
|
||||
onShowMessage?(validationMessage ?? "")
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
|
||||
homeViewModel.appendSupplementaryEvidence(
|
||||
reportID: reportID,
|
||||
text: trimmedText,
|
||||
imageCount: images.count,
|
||||
videoCount: videos.count
|
||||
)
|
||||
didSubmit = true
|
||||
notifyStateChange()
|
||||
onSubmitSuccess?()
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 附近风险地图 ViewModel。
|
||||
final class WildReportRiskMapViewModel {
|
||||
private(set) var region: MKCoordinateRegion
|
||||
private(set) var markers: [WildReportMapMarker]
|
||||
let scenicAreaName: String
|
||||
private(set) var selectedMarkerID: String?
|
||||
private(set) var toastMessage: String?
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) {
|
||||
let markers = WildPhotographerReportMockStore.buildMarkers(record: record, riskPoints: riskPoints)
|
||||
self.markers = markers
|
||||
self.region = WildPhotographerReportMockStore.region(for: markers)
|
||||
self.scenicAreaName = "那拉提景区"
|
||||
self.selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id
|
||||
}
|
||||
|
||||
var selectedMarker: WildReportMapMarker? {
|
||||
guard let selectedMarkerID else { return nil }
|
||||
return markers.first { $0.id == selectedMarkerID }
|
||||
}
|
||||
|
||||
var selectedClue: WildReportRadarActiveClue? {
|
||||
guard let marker = selectedMarker,
|
||||
case .activeClue(let clue) = marker.detail else { return nil }
|
||||
return clue
|
||||
}
|
||||
|
||||
/// 选中地图标记。
|
||||
func selectMarker(_ marker: WildReportMapMarker) {
|
||||
selectedMarkerID = marker.id
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 导航到选中标记或刷新蓝点定位。
|
||||
func navigateToSelectedMarker() {
|
||||
guard let marker = selectedMarker,
|
||||
case .activeClue(let clue) = marker.detail else { return }
|
||||
|
||||
if clue.type == .blue {
|
||||
refreshLocation()
|
||||
return
|
||||
}
|
||||
|
||||
showToast("已切换到该点位")
|
||||
region = MKCoordinateRegion(
|
||||
center: marker.coordinate,
|
||||
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
|
||||
)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 将地图居中到当前位置标记。
|
||||
func refreshLocation() {
|
||||
guard let blueMarker = markers.first(where: { $0.kind == .selfLocation }) else { return }
|
||||
selectedMarkerID = blueMarker.id
|
||||
region = MKCoordinateRegion(
|
||||
center: blueMarker.coordinate,
|
||||
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
|
||||
)
|
||||
showToast("已定位到当前位置")
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 生成线索分享提示。
|
||||
func shareSelectedClue() {
|
||||
guard let clue = selectedClue else { return }
|
||||
let title = clue.type == .red ? "线索 ID:\(clue.id)" : clue.displayTitle
|
||||
showToast("已生成\(title)的分享信息,可发送给他人查看。")
|
||||
}
|
||||
|
||||
/// 生成图片预览提示。
|
||||
func showImagePreview(_ url: String) {
|
||||
_ = url
|
||||
showToast("演示环境暂不支持预览图片")
|
||||
}
|
||||
|
||||
private func showToast(_ message: String) {
|
||||
toastMessage = message
|
||||
onShowMessage?(message)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user