feat: add wild photographer report mock flow

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

View File

@ -0,0 +1,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))
}
}