Files
suixinkan_ios_new/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift

631 lines
21 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

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

//
// WildPhotographerReportViewModels.swift
// suixinkan
//
// Created by Codex on 2026/7/1.
//
import Combine
import CoreLocation
import MapKit
import SwiftUI
import UIKit
/// ViewModel Mock
@MainActor
final class WildPhotographerReportHomeViewModel: ObservableObject {
@Published var pageState: WildReportPageState = .normal
@Published private(set) var reports: [WildReportRecord] = []
@Published private(set) var riskPoints: [WildReportRiskPoint] = []
@Published private(set) var supplementaryEvidences: [String: [WildReportSupplementaryEvidence]] = [:]
private var nextSequence = 0
private var nextSupplementSequence = 0
init() {
resetDemoData()
}
var emptyTitle: String { "暂无举报服务" }
var emptyImage: String { "exclamationmark.shield" }
var emptyMessage: String { "当前景区暂未开放举报摄影师演示入口。" }
///
func restoreDemoData() {
resetDemoData()
pageState = .normal
}
///
func retryLoadAfterError() {
pageState = .loading
Task { @MainActor in
try? await Task.sleep(nanoseconds: 450_000_000)
pageState = .normal
}
}
/// 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)
return record
}
///
func supplementaryEvidences(for reportID: String) -> [WildReportSupplementaryEvidence] {
supplementaryEvidences[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 = supplementaryEvidences[reportID] ?? []
items.insert(item, at: 0)
supplementaryEvidences[reportID] = items
}
/// Mock
func resetDemoData() {
reports = WildPhotographerReportMockStore.seedReports
riskPoints = WildPhotographerReportMockStore.seedRiskPoints
supplementaryEvidences = WildPhotographerReportMockStore.seedSupplementaryEvidences
nextSequence = WildPhotographerReportMockStore.maxSequence(from: WildPhotographerReportMockStore.seedReports) + 1
nextSupplementSequence = 100
}
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)
}
}
/// ViewModel
@MainActor
final class WildPhotographerReportSubmitViewModel: ObservableObject {
@Published var pageState: WildReportPageState = .normal
@Published var images: [WildReportAttachment]
@Published var videos: [WildReportAttachment]
@Published var paymentImages: [WildReportAttachment] = []
@Published var selectedReportType: WildReportType = .suspectedWild
@Published var description = ""
@Published var contact = ""
@Published var reportLocation = ""
@Published var isUpdatingLocation = false
@Published var locationConfirmed = false
@Published var submitResult: WildReportSubmitResult?
@Published var validationMessage: String?
private let homeViewModel: WildPhotographerReportHomeViewModel
private let locationProvider: ForegroundLocationProvider
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: ForegroundLocationProvider? = nil
) {
self.homeViewModel = homeViewModel
self.locationProvider = locationProvider ?? ForegroundLocationProvider()
self.images = WildPhotographerReportMockStore.initialImages
self.videos = WildPhotographerReportMockStore.initialVideos
}
///
func loadInitialLocationIfNeeded() {
guard !hasRequestedInitialLocation else { return }
hasRequestedInitialLocation = true
refreshCurrentLocation()
}
/// 500
func updateDescription(_ newValue: String) {
description = newValue.count > 500 ? String(newValue.prefix(500)) : newValue
}
/// 使 Provider
func refreshCurrentLocation() {
isUpdatingLocation = true
locationConfirmed = false
reportLocation = "正在获取当前位置..."
Task {
do {
let result = try await locationProvider.requestCurrentLocation()
applyResolvedLocation(result.address)
} catch {
applyResolvedLocation(WildPhotographerReportMockStore.fallbackLocation)
}
}
}
///
func restoreDemoDataFromEmpty() {
pageState = .normal
refreshCurrentLocation()
}
///
func retryLoadAfterError() {
pageState = .loading
Task { @MainActor in
try? await Task.sleep(nanoseconds: 450_000_000)
pageState = .normal
}
}
///
func clearSubmitResult() {
submitResult = nil
}
///
func submitReport() {
guard !images.isEmpty || !videos.isEmpty else {
validationMessage = "请至少上传一项现场证据。"
return
}
guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
validationMessage = "请填写举报说明。"
return
}
guard !isUpdatingLocation else {
validationMessage = "正在获取定位,请稍后再提交。"
return
}
guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else {
validationMessage = "请先更新举报位置后再提交举报。"
return
}
let record = homeViewModel.submitReport(
reportType: selectedReportType,
description: description,
location: reportLocation,
contact: contact.isEmpty ? nil : contact,
imageCount: images.count,
videoCount: videos.count
)
submitResult = WildReportSubmitResult(
record: record,
imageCount: images.count,
videoCount: videos.count
)
}
///
func resetForm() {
images = WildPhotographerReportMockStore.initialImages
videos = WildPhotographerReportMockStore.initialVideos
paymentImages = []
selectedReportType = .suspectedWild
description = ""
contact = ""
reportLocation = ""
locationConfirmed = false
isUpdatingLocation = false
hasRequestedInitialLocation = false
submitResult = nil
}
private func applyResolvedLocation(_ location: String) {
reportLocation = location
locationConfirmed = true
isUpdatingLocation = false
}
}
/// ViewModel
@MainActor
final class WildPhotographerReportSuccessViewModel: ObservableObject {
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: " ")
}
///
func copyReportID() -> String {
UIPasteboard.general.string = result.record.id
return result.record.id
}
}
/// ViewModel
@MainActor
final class WildPhotographerReportDetailViewModel: ObservableObject {
let record: WildReportRecord
private let homeViewModel: WildPhotographerReportHomeViewModel?
@Published var actionMessage: String?
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 displayStatusColor: Color { record.status.displayColor }
var descriptionText: String { record.description }
var contactText: String { record.contactDisplayText }
var supplementaryEvidences: [WildReportSupplementaryEvidence] {
homeViewModel?.supplementaryEvidences(for: record.id) ?? []
}
var imageCountText: String { "\(record.imageCount)" }
var videoCountText: String { record.videoCount > 0 ? "\(record.videoCount)" : "0段" }
var reporterMediaSummary: String {
switch (record.imageCount, record.videoCount) {
case (0, 0): "0项"
case (_, 0): "\(record.imageCount)"
case (0, _): "\(record.videoCount)"
default: "\(record.imageCount)\(record.videoCount)"
}
}
var reporterMediaItems: [WildReportReporterMediaItem] {
var items: [WildReportReporterMediaItem] = []
for index in 0..<record.imageCount { items.append(.image(index: index)) }
for index in 0..<record.videoCount { items.append(.video(index: index)) }
return items
}
var progressSteps: [WildReportProgressStep] {
Self.buildProgressSteps(for: record)
}
var showHandlerInfo: Bool {
record.status == .processed && record.handlerInfo != nil
}
var handlerInfo: WildReportHandlerInfo? { record.handlerInfo }
///
func showReporterMediaPreview(_ item: WildReportReporterMediaItem) {
switch item {
case .image: showMediaPreview(kind: "图片")
case .video: showMediaPreview(kind: "视频")
}
}
///
func showMediaPreview(kind: String) {
actionMessage = "演示环境暂不支持预览\(kind),可在提交页查看上传内容。"
}
///
func showLocationPreview() {
actionMessage = "举报位置:\(record.location)"
}
///
func shareReport() {
actionMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。"
}
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)
}
}
/// ViewModel
@MainActor
final class WildPhotographerReportListViewModel: ObservableObject {
@Published var selectedFilter: WildReportListFilter = .all
@Published var shareMessage: String?
let homeViewModel: WildPhotographerReportHomeViewModel
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) {
withAnimation(.easeInOut(duration: 0.22)) {
selectedFilter = filter
}
}
///
func shareReport(_ record: WildReportRecord) {
shareMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。"
}
}
/// ViewModel
@MainActor
final class WildReportSupplementEvidenceViewModel: ObservableObject {
@Published var text = ""
@Published var images: [WildReportAttachment] = []
@Published var videos: [WildReportAttachment] = []
@Published var validationMessage: String?
@Published private(set) var didSubmit = false
let reportID: String
private let homeViewModel: WildPhotographerReportHomeViewModel
init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) {
self.reportID = reportID
self.homeViewModel = homeViewModel
}
/// 500
func updateText(_ newValue: String) {
text = newValue.count > 500 ? String(newValue.prefix(500)) : newValue
}
///
func addImage() {
guard images.count < 9 else { return }
images.append(WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)"))
}
///
func addVideo() {
guard videos.count < 3 else { return }
videos.append(WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)"))
}
///
func submit() {
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedText.isEmpty || !images.isEmpty || !videos.isEmpty else {
validationMessage = "请至少填写补充说明或上传一项证据。"
return
}
homeViewModel.appendSupplementaryEvidence(
reportID: reportID,
text: trimmedText,
imageCount: images.count,
videoCount: videos.count
)
didSubmit = true
}
}
/// ViewModel
@MainActor
final class WildReportRiskMapViewModel: ObservableObject {
@Published var region: MKCoordinateRegion
@Published var markers: [WildReportMapMarker]
let scenicAreaName: String
@Published var selectedMarkerID: String?
@Published var previewMedia: WildReportMapMediaItem?
@Published var previewImageURL: WildReportPreviewImageURL?
@Published var toastMessage: String?
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
}
///
func showMediaPreview(_ media: WildReportMapMediaItem) {
previewMedia = media
}
///
func dismissMediaPreview() {
previewMedia = nil
}
///
func showImagePreview(_ url: String) {
previewImageURL = WildReportPreviewImageURL(url: url)
}
///
func dismissImagePreview() {
previewImageURL = nil
}
///
func navigateToSelectedMarker() {
guard let marker = selectedMarker,
case .activeClue(let clue) = marker.detail else { return }
if clue.type == .blue {
refreshLocation()
return
}
let mapItem = MKMapItem(placemark: MKPlacemark(coordinate: marker.coordinate))
mapItem.name = marker.title
mapItem.openInMaps(launchOptions: [
MKLaunchOptionsMapCenterKey: NSValue(mkCoordinate: marker.coordinate),
MKLaunchOptionsMapSpanKey: NSValue(mkCoordinateSpan: region.span)
])
showToast("已切换到该点位")
}
///
func refreshLocation() {
guard let blueMarker = markers.first(where: { $0.kind == .selfLocation }) else { return }
selectedMarkerID = blueMarker.id
withAnimation(.easeInOut(duration: 0.35)) {
region = MKCoordinateRegion(
center: blueMarker.coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
)
}
showToast("已定位到当前位置")
}
/// 线
func shareSelectedClue() {
guard let clue = selectedClue else { return }
let title = clue.type == .red ? "线索 ID\(clue.id)" : clue.displayTitle
showToast("已生成\(title)的分享信息,可发送给他人查看。")
}
private func showToast(_ message: String) {
toastMessage = message
}
/// Toast
func clearToast() {
toastMessage = nil
}
}