feat: 更新素材管理和举报风险地图

This commit is contained in:
2026-07-09 12:20:02 +08:00
parent 9b92d81902
commit 42aca73588
42 changed files with 6164 additions and 1369 deletions

View File

@ -10,6 +10,8 @@ import Foundation
protocol WildPhotographerReportServing {
///
func reportTypes() async throws -> WildReportTypeListResponse
///
func reportCopy() async throws -> WildReportCopyResponse
///
func submitReport(_ request: WildReportSubmitRequest) async throws
///
@ -41,6 +43,13 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing {
)
}
///
func reportCopy() async throws -> WildReportCopyResponse {
try await client.send(
APIRequest(method: .get, path: "/api/yf-handset-app/photog/report/copy")
)
}
///
func submitReport(_ request: WildReportSubmitRequest) async throws {
let _: EmptyPayload = try await client.send(
@ -67,9 +76,9 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing {
func riskMap(_ request: WildReportRiskMapRequest) async throws -> WildReportRiskMapResponse {
try await client.send(
APIRequest(
method: .post,
method: .get,
path: "/api/yf-handset-app/photog/report/risk-map",
body: request
queryItems: request.queryItems
)
)
}
@ -115,12 +124,23 @@ struct WildReportSupplementRequest: Encodable, Equatable {
let evidences: [WildReportEvidenceRequest]?
}
///
///
struct WildReportRiskMapRequest: Encodable, Equatable {
let scenicId: Int
let latitude: Double?
let longitude: Double?
var queryItems: [URLQueryItem] {
var items = [URLQueryItem(name: "scenic_id", value: String(scenicId))]
if let latitude {
items.append(URLQueryItem(name: "latitude", value: String(latitude)))
}
if let longitude {
items.append(URLQueryItem(name: "longitude", value: String(longitude)))
}
return items
}
enum CodingKeys: String, CodingKey {
case scenicId = "scenic_id"
case latitude

View File

@ -8,7 +8,7 @@ import Foundation
import MapKit
import UIKit
///
///
enum WildReportPageState: String, CaseIterable, Hashable {
case normal
case loading
@ -36,15 +36,6 @@ enum WildReportAttachmentKind: Hashable {
struct WildReportType: Decodable, Hashable {
let value: Int
let label: String
static let defaultSelected = WildReportType(value: 2, label: "疑似打野")
static let fallbackOptions = [
WildReportType(value: 1, label: "私下收款"),
WildReportType(value: 2, label: "疑似打野"),
WildReportType(value: 3, label: "未戴工牌"),
WildReportType(value: 4, label: "其他")
]
}
///
@ -56,7 +47,51 @@ struct WildReportTypeListResponse: Decodable, Hashable {
}
}
///
///
struct WildReportCopyResponse: Decodable, Equatable, Hashable {
let notice: [String]
let ruleGroups: [WildReportRuleGroup]
enum CodingKeys: String, CodingKey {
case notice
case ruleGroups = "rule_groups"
}
init(notice: [String] = [], ruleGroups: [WildReportRuleGroup] = []) {
self.notice = notice
self.ruleGroups = ruleGroups
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
notice = try container.decodeIfPresent([String].self, forKey: .notice) ?? []
ruleGroups = try container.decodeIfPresent([WildReportRuleGroup].self, forKey: .ruleGroups) ?? []
}
}
///
struct WildReportRuleGroup: Decodable, Equatable, Hashable {
let title: String
let items: [String]
enum CodingKeys: String, CodingKey {
case title
case items
}
init(title: String = "", items: [String] = []) {
self.title = title
self.items = items
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
items = try container.decodeIfPresent([String].self, forKey: .items) ?? []
}
}
///
struct WildReportAttachment: Hashable {
let id: UUID
let kind: WildReportAttachmentKind

View File

@ -1,606 +0,0 @@
//
// 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: WildReportType(value: 2, label: "疑似打野"),
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: WildReportType(value: 2, label: "疑似打野"),
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: WildReportType(value: 3, label: "未戴工牌"),
status: .pending,
location: "那拉提景区 · 盘龙古道停车区",
submitTime: "2026-06-16 09:15",
description: "停车区附近有摄影师未佩戴工牌开展拍摄服务,已收到图片和位置线索。",
wechatID: "",
phoneNumber: "13987654321",
imageCount: 1,
videoCount: 0,
handlerInfo: nil
),
WildReportRecord(
id: "JB20260615002",
title: "摄影师举报",
reportType: WildReportType(value: 4, label: "其他"),
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 .yellow:
return .processingClue
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))
}
}

View File

@ -22,126 +22,83 @@ protocol WildReportAttachmentUploading {
extension OSSUploadService: WildReportAttachmentUploading {}
/// ViewModel Mock
///
enum WildReportDateFormatters {
static let displayDateTime: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.locale = Locale(identifier: "zh_CN")
formatter.timeZone = .current
return formatter
}()
}
///
enum WildReportContactParser {
///
static func parse(_ contact: String?) -> (phone: String?, wechat: String?) {
let trimmed = contact?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { return (nil, nil) }
let digits = trimmed.filter(\.isNumber)
if digits.count >= 7 {
return (trimmed, nil)
}
return (nil, trimmed)
}
}
/// ViewModel
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]] = [:]
private(set) var noticeItems: [String] = []
private(set) var ruleGroups: [WildReportRuleGroup] = []
private(set) var isLoadingCopy = false
var onStateChange: (() -> Void)?
private var nextSequence = 0
private var nextSupplementSequence = 0
init() {
resetDemoData(notify: false)
}
var onShowMessage: ((String) -> Void)?
var emptyTitle: String { "暂无举报服务" }
var emptyMessage: String { "当前景区暂未开放举报摄影师演示入口。" }
var emptyMessage: String { "当前景区暂未开放举报摄影师入口。" }
var shouldShowNoticeModule: Bool { !noticeItems.isEmpty || !ruleGroups.isEmpty }
var shouldShowRuleButton: Bool { !ruleGroups.isEmpty }
///
func restoreDemoData() {
resetDemoData(notify: false)
pageState = .normal
///
func loadReportCopy(api: any WildPhotographerReportServing) async {
guard !isLoadingCopy else { return }
isLoadingCopy = true
notifyStateChange()
}
///
func retryLoadAfterError() {
pageState = .loading
notifyStateChange()
Task {
try? await Task.sleep(nanoseconds: 450_000_000)
pageState = .normal
defer {
isLoadingCopy = false
notifyStateChange()
}
do {
let response = try await api.reportCopy()
noticeItems = response.notice
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
ruleGroups = response.ruleGroups.compactMap { group in
let title = group.title.trimmingCharacters(in: .whitespacesAndNewlines)
let items = group.items
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !title.isEmpty || !items.isEmpty else { return nil }
return WildReportRuleGroup(title: title, items: items)
}
} catch is CancellationError {
return
} catch {
noticeItems = []
ruleGroups = []
onShowMessage?(error.localizedDescription)
}
}
///
///
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?()
}
@ -153,8 +110,8 @@ final class WildPhotographerReportSubmitViewModel {
private(set) var images: [WildReportAttachment]
private(set) var videos: [WildReportAttachment]
private(set) var paymentImages: [WildReportAttachment] = []
private(set) var reportTypes: [WildReportType] = WildReportType.fallbackOptions
private(set) var selectedReportType: WildReportType = .defaultSelected
private(set) var reportTypes: [WildReportType] = []
private(set) var selectedReportType: WildReportType?
private(set) var description = ""
private(set) var contact = ""
private(set) var reportLocation = ""
@ -209,7 +166,7 @@ final class WildPhotographerReportSubmitViewModel {
refreshCurrentLocation()
}
/// 退
///
func loadReportTypes(api: any WildPhotographerReportServing) async {
guard !isLoadingReportTypes else { return }
isLoadingReportTypes = true
@ -222,22 +179,19 @@ final class WildPhotographerReportSubmitViewModel {
do {
let response = try await api.reportTypes()
let loadedTypes = uniqueReportTypes(response.list)
reportTypes = loadedTypes.isEmpty ? WildReportType.fallbackOptions : loadedTypes
if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) {
reportTypes = loadedTypes
if let currentSelectedType = selectedReportType,
let updatedType = reportTypes.first(where: { $0.value == currentSelectedType.value }) {
selectedReportType = updatedType
} else {
selectedReportType = reportTypes.first ?? .defaultSelected
selectedReportType = reportTypes.first
}
} catch is CancellationError {
return
} catch {
reportTypes = WildReportType.fallbackOptions
if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) {
selectedReportType = updatedType
} else {
selectedReportType = .defaultSelected
}
onShowMessage?("举报类型获取失败,已使用默认类型")
reportTypes = []
selectedReportType = nil
onShowMessage?(error.localizedDescription)
}
}
@ -270,11 +224,6 @@ final class WildPhotographerReportSubmitViewModel {
notifyStateChange()
}
///
func addImage() {
addImages([WildReportAttachment(kind: .image, title: "现场图片\(images.count + 1)")])
}
///
func addVideos(_ attachments: [WildReportAttachment]) {
let videoRemaining = max(0, 3 - videos.count)
@ -288,11 +237,6 @@ final class WildPhotographerReportSubmitViewModel {
notifyStateChange()
}
///
func addVideo() {
addVideos([WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)")])
}
///
func addPaymentImages(_ attachments: [WildReportAttachment]) {
guard paymentImages.count < 3 else {
@ -303,11 +247,6 @@ final class WildPhotographerReportSubmitViewModel {
notifyStateChange()
}
///
func addPaymentImage() {
addPaymentImages([WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)")])
}
///
func removeAttachment(_ attachment: WildReportAttachment) {
images.removeAll { $0.id == attachment.id }
@ -329,11 +268,7 @@ final class WildPhotographerReportSubmitViewModel {
let snapshot = try await locationProvider.requestSnapshot()
applyResolvedLocation(snapshot)
} catch {
applyResolvedLocation(HomeLocationSnapshot(
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
address: WildPhotographerReportMockStore.fallbackLocation
))
applyLocationFailure(error.localizedDescription)
}
}
}
@ -389,14 +324,7 @@ final class WildPhotographerReportSubmitViewModel {
paymentScreenshots: paymentRequests
))
let record = homeViewModel.submitReport(
reportType: selectedReportType,
description: description,
location: reportLocation,
contact: contact.isEmpty ? nil : contact,
imageCount: images.count,
videoCount: videos.count
)
let record = buildSubmittedRecord()
let result = WildReportSubmitResult(
record: record,
imageCount: images.count,
@ -412,36 +340,13 @@ final class WildPhotographerReportSubmitViewModel {
}
}
/// Mock
func submitReport() {
validationMessage = nil
guard validateBeforeSubmit(scenicId: 1) else { 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 = []
videos = []
paymentImages = []
reportTypes = WildReportType.fallbackOptions
selectedReportType = .defaultSelected
reportTypes = []
selectedReportType = nil
description = ""
contact = ""
reportLocation = ""
@ -456,14 +361,6 @@ final class WildPhotographerReportSubmitViewModel {
notifyStateChange()
}
func applyResolvedLocation(_ location: String) {
applyResolvedLocation(HomeLocationSnapshot(
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
address: location
))
}
func applyResolvedLocation(_ snapshot: HomeLocationSnapshot) {
let address = snapshot.address.isEmpty
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
@ -479,6 +376,15 @@ final class WildPhotographerReportSubmitViewModel {
notifyStateChange()
}
private func applyLocationFailure(_ message: String) {
reportLocation = ""
locationSnapshot = nil
locationConfirmed = false
isUpdatingLocation = false
notifyStateChange()
onShowMessage?(message)
}
private func setValidationMessage(_ message: String) {
validationMessage = message
notifyStateChange()
@ -503,6 +409,10 @@ final class WildPhotographerReportSubmitViewModel {
setValidationMessage("请先选择景区后再提交举报。")
return false
}
guard selectedReportType != nil else {
setValidationMessage("请先选择举报类型。")
return false
}
guard !images.isEmpty || !videos.isEmpty else {
setValidationMessage("请至少上传一项现场证据。")
return false
@ -527,6 +437,10 @@ final class WildPhotographerReportSubmitViewModel {
setValidationMessage("请先更新举报位置后再提交举报。")
return false
}
guard locationSnapshot != nil else {
setValidationMessage("缺少定位坐标,请重新更新举报位置。")
return false
}
return true
}
@ -572,15 +486,11 @@ final class WildPhotographerReportSubmitViewModel {
let locationParts = WildReportLocationParser.split(reportLocation)
let trimmedScenicName = scenicName.trimmingCharacters(in: .whitespacesAndNewlines)
let locationName = !trimmedScenicName.isEmpty ? trimmedScenicName : locationParts.scenic
let snapshot = locationSnapshot ?? HomeLocationSnapshot(
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
address: reportLocation
)
let snapshot = locationSnapshot ?? HomeLocationSnapshot(latitude: 0, longitude: 0, address: reportLocation)
let trimmedContact = contact.trimmingCharacters(in: .whitespacesAndNewlines)
return WildReportSubmitRequest(
scenicId: scenicId,
reportType: selectedReportType.value,
reportType: selectedReportType?.value ?? 0,
desc: description.trimmingCharacters(in: .whitespacesAndNewlines),
storeUserId: nil,
contactPhone: trimmedContact.isEmpty ? nil : trimmedContact,
@ -592,6 +502,25 @@ final class WildPhotographerReportSubmitViewModel {
paymentScreenshots: paymentScreenshots
)
}
private func buildSubmittedRecord() -> WildReportRecord {
let parsedContact = WildReportContactParser.parse(contact)
return WildReportRecord(
serverID: nil,
id: "",
title: "摄影师举报",
reportType: selectedReportType ?? WildReportType(value: 0, label: ""),
status: .pending,
location: reportLocation,
submitTime: WildReportDateFormatters.displayDateTime.string(from: Date()),
description: description.trimmingCharacters(in: .whitespacesAndNewlines),
wechatID: parsedContact.wechat ?? "",
phoneNumber: parsedContact.phone ?? "",
imageCount: images.count,
videoCount: videos.count,
handlerInfo: nil
)
}
}
/// ViewModel
@ -642,6 +571,17 @@ final class WildPhotographerReportDetailViewModel {
let detail = WildReportLocationParser.split(record.location).detail
return detail.contains("附近") ? detail : "\(detail)附近"
}
var mapCoordinate: CLLocationCoordinate2D? {
guard let latitudeText = detail?.latitude,
let longitudeText = detail?.longitude,
let latitude = Double(latitudeText),
let longitude = Double(longitudeText) else {
return nil
}
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
guard CLLocationCoordinate2DIsValid(coordinate), latitude != 0 || longitude != 0 else { return nil }
return coordinate
}
var displayStatusText: String { detail?.displayStatusText ?? record.status.rawValue }
var descriptionText: String { record.description }
@ -651,7 +591,7 @@ final class WildPhotographerReportDetailViewModel {
if let detail {
return detail.supplementRecords
}
return homeViewModel?.supplementaryEvidences(for: record.id) ?? []
return []
}
var reporterMediaItems: [WildReportReporterMediaItem] {
if let detail {
@ -718,7 +658,7 @@ final class WildPhotographerReportDetailViewModel {
///
func showMediaPreview(kind: String) {
showActionMessage("演示环境暂不支持预览\(kind),可在提交页查看上传内容。")
showActionMessage("暂不支持预览\(kind)")
}
///
@ -738,30 +678,14 @@ final class WildPhotographerReportDetailViewModel {
}
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, .rejected:
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: record.status == .rejected ? "已驳回举报" : "已反馈核查结果",
date: baseDate.addingTimeInterval(3 * 3_600),
state: .completed
)
]
}
[
WildReportProgressStep(
id: "current-status",
title: record.status.rawValue,
timeText: record.submitTime,
state: record.status == .pending || record.status == .processing ? .current : .completed
)
]
}
private static func buildProgressSteps(
@ -780,29 +704,6 @@ final class WildPhotographerReportDetailViewModel {
}
}
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?()
}
@ -855,13 +756,6 @@ final class WildPhotographerReportListViewModel {
await reload(api: api, scenicId: scenicId)
}
/// Mock 使
func selectFilter(_ filter: WildReportListFilter) {
selectedFilter = filter
reports = filterLocalReports(homeViewModel.reports)
notifyStateChange()
}
///
func loadMoreIfNeeded(api: any WildPhotographerReportServing, scenicId: Int?) async {
guard hasMore, !isLoading, !isLoadingMore else { return }
@ -935,15 +829,6 @@ final class WildPhotographerReportListViewModel {
)
}
private func filterLocalReports(_ localReports: [WildReportRecord]) -> [WildReportRecord] {
switch selectedFilter {
case .all: return localReports
case .pending: return localReports.filter { $0.status == .pending }
case .processing: return localReports.filter { $0.status == .processing }
case .processed: return localReports.filter { $0.status == .processed }
}
}
private func notifyStateChange() {
onStateChange?()
}
@ -990,11 +875,6 @@ final class WildReportSupplementEvidenceViewModel {
notifyStateChange()
}
///
func addImage() {
addImages([WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)")])
}
///
func addVideos(_ attachments: [WildReportAttachment]) {
let videoRemaining = max(0, 3 - videos.count)
@ -1008,11 +888,6 @@ final class WildReportSupplementEvidenceViewModel {
notifyStateChange()
}
///
func addVideo() {
addVideos([WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)")])
}
///
func removeAttachment(_ attachment: WildReportAttachment) {
images.removeAll { $0.id == attachment.id }
@ -1055,12 +930,6 @@ final class WildReportSupplementEvidenceViewModel {
evidences: evidenceRequests.isEmpty ? nil : evidenceRequests
))
homeViewModel.appendSupplementaryEvidence(
reportID: reportID,
text: trimmedText,
imageCount: images.count,
videoCount: videos.count
)
didSubmit = true
notifyStateChange()
onSubmitSuccess?()
@ -1141,7 +1010,8 @@ final class WildReportSupplementEvidenceViewModel {
/// ViewModel
final class WildReportRiskMapViewModel {
private let fallbackMarkers: [WildReportMapMarker]
private static let riskMapLocationAccuracy = kCLLocationAccuracyHundredMeters
private var loadedDetailMarkerIDs: Set<String> = []
private(set) var region: MKCoordinateRegion
@ -1159,14 +1029,12 @@ final class WildReportRiskMapViewModel {
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) {
let markers = WildPhotographerReportMockStore.buildMarkers(record: record, riskPoints: riskPoints)
self.fallbackMarkers = markers
self.markers = markers
self.region = WildPhotographerReportMockStore.region(for: markers)
init() {
self.markers = []
self.region = Self.region(for: [])
let scenicName = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
self.scenicAreaName = scenicName.isEmpty ? "那拉提景区" : scenicName
self.selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id
self.scenicAreaName = scenicName.isEmpty ? "当前景区" : scenicName
self.selectedMarkerID = nil
}
var selectedMarker: WildReportMapMarker? {
@ -1272,7 +1140,7 @@ final class WildReportRiskMapViewModel {
///
func showImagePreview(_ url: String) {
_ = url
showToast("演示环境暂不支持预览图片")
showToast("暂不支持预览图片")
}
private func showToast(_ message: String) {
@ -1295,16 +1163,37 @@ final class WildReportRiskMapViewModel {
return
}
let coordinate = await requestRiskMapCoordinate(locationProvider: locationProvider)
currentCoordinate = coordinate
await requestRiskMap(
api: api,
scenicId: resolvedScenicId,
scenicName: scenicName,
coordinate: coordinate,
selectCurrentLocation: selectCurrentLocation,
showsLocationRefreshToast: coordinate != nil
)
}
private func requestRiskMapCoordinate(locationProvider: any LocationProviding) async -> CLLocationCoordinate2D? {
try? await locationProvider.requestCoordinate(desiredAccuracy: Self.riskMapLocationAccuracy)
}
private func requestRiskMap(
api: any WildPhotographerReportServing,
scenicId: Int,
scenicName: String?,
coordinate: CLLocationCoordinate2D?,
selectCurrentLocation: Bool,
showsLocationRefreshToast: Bool
) async {
isLoading = true
errorMessage = nil
notifyStateChange()
let coordinate = try? await locationProvider.requestCoordinate()
currentCoordinate = coordinate
do {
let response = try await api.riskMap(WildReportRiskMapRequest(
scenicId: resolvedScenicId,
scenicId: scenicId,
latitude: coordinate?.latitude,
longitude: coordinate?.longitude
))
@ -1315,25 +1204,17 @@ final class WildReportRiskMapViewModel {
greenMarkerTip = response.greenMarkerTip
legendItems = response.legend
markers = buildMarkers(response: response, currentCoordinate: coordinate)
if markers.isEmpty {
markers = fallbackMarkers
}
region = WildPhotographerReportMockStore.region(for: markers)
region = Self.region(for: markers)
if selectCurrentLocation, let current = markers.first(where: { $0.kind == .selfLocation }) {
selectedMarkerID = current.id
} else if selectedMarker == nil {
selectedMarkerID = markers.first?.id
}
if coordinate != nil {
if showsLocationRefreshToast {
showToast("已更新附近风险点位")
}
} catch {
errorMessage = error.localizedDescription
if markers.isEmpty {
markers = fallbackMarkers
region = WildPhotographerReportMockStore.region(for: markers)
selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id ?? markers.first?.id
}
showToast(error.localizedDescription)
}
@ -1538,7 +1419,7 @@ final class WildReportRiskMapViewModel {
case .yellow:
return "处理中线索"
case .red, .unknown:
return dto.reportTypeText.nonEmpty ?? "疑似打野"
return dto.reportTypeText.nonEmpty ?? "异常线索"
case .blue:
return "当前位置"
}
@ -1563,6 +1444,32 @@ final class WildReportRiskMapViewModel {
return String(format: "%.1f公里", meters / 1000)
}
private static func region(for markers: [WildReportMapMarker]) -> MKCoordinateRegion {
guard !markers.isEmpty else {
return MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 0, longitude: 0),
span: MKCoordinateSpan(latitudeDelta: 0.08, longitudeDelta: 0.08)
)
}
let latitudes = markers.map(\.coordinate.latitude)
let longitudes = markers.map(\.coordinate.longitude)
let minLat = latitudes.min() ?? markers[0].coordinate.latitude
let maxLat = latitudes.max() ?? markers[0].coordinate.latitude
let minLon = longitudes.min() ?? markers[0].coordinate.longitude
let maxLon = longitudes.max() ?? markers[0].coordinate.longitude
let center = CLLocationCoordinate2D(
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
)
return MKCoordinateRegion(
center: center,
span: MKCoordinateSpan(
latitudeDelta: max(0.018, (maxLat - minLat) * 1.6),
longitudeDelta: max(0.018, (maxLon - minLon) * 1.6)
)
)
}
private static func maskPhone(_ phone: String) -> String {
guard phone.count == 11, phone.allSatisfy(\.isNumber) else { return phone }
return "\(phone.prefix(3))****\(phone.suffix(4))"

View File

@ -1,6 +1,6 @@
# 举报摄影师接口文档
本文档记录 iOS `WildPhotographerReport` 模块当前使用的后端接口,以及仍处于 Mock 阶段的业务点
本文档记录 iOS `WildPhotographerReport` 模块当前使用的后端接口。
## 基础约定
@ -9,7 +9,59 @@
- 网络入口:`NetworkServices.shared.wildPhotographerReportAPI`
- 后端成功码:`100000`
- 响应包裹:工程统一 `APIEnvelope<T>`
- 当前状态:“举报类型”、“提交举报”、“我的举报列表”、“举报详情”“补充证据提交”已接接口;风险地图仍使用 Mock 数据闭环
- 当前状态:“首页文案”、“举报类型”、“提交举报”、“我的举报列表”、“举报详情”“补充证据提交”和“附近风险地图”已接接口;模块内生产 Mock 数据已移除
## 首页文案
### 用途
“举报摄影师”首页进入时拉取举报须知和举报规则文案。
### 请求
```http
GET /api/yf-handset-app/photog/report/copy
```
### 响应示例
```json
{
"code": 100000,
"msg": "success",
"data": {
"notice": [
"需实名登录",
"请尽量上传清晰证据",
"恶意举报将被追责"
],
"rule_groups": [
{
"title": "可举报行为",
"items": [
"疑似打野摄影师主动揽客",
"未佩戴工牌开展摄影服务",
"引导游客线下付款或私下交易"
]
}
]
},
"time": "2026-07-09 10:15:17"
}
```
### UI 行为
- `notice` 用于首页“举报须知”条目展示。
- `rule_groups` 非空时显示“举报规则”按钮,点击后在举报规则页按分组展示 `title``items`
- `notice``rule_groups` 都为空时,隐藏整个“举报须知”模块。
- 接口失败时不展示本地写死兜底文案,并 toast 错误。
### 当前调用位置
- `WildPhotographerReportHomeViewController.viewDidLoad()`
- `WildPhotographerReportHomeViewModel.loadReportCopy(api:)`
- `WildPhotographerReportAPI.reportCopy()`
## 1. 获取举报类型
@ -77,7 +129,7 @@ struct WildReportTypeListResponse: Decodable, Hashable {
- 每行最多展示 3 个类型,超过 3 个自动换行。
-`label` 展示文字,不展示图标。
- 默认选中 `value == 2` 的类型;如果接口返回中不存在该类型,则选中列表第一项。
- 接口失败时回退本地兜底列表,并提示“举报类型获取失败,已使用默认类型”
- 接口失败时不展示本地默认类型,并 toast 错误
### 当前调用位置
@ -139,7 +191,7 @@ POST /api/yf-handset-app/photog/report/submit
- `store_user_id`:被举报摄影师 ID当前页面无来源本轮暂不传。
- `contact_phone`:当前联系方式输入值,非空才传。
- `location_name` / `location_address`:从当前定位展示文案拆分;无法拆分时使用景区名和完整地址兜底。
- `lat` / `lng`:当前定位快照坐标;定位失败时使用 Mock 地址对应的兜底坐标
- `lat` / `lng`:当前定位快照坐标;定位失败时不提交举报,提示用户重新获取位置
- `evidences`:现场证据,必填,至少 1 项、最多 9 项。
- `payment_screenshots`:线下微信、支付宝截图,选填,最多 3 张。
- `file_type`:举报提交接口约定为 `1 = 图片``2 = 视频`
@ -307,7 +359,7 @@ GET /api/yf-handset-app/photog/report/detail?id=32
- `location_name` / `location_address`:详情页位置展示。
- `handle_status` / `handle_status_text`处理状态iOS 支持 0 待处理、1 处理中、2 已处理、3 驳回。
- `handle_remark`:处理备注,有值时在详情信息卡展示。
- `timeline`:处理进度时间线;为空时使用本地状态推导兜底
- `timeline`:处理进度时间线;为空时只展示列表记录中的当前处理状态,不生成本地模拟流程
### iOS 模型
@ -368,11 +420,8 @@ POST /api/yf-handset-app/photog/report/supplement
- `WildPhotographerReportAPI.supplementReport(_:)`
- `WildReportSupplementEvidenceViewController.submitTapped()`
## Mock 阶段业务
## 生产 Mock 清理
以下业务当前仍由 `WildPhotographerReportMockStore` 和 ViewModel 本地逻辑完成,暂未接真实接口:
- 附近风险地图
- 分享、媒体预览、地图导航演示提示
后续接入真实接口时,应优先在 `WildPhotographerReportServing` 中补充语义化方法,并保持 ViewModel 不依赖 UIKit 视图类型。
- 已删除本地种子举报、种子风险点、种子补充证据及对应生产代码文件。
- 举报类型、首页文案、列表、详情、补充证据和风险地图均不再使用本地写死数据兜底。
- 提交接口当前无业务 `data` 返回;提交成功页只展示本次用户提交快照,不伪造服务端举报编号,也不写入“我的举报”本地列表。