Files
suixinkan_uikit/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift
lujiuyinandCursor 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
}
}