1488 lines
54 KiB
Swift
1488 lines
54 KiB
Swift
//
|
||
// 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 {}
|
||
|
||
/// 举报摄影师模块通用日期格式化工具。
|
||
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 noticeItems: [String] = []
|
||
private(set) var ruleGroups: [WildReportRuleGroup] = []
|
||
private(set) var isLoadingCopy = false
|
||
|
||
var onStateChange: (() -> Void)?
|
||
var onShowMessage: ((String) -> Void)?
|
||
|
||
var emptyTitle: String { "暂无举报服务" }
|
||
var emptyMessage: String { "当前景区暂未开放举报摄影师入口。" }
|
||
var shouldShowNoticeModule: Bool { !noticeItems.isEmpty || !ruleGroups.isEmpty }
|
||
var shouldShowRuleButton: Bool { !ruleGroups.isEmpty }
|
||
|
||
/// 加载首页举报须知与规则文案。
|
||
func loadReportCopy(api: any WildPhotographerReportServing) async {
|
||
guard !isLoadingCopy else { return }
|
||
isLoadingCopy = true
|
||
notifyStateChange()
|
||
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()
|
||
}
|
||
|
||
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] = []
|
||
private(set) var selectedReportType: WildReportType?
|
||
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
|
||
if let currentSelectedType = selectedReportType,
|
||
let updatedType = reportTypes.first(where: { $0.value == currentSelectedType.value }) {
|
||
selectedReportType = updatedType
|
||
} else {
|
||
selectedReportType = reportTypes.first
|
||
}
|
||
} catch is CancellationError {
|
||
return
|
||
} catch {
|
||
reportTypes = []
|
||
selectedReportType = nil
|
||
onShowMessage?(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 选择举报类型。
|
||
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 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 addPaymentImages(_ attachments: [WildReportAttachment]) {
|
||
guard paymentImages.count < 3 else {
|
||
onShowMessage?("支付截图最多上传3张")
|
||
return
|
||
}
|
||
paymentImages.append(contentsOf: attachments.prefix(3 - paymentImages.count))
|
||
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()
|
||
applyResolvedLocation(snapshot)
|
||
} catch {
|
||
applyLocationFailure(error.localizedDescription)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 清除提交结果,用于导航返回。
|
||
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
|
||
notifyStateChange()
|
||
defer {
|
||
isSubmitting = false
|
||
submitProgressText = ""
|
||
notifyStateChange()
|
||
}
|
||
|
||
do {
|
||
let evidenceRequests = try await uploadAttachments(
|
||
images + videos,
|
||
uploader: uploader,
|
||
scenicId: scenicId
|
||
)
|
||
let paymentRequests = try await uploadAttachments(
|
||
paymentImages,
|
||
uploader: uploader,
|
||
scenicId: scenicId
|
||
)
|
||
|
||
let submitResponse = try await api.submitReport(buildSubmitRequest(
|
||
scenicId: scenicId,
|
||
scenicName: scenicName,
|
||
evidences: evidenceRequests,
|
||
paymentScreenshots: paymentRequests
|
||
))
|
||
|
||
let record = buildSubmittedRecord(response: submitResponse)
|
||
let result = WildReportSubmitResult(
|
||
record: record,
|
||
imageCount: images.count,
|
||
videoCount: videos.count
|
||
)
|
||
submitResult = result
|
||
notifyStateChange()
|
||
onSubmitSuccess?(result)
|
||
} catch is CancellationError {
|
||
return
|
||
} catch {
|
||
setValidationMessage(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
/// 重置表单到初始状态。
|
||
func resetForm() {
|
||
images = []
|
||
videos = []
|
||
paymentImages = []
|
||
reportTypes = []
|
||
selectedReportType = nil
|
||
description = ""
|
||
contact = ""
|
||
reportLocation = ""
|
||
locationConfirmed = false
|
||
isUpdatingLocation = false
|
||
isSubmitting = false
|
||
submitProgressText = ""
|
||
locationSnapshot = nil
|
||
hasRequestedInitialLocation = false
|
||
submitResult = nil
|
||
validationMessage = nil
|
||
notifyStateChange()
|
||
}
|
||
|
||
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 applyLocationFailure(_ message: String) {
|
||
reportLocation = ""
|
||
locationSnapshot = nil
|
||
locationConfirmed = false
|
||
isUpdatingLocation = false
|
||
notifyStateChange()
|
||
onShowMessage?(message)
|
||
}
|
||
|
||
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 selectedReportType != nil 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
|
||
}
|
||
guard locationSnapshot != nil else {
|
||
setValidationMessage("缺少定位坐标,请重新更新举报位置。")
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
private func uploadAttachments(
|
||
_ attachments: [WildReportAttachment],
|
||
uploader: any WildReportAttachmentUploading,
|
||
scenicId: Int
|
||
) async throws -> [WildReportEvidenceRequest] {
|
||
var requests: [WildReportEvidenceRequest] = []
|
||
for attachment in attachments {
|
||
let data = try attachment.uploadData()
|
||
let url = try await uploader.uploadWildReportAttachment(
|
||
data: data,
|
||
fileName: attachment.fileName,
|
||
fileType: attachment.fileType,
|
||
scenicId: scenicId
|
||
) { _ in }
|
||
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: 0, longitude: 0, address: reportLocation)
|
||
let trimmedContact = contact.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return WildReportSubmitRequest(
|
||
scenicId: scenicId,
|
||
reportType: selectedReportType?.value ?? 0,
|
||
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
|
||
)
|
||
}
|
||
|
||
private func buildSubmittedRecord(response: WildReportSubmitResponse) -> WildReportRecord {
|
||
let parsedContact = WildReportContactParser.parse(contact)
|
||
return WildReportRecord(
|
||
serverID: response.id > 0 ? response.id : nil,
|
||
id: response.complaintNo,
|
||
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。
|
||
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 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 }
|
||
var contactText: String { record.contactDisplayText }
|
||
var handleRemarkText: String { detail?.handleRemark.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" }
|
||
var supplementaryEvidences: [WildReportSupplementaryEvidence] {
|
||
if let detail {
|
||
return detail.supplementRecords
|
||
}
|
||
return []
|
||
}
|
||
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(api: any WildPhotographerReportServing) async -> WeChatMiniProgramSharePayload? {
|
||
guard let serverID = record.serverID, serverID > 0 else {
|
||
showActionMessage("当前举报暂未同步到服务器,无法分享")
|
||
return nil
|
||
}
|
||
do {
|
||
let shareData = try await api.reportShare(id: String(serverID))
|
||
return shareData.payload
|
||
} catch is CancellationError {
|
||
return nil
|
||
} catch {
|
||
showActionMessage(error.localizedDescription)
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private func showActionMessage(_ message: String) {
|
||
actionMessage = message
|
||
onShowMessage?(message)
|
||
notifyStateChange()
|
||
}
|
||
|
||
private static func buildProgressSteps(for record: WildReportRecord) -> [WildReportProgressStep] {
|
||
[
|
||
WildReportProgressStep(
|
||
id: "current-status",
|
||
title: record.status.rawValue,
|
||
timeText: record.submitTime,
|
||
state: record.status == .pending || record.status == .processing ? .current : .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 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)
|
||
}
|
||
|
||
/// 滚动到底部时加载下一页。
|
||
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, api: any WildPhotographerReportServing) async -> WeChatMiniProgramSharePayload? {
|
||
guard let serverID = record.serverID, serverID > 0 else {
|
||
shareMessage = "当前举报暂未同步到服务器,无法分享"
|
||
onShowMessage?(shareMessage ?? "")
|
||
notifyStateChange()
|
||
return nil
|
||
}
|
||
do {
|
||
let shareData = try await api.reportShare(id: String(serverID))
|
||
shareMessage = nil
|
||
notifyStateChange()
|
||
return shareData.payload
|
||
} catch is CancellationError {
|
||
return nil
|
||
} catch {
|
||
shareMessage = error.localizedDescription
|
||
onShowMessage?(shareMessage ?? "")
|
||
notifyStateChange()
|
||
return nil
|
||
}
|
||
}
|
||
|
||
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 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 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 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
|
||
notifyStateChange()
|
||
defer {
|
||
isSubmitting = false
|
||
submitProgressText = ""
|
||
notifyStateChange()
|
||
}
|
||
|
||
do {
|
||
let evidenceRequests = try await uploadAttachments(
|
||
images + videos,
|
||
uploader: uploader,
|
||
scenicId: scenicId
|
||
)
|
||
|
||
try await api.supplementReport(WildReportSupplementRequest(
|
||
id: serverID ?? 0,
|
||
desc: trimmedText.isEmpty ? nil : trimmedText,
|
||
evidences: evidenceRequests.isEmpty ? nil : evidenceRequests
|
||
))
|
||
|
||
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 attachment in attachments {
|
||
let data = try attachment.uploadData()
|
||
let url = try await uploader.uploadWildReportAttachment(
|
||
data: data,
|
||
fileName: attachment.fileName,
|
||
fileType: attachment.fileType,
|
||
scenicId: scenicId
|
||
) { _ in }
|
||
requests.append(WildReportEvidenceRequest(
|
||
fileURL: url,
|
||
fileType: attachment.fileType,
|
||
fileName: attachment.fileName
|
||
))
|
||
}
|
||
return requests
|
||
}
|
||
|
||
private func notifyStateChange() {
|
||
onStateChange?()
|
||
}
|
||
}
|
||
|
||
/// 附近风险地图 ViewModel。
|
||
final class WildReportRiskMapViewModel {
|
||
private static let riskMapLocationAccuracy = kCLLocationAccuracyHundredMeters
|
||
|
||
private var loadedDetailMarkerIDs: Set<String> = []
|
||
|
||
private(set) var region: MKCoordinateRegion
|
||
private(set) var markers: [WildReportMapMarker]
|
||
private(set) var scenicAreaName: String
|
||
private(set) var selectedMarkerID: String?
|
||
private(set) var isLoading = false
|
||
private(set) var isLoadingDetail = false
|
||
private(set) var errorMessage: String?
|
||
private(set) var greenMarkerTip = ""
|
||
private(set) var legendItems: [WildReportRiskLegendItem] = []
|
||
private(set) var currentCoordinate: CLLocationCoordinate2D?
|
||
|
||
var onStateChange: (() -> Void)?
|
||
|
||
init() {
|
||
self.markers = []
|
||
self.region = Self.region(for: [])
|
||
let scenicName = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
self.scenicAreaName = scenicName.isEmpty ? "当前景区" : scenicName
|
||
self.selectedMarkerID = nil
|
||
}
|
||
|
||
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 loadInitial(
|
||
api: any WildPhotographerReportServing,
|
||
locationProvider: any LocationProviding,
|
||
scenicId: Int? = nil,
|
||
scenicName: String? = nil
|
||
) async {
|
||
await loadRiskMap(
|
||
api: api,
|
||
locationProvider: locationProvider,
|
||
scenicId: scenicId,
|
||
scenicName: scenicName,
|
||
selectCurrentLocation: selectedMarkerID == nil || selectedMarker?.kind == .selfLocation
|
||
)
|
||
}
|
||
|
||
/// 选中地图标记。
|
||
func selectMarker(
|
||
_ marker: WildReportMapMarker,
|
||
api: (any WildPhotographerReportServing)? = nil,
|
||
scenicId: Int? = nil
|
||
) async {
|
||
selectedMarkerID = marker.id
|
||
region = MKCoordinateRegion(
|
||
center: marker.coordinate,
|
||
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
|
||
)
|
||
notifyStateChange()
|
||
|
||
guard marker.kind != .selfLocation,
|
||
!loadedDetailMarkerIDs.contains(marker.id),
|
||
let api,
|
||
let request = markerDetailRequest(for: marker, scenicId: scenicId)
|
||
else { return }
|
||
|
||
isLoadingDetail = true
|
||
notifyStateChange()
|
||
do {
|
||
let detail = try await api.markerDetail(request)
|
||
mergeDetail(detail, intoMarkerID: marker.id)
|
||
loadedDetailMarkerIDs.insert(marker.id)
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
isLoadingDetail = false
|
||
notifyStateChange()
|
||
}
|
||
|
||
/// 导航到选中标记或刷新蓝点定位。
|
||
@discardableResult
|
||
func navigateToSelectedMarker() -> WildReportMapMarker? {
|
||
guard let marker = selectedMarker,
|
||
case .activeClue(let clue) = marker.detail else { return nil }
|
||
|
||
if clue.type == .blue {
|
||
return nil
|
||
}
|
||
|
||
region = MKCoordinateRegion(
|
||
center: marker.coordinate,
|
||
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
|
||
)
|
||
notifyStateChange()
|
||
return marker
|
||
}
|
||
|
||
/// 将地图快速移动到已加载的当前位置标记。
|
||
@discardableResult
|
||
func focusCurrentLocation() -> Bool {
|
||
guard let marker = markers.first(where: { $0.kind == .selfLocation }) else { return false }
|
||
selectedMarkerID = marker.id
|
||
region = MKCoordinateRegion(
|
||
center: marker.coordinate,
|
||
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
|
||
)
|
||
notifyStateChange()
|
||
return true
|
||
}
|
||
|
||
/// 将地图居中到当前位置标记。
|
||
func refreshLocation(
|
||
api: any WildPhotographerReportServing,
|
||
locationProvider: any LocationProviding,
|
||
scenicId: Int? = nil,
|
||
scenicName: String? = nil
|
||
) async {
|
||
await loadRiskMap(
|
||
api: api,
|
||
locationProvider: locationProvider,
|
||
scenicId: scenicId,
|
||
scenicName: scenicName,
|
||
selectCurrentLocation: true
|
||
)
|
||
}
|
||
|
||
/// 生成线索分享提示。
|
||
func shareSelectedClue() {
|
||
_ = selectedClue
|
||
}
|
||
|
||
/// 生成图片预览提示。
|
||
func showImagePreview(_ url: String) {
|
||
_ = url
|
||
}
|
||
|
||
private func loadRiskMap(
|
||
api: any WildPhotographerReportServing,
|
||
locationProvider: any LocationProviding,
|
||
scenicId: Int?,
|
||
scenicName: String?,
|
||
selectCurrentLocation: Bool
|
||
) async {
|
||
let resolvedScenicId = scenicId ?? AppStore.shared.currentScenicId
|
||
guard resolvedScenicId >= 1 else {
|
||
errorMessage = "请先选择景区后再查看风险地图"
|
||
notifyStateChange()
|
||
return
|
||
}
|
||
|
||
let coordinate = await requestRiskMapCoordinate(locationProvider: locationProvider)
|
||
currentCoordinate = coordinate
|
||
await requestRiskMap(
|
||
api: api,
|
||
scenicId: resolvedScenicId,
|
||
scenicName: scenicName,
|
||
coordinate: coordinate,
|
||
selectCurrentLocation: selectCurrentLocation
|
||
)
|
||
}
|
||
|
||
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
|
||
) async {
|
||
isLoading = true
|
||
errorMessage = nil
|
||
notifyStateChange()
|
||
|
||
do {
|
||
let response = try await api.riskMap(WildReportRiskMapRequest(
|
||
scenicId: scenicId,
|
||
latitude: coordinate?.latitude,
|
||
longitude: coordinate?.longitude
|
||
))
|
||
scenicAreaName = response.scenicName.nonEmpty
|
||
?? scenicName?.nonEmpty
|
||
?? AppStore.shared.currentScenicName.nonEmpty
|
||
?? scenicAreaName
|
||
greenMarkerTip = response.greenMarkerTip
|
||
legendItems = response.legend
|
||
markers = buildMarkers(response: response, currentCoordinate: coordinate)
|
||
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
|
||
}
|
||
} catch {
|
||
errorMessage = error.localizedDescription
|
||
}
|
||
|
||
isLoading = false
|
||
notifyStateChange()
|
||
}
|
||
|
||
private func buildMarkers(
|
||
response: WildReportRiskMapResponse,
|
||
currentCoordinate: CLLocationCoordinate2D?
|
||
) -> [WildReportMapMarker] {
|
||
let remoteMarkers = response.markers.compactMap {
|
||
marker(from: $0, greenMarkerTip: response.greenMarkerTip)
|
||
}
|
||
var result: [WildReportMapMarker] = []
|
||
if let currentCoordinate {
|
||
result.append(currentLocationMarker(
|
||
coordinate: currentCoordinate,
|
||
nearestAbnormalText: nearestAbnormalText(for: remoteMarkers)
|
||
))
|
||
}
|
||
result.append(contentsOf: remoteMarkers)
|
||
return result
|
||
}
|
||
|
||
private func currentLocationMarker(
|
||
coordinate: CLLocationCoordinate2D,
|
||
nearestAbnormalText: String
|
||
) -> WildReportMapMarker {
|
||
WildReportMapMarker(
|
||
id: "blue-current",
|
||
title: "我的位置",
|
||
coordinate: coordinate,
|
||
kind: .selfLocation,
|
||
detail: .activeClue(WildReportRadarActiveClue(
|
||
id: "blue-current",
|
||
type: .blue,
|
||
displayTitle: "当前位置",
|
||
statusText: "当前位置",
|
||
actionText: "刷新定位",
|
||
nearestAbnormalText: nearestAbnormalText,
|
||
distance: "0米",
|
||
direction: "当前位置",
|
||
updatedAt: "实时定位",
|
||
name: scenicAreaName,
|
||
storeName: nil,
|
||
avatar: nil,
|
||
summary: "蓝色标记为当前位置,可对比周边摄影师风险点位距离。",
|
||
detail: "点击刷新定位可重新计算周边风险点位距离。",
|
||
distanceText: "0米",
|
||
reporterName: nil,
|
||
reporterPhone: nil,
|
||
maskedReporterPhone: nil,
|
||
reportContents: [],
|
||
images: [],
|
||
processingStarted: false,
|
||
completed: false,
|
||
record: nil,
|
||
processingTimeline: [],
|
||
completionPhoto: nil
|
||
))
|
||
)
|
||
}
|
||
|
||
private func marker(
|
||
from dto: WildReportRiskMapMarkerDTO,
|
||
greenMarkerTip: String
|
||
) -> WildReportMapMarker? {
|
||
guard let latitude = Double(dto.latitude),
|
||
let longitude = Double(dto.longitude),
|
||
dto.id > 0
|
||
else { return nil }
|
||
|
||
let markerType = WildReportRiskMarkerType(rawValue: dto.markerType)
|
||
let statusText = dto.handleStatusText?.nonEmpty
|
||
?? dto.handleStatus?.displayText
|
||
?? defaultStatusText(for: markerType, dto: dto)
|
||
let title = dto.title.nonEmpty ?? dto.locationName.nonEmpty ?? defaultTitle(for: markerType, id: dto.id)
|
||
let clue = WildReportRadarActiveClue(
|
||
id: String(dto.id),
|
||
type: markerType.radarType,
|
||
displayTitle: title,
|
||
statusText: statusText,
|
||
actionText: markerType == .blue ? "刷新定位" : "导航到此",
|
||
nearestAbnormalText: nil,
|
||
distance: formattedDistance(dto.distanceM),
|
||
direction: nil,
|
||
updatedAt: dto.createdAt.nonEmpty,
|
||
name: dto.name?.nonEmpty ?? dto.locationName.nonEmpty,
|
||
storeName: dto.storeName?.nonEmpty,
|
||
avatar: dto.avatar?.nonEmpty,
|
||
summary: markerType == .green ? (dto.greenMarkerTip?.nonEmpty ?? greenMarkerTip.nonEmpty) : dto.reportTypeText.nonEmpty,
|
||
detail: dto.activityText?.nonEmpty,
|
||
distanceText: formattedDistance(dto.distanceM),
|
||
reporterName: nil,
|
||
reporterPhone: nil,
|
||
maskedReporterPhone: nil,
|
||
reportContents: dto.reportTypeText.nonEmpty.map {
|
||
[WildReportRadarReportContent(time: dto.createdAt.nonEmpty ?? "", content: $0)]
|
||
} ?? [],
|
||
images: [],
|
||
processingStarted: markerType == .yellow || statusText == WildReportStatus.processing.rawValue,
|
||
completed: statusText == WildReportStatus.processed.rawValue,
|
||
record: nil,
|
||
processingTimeline: [],
|
||
completionPhoto: nil
|
||
)
|
||
return WildReportMapMarker(
|
||
id: "\(markerType.rawValue)-\(dto.id)",
|
||
title: title,
|
||
coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude),
|
||
kind: markerType.markerKind,
|
||
detail: .activeClue(clue)
|
||
)
|
||
}
|
||
|
||
private func markerDetailRequest(
|
||
for marker: WildReportMapMarker,
|
||
scenicId: Int?
|
||
) -> WildReportMarkerDetailRequest? {
|
||
let resolvedScenicId = scenicId ?? AppStore.shared.currentScenicId
|
||
guard resolvedScenicId >= 1 else { return nil }
|
||
let parts = marker.id.split(separator: "-", maxSplits: 1).map(String.init)
|
||
guard parts.count == 2, let id = Int(parts[1]), id >= 1 else { return nil }
|
||
return WildReportMarkerDetailRequest(
|
||
scenicId: resolvedScenicId,
|
||
markerType: parts[0],
|
||
id: id,
|
||
latitude: currentCoordinate?.latitude,
|
||
longitude: currentCoordinate?.longitude
|
||
)
|
||
}
|
||
|
||
private func mergeDetail(_ detail: WildReportMarkerDetailResponse, intoMarkerID markerID: String) {
|
||
guard let index = markers.firstIndex(where: { $0.id == markerID }),
|
||
case .activeClue(let oldClue) = markers[index].detail
|
||
else { return }
|
||
|
||
let markerType = WildReportRiskMarkerType(rawValue: detail.markerType.nonEmpty ?? oldClue.type.rawValue)
|
||
let statusText = detail.statusTag.nonEmpty
|
||
?? detail.handleStatusText.nonEmpty
|
||
?? detail.handleStatus?.displayText
|
||
?? oldClue.statusText
|
||
let reportContents = !detail.reportContents.isEmpty
|
||
? detail.reportContents.map { WildReportRadarReportContent(time: $0.time, content: $0.desc) }
|
||
: (detail.desc.nonEmpty.map { [WildReportRadarReportContent(time: detail.createdAt, content: $0)] } ?? oldClue.reportContents)
|
||
let imageURLs = detail.evidences.filter(\.isImage).map(\.fileURL)
|
||
let clue = WildReportRadarActiveClue(
|
||
id: detail.clueId.nonEmpty ?? String(detail.id > 0 ? detail.id : Int(oldClue.id) ?? 0),
|
||
type: markerType.radarType,
|
||
displayTitle: detail.title.nonEmpty ?? oldClue.displayTitle,
|
||
statusText: statusText,
|
||
actionText: oldClue.actionText,
|
||
nearestAbnormalText: oldClue.nearestAbnormalText,
|
||
distance: formattedDistance(detail.distanceM),
|
||
direction: oldClue.direction,
|
||
updatedAt: detail.createdAt.nonEmpty ?? oldClue.updatedAt,
|
||
name: detail.name.nonEmpty ?? oldClue.name,
|
||
storeName: detail.storeName.nonEmpty ?? oldClue.storeName,
|
||
avatar: detail.avatar.nonEmpty ?? oldClue.avatar,
|
||
summary: detail.greenMarkerTip.nonEmpty ?? detail.reportTypeText.nonEmpty ?? oldClue.summary,
|
||
detail: detail.activityText.nonEmpty ?? detail.desc.nonEmpty ?? oldClue.detail,
|
||
distanceText: formattedDistance(detail.distanceM) ?? oldClue.distanceText,
|
||
reporterName: detail.reporterName.nonEmpty ?? oldClue.reporterName,
|
||
reporterPhone: detail.reporterPhone.nonEmpty ?? oldClue.reporterPhone,
|
||
maskedReporterPhone: detail.reporterPhone.nonEmpty.map(Self.maskPhone) ?? oldClue.maskedReporterPhone,
|
||
reportContents: reportContents,
|
||
images: imageURLs.isEmpty ? oldClue.images : imageURLs,
|
||
processingStarted: markerType == .yellow || statusText == WildReportStatus.processing.rawValue,
|
||
completed: statusText == WildReportStatus.processed.rawValue,
|
||
record: oldClue.record,
|
||
processingTimeline: oldClue.processingTimeline,
|
||
completionPhoto: oldClue.completionPhoto
|
||
)
|
||
markers[index] = WildReportMapMarker(
|
||
id: markers[index].id,
|
||
title: detail.title.nonEmpty ?? markers[index].title,
|
||
coordinate: markers[index].coordinate,
|
||
kind: markerType.markerKind,
|
||
detail: .activeClue(clue)
|
||
)
|
||
}
|
||
|
||
private func nearestAbnormalText(for markers: [WildReportMapMarker]) -> String {
|
||
let distances = markers.compactMap { marker -> Double? in
|
||
guard case .activeClue(let clue) = marker.detail,
|
||
clue.type == .red || clue.type == .yellow,
|
||
let distance = clue.distanceText ?? clue.distance
|
||
else { return nil }
|
||
if distance.hasSuffix("公里") {
|
||
return Double(distance.replacingOccurrences(of: "公里", with: "")).map { $0 * 1000 }
|
||
}
|
||
return Double(distance.replacingOccurrences(of: "米", with: ""))
|
||
}
|
||
guard let minDistance = distances.min() else { return "周边暂无异常点位" }
|
||
return "距离最近的异常点位\(formattedDistance(minDistance) ?? "0米")"
|
||
}
|
||
|
||
private func defaultStatusText(for markerType: WildReportRiskMarkerType, dto: WildReportRiskMapMarkerDTO) -> String {
|
||
switch markerType {
|
||
case .green:
|
||
return dto.online == false ? "离线" : "在线"
|
||
case .yellow:
|
||
return "处理中线索"
|
||
case .red, .unknown:
|
||
return dto.reportTypeText.nonEmpty ?? "异常线索"
|
||
case .blue:
|
||
return "当前位置"
|
||
}
|
||
}
|
||
|
||
private func defaultTitle(for markerType: WildReportRiskMarkerType, id: Int) -> String {
|
||
switch markerType {
|
||
case .green:
|
||
return "景区内摄影师"
|
||
case .yellow, .red, .unknown:
|
||
return "线索 ID:\(id)"
|
||
case .blue:
|
||
return "我的位置"
|
||
}
|
||
}
|
||
|
||
private func formattedDistance(_ meters: Double) -> String? {
|
||
guard meters > 0 else { return nil }
|
||
if meters < 1000 {
|
||
return "\(Int(meters.rounded()))米"
|
||
}
|
||
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))"
|
||
}
|
||
|
||
private func notifyStateChange() {
|
||
onStateChange?()
|
||
}
|
||
}
|
||
|
||
private extension String {
|
||
var nonEmpty: String? {
|
||
let text = trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return text.isEmpty ? nil : text
|
||
}
|
||
}
|