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

1231 lines
44 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
//
import Foundation
import MapKit
import UIKit
/// 便 OSS
@MainActor
protocol WildReportAttachmentUploading {
/// OSS URL
func uploadWildReportAttachment(
data: Data,
fileName: String,
fileType: Int,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String
}
extension OSSUploadService: WildReportAttachmentUploading {}
/// 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 reportTypes: [WildReportType] = WildReportType.fallbackOptions
private(set) var selectedReportType: WildReportType = .defaultSelected
private(set) var description = ""
private(set) var contact = ""
private(set) var reportLocation = ""
private(set) var isLoadingReportTypes = false
private(set) var isUpdatingLocation = false
private(set) var isSubmitting = false
private(set) var submitProgressText = ""
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
private var locationSnapshot: HomeLocationSnapshot?
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 = []
self.videos = []
}
///
func loadInitialLocationIfNeeded() {
guard !hasRequestedInitialLocation else { return }
hasRequestedInitialLocation = true
refreshCurrentLocation()
}
/// 退
func loadReportTypes(api: any WildPhotographerReportServing) async {
guard !isLoadingReportTypes else { return }
isLoadingReportTypes = true
notifyStateChange()
defer {
isLoadingReportTypes = false
notifyStateChange()
}
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 }) {
selectedReportType = updatedType
} else {
selectedReportType = reportTypes.first ?? .defaultSelected
}
} catch is CancellationError {
return
} catch {
reportTypes = WildReportType.fallbackOptions
if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) {
selectedReportType = updatedType
} else {
selectedReportType = .defaultSelected
}
onShowMessage?("举报类型获取失败,已使用默认类型")
}
}
///
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 addImages(_ attachments: [WildReportAttachment]) {
let remaining = max(0, 9 - evidenceCount)
guard remaining > 0 else {
onShowMessage?("现场证据最多上传9项")
return
}
images.append(contentsOf: attachments.prefix(remaining))
notifyStateChange()
}
///
func addImage() {
addImages([WildReportAttachment(kind: .image, title: "现场图片\(images.count + 1)")])
}
///
func addVideos(_ attachments: [WildReportAttachment]) {
let videoRemaining = max(0, 3 - videos.count)
let evidenceRemaining = max(0, 9 - evidenceCount)
let allowedCount = min(videoRemaining, evidenceRemaining)
guard allowedCount > 0 else {
onShowMessage?(videos.count >= 3 ? "现场视频最多上传3段" : "现场证据最多上传9项")
return
}
videos.append(contentsOf: attachments.prefix(allowedCount))
notifyStateChange()
}
///
func addVideo() {
addVideos([WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)")])
}
///
func addPaymentImages(_ attachments: [WildReportAttachment]) {
guard paymentImages.count < 3 else {
onShowMessage?("支付截图最多上传3张")
return
}
paymentImages.append(contentsOf: attachments.prefix(3 - paymentImages.count))
notifyStateChange()
}
///
func addPaymentImage() {
addPaymentImages([WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)")])
}
///
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()
applyResolvedLocation(snapshot)
} catch {
applyResolvedLocation(HomeLocationSnapshot(
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
address: WildPhotographerReportMockStore.fallbackLocation
))
}
}
}
///
func clearSubmitResult() {
submitResult = nil
notifyStateChange()
}
///
func submitReport(
api: any WildPhotographerReportServing,
uploader: any WildReportAttachmentUploading,
scenicId: Int,
scenicName: String
) async {
validationMessage = nil
guard validateBeforeSubmit(scenicId: scenicId) else { return }
guard !isSubmitting else { return }
isSubmitting = true
submitProgressText = "正在上传证据"
notifyStateChange()
defer {
isSubmitting = false
submitProgressText = ""
notifyStateChange()
}
do {
let evidenceRequests = try await uploadAttachments(
images + videos,
uploader: uploader,
scenicId: scenicId,
progressPrefix: "正在上传现场证据"
)
let paymentRequests = try await uploadAttachments(
paymentImages,
uploader: uploader,
scenicId: scenicId,
progressPrefix: "正在上传支付截图",
completedOffset: evidenceRequests.count,
totalOverride: evidenceRequests.count + paymentImages.count
)
submitProgressText = "正在提交举报"
notifyStateChange()
try await api.submitReport(buildSubmitRequest(
scenicId: scenicId,
scenicName: scenicName,
evidences: evidenceRequests,
paymentScreenshots: paymentRequests
))
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)
} catch is CancellationError {
return
} catch {
setValidationMessage(error.localizedDescription)
}
}
/// 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
description = ""
contact = ""
reportLocation = ""
locationConfirmed = false
isUpdatingLocation = false
isSubmitting = false
submitProgressText = ""
locationSnapshot = nil
hasRequestedInitialLocation = false
submitResult = nil
validationMessage = nil
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)
: snapshot.address
locationSnapshot = HomeLocationSnapshot(
latitude: snapshot.latitude,
longitude: snapshot.longitude,
address: address
)
reportLocation = address
locationConfirmed = true
isUpdatingLocation = false
notifyStateChange()
}
private func setValidationMessage(_ message: String) {
validationMessage = message
notifyStateChange()
onShowMessage?(message)
}
private func notifyStateChange() {
onStateChange?()
}
private func uniqueReportTypes(_ types: [WildReportType]) -> [WildReportType] {
var seen: Set<Int> = []
return types.filter { seen.insert($0.value).inserted }
}
private var evidenceCount: Int {
images.count + videos.count
}
private func validateBeforeSubmit(scenicId: Int) -> Bool {
guard scenicId > 0 else {
setValidationMessage("请先选择景区后再提交举报。")
return false
}
guard !images.isEmpty || !videos.isEmpty else {
setValidationMessage("请至少上传一项现场证据。")
return false
}
guard evidenceCount <= 9 else {
setValidationMessage("现场证据最多上传9项。")
return false
}
guard paymentImages.count <= 3 else {
setValidationMessage("支付截图最多上传3张。")
return false
}
guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
setValidationMessage("请填写举报说明。")
return false
}
guard !isUpdatingLocation else {
setValidationMessage("正在获取定位,请稍后再提交。")
return false
}
guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else {
setValidationMessage("请先更新举报位置后再提交举报。")
return false
}
return true
}
private func uploadAttachments(
_ attachments: [WildReportAttachment],
uploader: any WildReportAttachmentUploading,
scenicId: Int,
progressPrefix: String,
completedOffset: Int = 0,
totalOverride: Int? = nil
) async throws -> [WildReportEvidenceRequest] {
var requests: [WildReportEvidenceRequest] = []
let total = totalOverride ?? attachments.count
for (index, attachment) in attachments.enumerated() {
let current = completedOffset + index + 1
submitProgressText = "\(progressPrefix) \(current)/\(max(total, current))"
notifyStateChange()
let data = try attachment.uploadData()
let url = try await uploader.uploadWildReportAttachment(
data: data,
fileName: attachment.fileName,
fileType: attachment.fileType,
scenicId: scenicId
) { [weak self] progress in
self?.submitProgressText = "\(progressPrefix) \(current)/\(max(total, current)) · \(progress)%"
self?.notifyStateChange()
}
requests.append(WildReportEvidenceRequest(
fileURL: url,
fileType: attachment.fileType,
fileName: attachment.fileName
))
}
return requests
}
private func buildSubmitRequest(
scenicId: Int,
scenicName: String,
evidences: [WildReportEvidenceRequest],
paymentScreenshots: [WildReportEvidenceRequest]
) -> WildReportSubmitRequest {
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 trimmedContact = contact.trimmingCharacters(in: .whitespacesAndNewlines)
return WildReportSubmitRequest(
scenicId: scenicId,
reportType: selectedReportType.value,
desc: description.trimmingCharacters(in: .whitespacesAndNewlines),
storeUserId: nil,
contactPhone: trimmedContact.isEmpty ? nil : trimmedContact,
locationName: locationName.isEmpty ? nil : locationName,
locationAddress: locationParts.detail.isEmpty ? reportLocation : locationParts.detail,
lat: snapshot.latitude,
lng: snapshot.longitude,
evidences: evidences,
paymentScreenshots: paymentScreenshots
)
}
}
/// 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 {
private let initialRecord: WildReportRecord
private let homeViewModel: WildPhotographerReportHomeViewModel?
private(set) var detail: WildReportDetailData?
private(set) var isLoadingDetail = false
private(set) var detailErrorMessage: String?
private(set) var actionMessage: String?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel? = nil) {
self.initialRecord = record
self.homeViewModel = homeViewModel
}
var record: WildReportRecord { detail?.toRecord(fallback: initialRecord) ?? initialRecord }
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 { detail?.displayStatusText ?? record.status.rawValue }
var descriptionText: String { record.description }
var contactText: String { record.contactDisplayText }
var handleRemarkText: String { detail?.handleRemark.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" }
var supplementaryEvidences: [WildReportSupplementaryEvidence] {
if let detail {
return detail.supplementRecords
}
return homeViewModel?.supplementaryEvidences(for: record.id) ?? []
}
var reporterMediaItems: [WildReportReporterMediaItem] {
if let detail {
return detail.evidences.enumerated().map { index, evidence in
evidence.isVideo
? .video(index: index, url: evidence.fileURL, fileName: evidence.fileName, fileTypeText: evidence.fileTypeText)
: .image(index: index, url: evidence.fileURL, fileName: evidence.fileName, fileTypeText: evidence.fileTypeText)
}
}
let images = (0..<record.imageCount).map { WildReportReporterMediaItem.image(index: $0, url: nil, fileName: nil, fileTypeText: nil) }
let videos = (0..<record.videoCount).map { WildReportReporterMediaItem.video(index: $0, url: nil, fileName: nil, fileTypeText: nil) }
return images + videos
}
var paymentScreenshotItems: [WildReportReporterMediaItem] {
detail?.paymentScreenshots.enumerated().map { index, evidence in
let fileTypeText = evidence.fileTypeText.trimmingCharacters(in: .whitespacesAndNewlines)
return .image(
index: index,
url: evidence.fileURL,
fileName: evidence.fileName,
fileTypeText: fileTypeText.isEmpty ? "支付截图" : fileTypeText
)
} ?? []
}
var imageCountText: String { "\(record.imageCount)" }
var videoCountText: String { "\(record.videoCount)" }
var paymentScreenshotSummary: String { "\(paymentScreenshotItems.count)" }
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] {
if let detail, !detail.timeline.isEmpty {
return Self.buildProgressSteps(from: detail.timeline, status: record.status)
}
return Self.buildProgressSteps(for: record)
}
var showHandlerInfo: Bool { (record.status == .processed || record.status == .rejected) && record.handlerInfo != nil }
var handlerInfo: WildReportHandlerInfo? { record.handlerInfo }
/// ID
func loadDetailIfNeeded(api: any WildPhotographerReportServing, force: Bool = false) async {
guard let serverID = initialRecord.serverID, serverID > 0, (force || detail == nil), !isLoadingDetail else { return }
isLoadingDetail = true
detailErrorMessage = nil
notifyStateChange()
defer {
isLoadingDetail = false
notifyStateChange()
}
do {
detail = try await api.reportDetail(id: serverID)
} catch is CancellationError {
return
} catch {
detailErrorMessage = error.localizedDescription
onShowMessage?(error.localizedDescription)
}
}
///
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, .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
)
]
}
}
private static func buildProgressSteps(
from timeline: [WildReportDetailTimelineItem],
status: WildReportStatus
) -> [WildReportProgressStep] {
timeline.enumerated().map { index, item in
let isLast = index == timeline.count - 1
let lastIsOpen = isLast && (status == .pending || status == .processing)
return WildReportProgressStep(
id: "\(index)-\(item.time)",
title: item.content.isEmpty ? "处理进度" : item.content,
timeText: item.time,
state: lastIsOpen ? .current : .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 static let pageSize = 10
private(set) var selectedFilter: WildReportListFilter = .all
private(set) var shareMessage: String?
private(set) var reports: [WildReportRecord] = []
private(set) var isLoading = false
private(set) var isLoadingMore = false
private(set) var hasMore = true
private(set) var page = 1
private(set) var total = 0
private(set) var errorMessage: String?
let homeViewModel: WildPhotographerReportHomeViewModel
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
init(homeViewModel: WildPhotographerReportHomeViewModel) {
self.homeViewModel = homeViewModel
}
var filteredReports: [WildReportRecord] {
reports
}
var footerTip: String { "温馨提示:为保障处理效率,请如实举报并提供有效证据" }
var footerDisplayText: String {
if isLoadingMore { return "正在加载更多..." }
if isLoading && reports.isEmpty { return "正在加载举报记录..." }
if !hasMore && !reports.isEmpty { return footerTip }
return footerTip
}
///
func loadInitial(api: any WildPhotographerReportServing, scenicId: Int?) async {
await reload(api: api, scenicId: scenicId)
}
///
func selectFilter(_ filter: WildReportListFilter, api: any WildPhotographerReportServing, scenicId: Int?) async {
guard selectedFilter != filter else { return }
selectedFilter = filter
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 }
let nextPage = page + 1
isLoadingMore = true
errorMessage = nil
notifyStateChange()
defer {
isLoadingMore = false
notifyStateChange()
}
do {
let response = try await api.reportList(makeRequest(page: nextPage, scenicId: scenicId))
let newRecords = response.list.map { $0.toRecord() }
reports.append(contentsOf: newRecords)
page = response.page
total = response.total
hasMore = reports.count < response.total
} catch is CancellationError {
return
} catch {
errorMessage = error.localizedDescription
onShowMessage?(error.localizedDescription)
}
}
///
func shareReport(_ record: WildReportRecord) {
shareMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。"
onShowMessage?(shareMessage ?? "")
notifyStateChange()
}
private func reload(api: any WildPhotographerReportServing, scenicId: Int?) async {
let hadReportsBeforeReload = !reports.isEmpty
isLoading = true
isLoadingMore = false
errorMessage = nil
page = 1
hasMore = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
let response = try await api.reportList(makeRequest(page: 1, scenicId: scenicId))
reports = response.list.map { $0.toRecord() }
page = response.page
total = response.total
hasMore = reports.count < response.total
} catch is CancellationError {
return
} catch {
errorMessage = error.localizedDescription
hasMore = false
if !hadReportsBeforeReload {
reports = []
total = 0
}
onShowMessage?(error.localizedDescription)
}
}
private func makeRequest(page: Int, scenicId: Int?) -> WildReportListRequest {
WildReportListRequest(
scenicId: scenicId,
handleStatus: selectedFilter.handleStatusValue,
page: page,
pageSize: Self.pageSize
)
}
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?()
}
}
/// 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
private(set) var isSubmitting = false
private(set) var submitProgressText = ""
let reportID: String
let serverID: Int?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onSubmitSuccess: (() -> Void)?
private let homeViewModel: WildPhotographerReportHomeViewModel
init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel) {
self.reportID = record.id
self.serverID = record.serverID
self.homeViewModel = homeViewModel
}
/// 500
func updateText(_ newValue: String) {
text = String(newValue.prefix(500))
notifyStateChange()
}
///
func addImages(_ attachments: [WildReportAttachment]) {
let remaining = max(0, 9 - evidenceCount)
guard remaining > 0 else {
onShowMessage?("补充证据最多上传9项")
return
}
images.append(contentsOf: attachments.prefix(remaining))
notifyStateChange()
}
///
func addImage() {
addImages([WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)")])
}
///
func addVideos(_ attachments: [WildReportAttachment]) {
let videoRemaining = max(0, 3 - videos.count)
let evidenceRemaining = max(0, 9 - evidenceCount)
let allowedCount = min(videoRemaining, evidenceRemaining)
guard allowedCount > 0 else {
onShowMessage?(videos.count >= 3 ? "补充视频最多上传3段" : "补充证据最多上传9项")
return
}
videos.append(contentsOf: attachments.prefix(allowedCount))
notifyStateChange()
}
///
func addVideo() {
addVideos([WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)")])
}
///
func removeAttachment(_ attachment: WildReportAttachment) {
images.removeAll { $0.id == attachment.id }
videos.removeAll { $0.id == attachment.id }
notifyStateChange()
}
///
func submit(
api: any WildPhotographerReportServing,
uploader: any WildReportAttachmentUploading,
scenicId: Int
) async {
validationMessage = nil
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard validateBeforeSubmit(trimmedText: trimmedText, scenicId: scenicId) else { return }
guard !isSubmitting else { return }
isSubmitting = true
submitProgressText = evidenceCount > 0 ? "正在上传补充证据" : "正在提交补充证据"
notifyStateChange()
defer {
isSubmitting = false
submitProgressText = ""
notifyStateChange()
}
do {
let evidenceRequests = try await uploadAttachments(
images + videos,
uploader: uploader,
scenicId: scenicId
)
submitProgressText = "正在提交补充证据"
notifyStateChange()
try await api.supplementReport(WildReportSupplementRequest(
id: serverID ?? 0,
desc: trimmedText.isEmpty ? nil : trimmedText,
evidences: evidenceRequests.isEmpty ? nil : evidenceRequests
))
homeViewModel.appendSupplementaryEvidence(
reportID: reportID,
text: trimmedText,
imageCount: images.count,
videoCount: videos.count
)
didSubmit = true
notifyStateChange()
onSubmitSuccess?()
} catch is CancellationError {
return
} catch {
setValidationMessage(error.localizedDescription)
}
}
private var evidenceCount: Int {
images.count + videos.count
}
private func validateBeforeSubmit(trimmedText: String, scenicId: Int) -> Bool {
guard let serverID, serverID >= 1 else {
setValidationMessage("当前举报暂未同步到服务器,无法补充证据")
return false
}
guard !trimmedText.isEmpty || evidenceCount > 0 else {
setValidationMessage("请至少填写补充说明或上传一项证据。")
return false
}
guard evidenceCount <= 9 else {
setValidationMessage("补充证据最多上传9项。")
return false
}
guard videos.count <= 3 else {
setValidationMessage("补充视频最多上传3段。")
return false
}
if evidenceCount > 0, scenicId <= 0 {
setValidationMessage("请先选择景区后再提交补充证据。")
return false
}
return true
}
private func setValidationMessage(_ message: String) {
validationMessage = message
notifyStateChange()
onShowMessage?(message)
}
private func uploadAttachments(
_ attachments: [WildReportAttachment],
uploader: any WildReportAttachmentUploading,
scenicId: Int
) async throws -> [WildReportEvidenceRequest] {
var requests: [WildReportEvidenceRequest] = []
for (index, attachment) in attachments.enumerated() {
let current = index + 1
submitProgressText = "正在上传补充证据 \(current)/\(attachments.count)"
notifyStateChange()
let data = try attachment.uploadData()
let url = try await uploader.uploadWildReportAttachment(
data: data,
fileName: attachment.fileName,
fileType: attachment.fileType,
scenicId: scenicId
) { [weak self] progress in
self?.submitProgressText = "正在上传补充证据 \(current)/\(attachments.count) · \(progress)%"
self?.notifyStateChange()
}
requests.append(WildReportEvidenceRequest(
fileURL: url,
fileType: attachment.fileType,
fileName: attachment.fileName
))
}
return requests
}
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?()
}
}