Files
suixinkan_uikit/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift
汉秋 6336feff27 完善景区排队设置页与全局按钮配置迁移。
重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:21:53 +08:00

1535 lines
57 KiB
Swift
Raw Permalink 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 {}
///
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)"
}
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))
}
///
func updateContact(_ value: String) {
contact = String(value.prefix(80))
}
///
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
let reportAddress = Self.simplifiedReportLocation(address)
locationSnapshot = HomeLocationSnapshot(
latitude: snapshot.latitude,
longitude: snapshot.longitude,
address: reportAddress
)
reportLocation = reportAddress
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 static func simplifiedReportLocation(_ address: String) -> String {
let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return trimmed }
let detail = WildReportLocationParser.split(trimmed).detail
let simplified = removeAdministrativePrefix(from: detail)
return simplified.isEmpty ? trimmed : simplified
}
private static func removeAdministrativePrefix(from address: String) -> String {
var text = address
removeLeadingRegion(from: &text, matches: [
"北京市", "天津市", "上海市", "重庆市",
"内蒙古自治区", "广西壮族自治区", "西藏自治区", "宁夏回族自治区", "新疆维吾尔自治区"
])
removeLeadingRegion(from: &text, suffixes: [""], maxLength: 8)
removeLeadingRegion(from: &text, suffixes: ["自治州", "地区", "", ""], maxLength: 12)
removeLeadingRegion(from: &text, suffixes: ["自治县", "新区", "", "", "", ""], maxLength: 12)
return text.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func removeLeadingRegion(from text: inout String, matches: [String]) {
guard let match = matches.first(where: { text.hasPrefix($0) }) else { return }
text.removeFirst(match.count)
}
private static func removeLeadingRegion(from text: inout String, suffixes: [String], maxLength: Int) {
guard let match = suffixes.compactMap({ suffix -> String? in
guard let range = text.range(of: suffix) else { return nil }
let prefix = String(text[..<range.upperBound])
return prefix.count <= maxLength ? prefix : nil
}).min(by: { $0.count < $1.count }) else { return }
text.removeFirst(match.count)
}
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.session.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(api: any WildPhotographerReportServing) async -> WeChatMiniProgramSharePayload? {
guard let marker = selectedMarker,
marker.kind == .wildPhotographer || marker.kind == .processingClue,
let reportID = reportID(fromMarkerID: marker.id)
else {
errorMessage = "请选择可分享的举报线索"
notifyStateChange()
return nil
}
do {
let shareData = try await api.reportShare(id: String(reportID))
return shareData.payload
} catch is CancellationError {
return nil
} catch {
errorMessage = error.localizedDescription
notifyStateChange()
return nil
}
}
///
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.session.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.session.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.session.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 reportID(fromMarkerID markerID: String) -> Int? {
let parts = markerID.split(separator: "-", maxSplits: 1).map(String.init)
guard parts.count == 2, let id = Int(parts[1]), id >= 1 else { return nil }
return id
}
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
}
}