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,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?()
}
}