接入举报摄影师正式接口

This commit is contained in:
2026-07-08 16:38:15 +08:00
parent 4a78a0c21a
commit 290a01e699
12 changed files with 2586 additions and 218 deletions

View File

@ -7,6 +7,21 @@ import Foundation
import MapKit
import UIKit
/// 便 OSS
@MainActor
protocol WildReportAttachmentUploading {
/// OSS URL
func uploadWildReportAttachment(
data: Data,
fileName: String,
fileType: Int,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String
}
extension OSSUploadService: WildReportAttachmentUploading {}
/// ViewModel Mock
final class WildPhotographerReportHomeViewModel {
private(set) var pageState: WildReportPageState = .normal
@ -138,11 +153,15 @@ final class WildPhotographerReportSubmitViewModel {
private(set) var images: [WildReportAttachment]
private(set) var videos: [WildReportAttachment]
private(set) var paymentImages: [WildReportAttachment] = []
private(set) var selectedReportType: WildReportType = .suspectedWild
private(set) var reportTypes: [WildReportType] = WildReportType.fallbackOptions
private(set) var selectedReportType: WildReportType = .defaultSelected
private(set) var description = ""
private(set) var contact = ""
private(set) var reportLocation = ""
private(set) var isLoadingReportTypes = false
private(set) var isUpdatingLocation = false
private(set) var isSubmitting = false
private(set) var submitProgressText = ""
private(set) var locationConfirmed = false
private(set) var submitResult: WildReportSubmitResult?
private(set) var validationMessage: String?
@ -154,6 +173,7 @@ final class WildPhotographerReportSubmitViewModel {
private let homeViewModel: WildPhotographerReportHomeViewModel
private let locationProvider: any LocationProviding
private var hasRequestedInitialLocation = false
private var locationSnapshot: HomeLocationSnapshot?
var locationTitleText: String {
if reportLocation.isEmpty || isUpdatingLocation {
@ -178,8 +198,8 @@ final class WildPhotographerReportSubmitViewModel {
) {
self.homeViewModel = homeViewModel
self.locationProvider = locationProvider
self.images = WildPhotographerReportMockStore.initialImages
self.videos = WildPhotographerReportMockStore.initialVideos
self.images = []
self.videos = []
}
///
@ -189,6 +209,38 @@ final class WildPhotographerReportSubmitViewModel {
refreshCurrentLocation()
}
/// 退
func loadReportTypes(api: any WildPhotographerReportServing) async {
guard !isLoadingReportTypes else { return }
isLoadingReportTypes = true
notifyStateChange()
defer {
isLoadingReportTypes = false
notifyStateChange()
}
do {
let response = try await api.reportTypes()
let loadedTypes = uniqueReportTypes(response.list)
reportTypes = loadedTypes.isEmpty ? WildReportType.fallbackOptions : loadedTypes
if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) {
selectedReportType = updatedType
} else {
selectedReportType = reportTypes.first ?? .defaultSelected
}
} catch is CancellationError {
return
} catch {
reportTypes = WildReportType.fallbackOptions
if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) {
selectedReportType = updatedType
} else {
selectedReportType = .defaultSelected
}
onShowMessage?("举报类型获取失败,已使用默认类型")
}
}
///
func selectReportType(_ type: WildReportType) {
selectedReportType = type
@ -207,26 +259,53 @@ final class WildPhotographerReportSubmitViewModel {
notifyStateChange()
}
///
func addImages(_ attachments: [WildReportAttachment]) {
let remaining = max(0, 9 - evidenceCount)
guard remaining > 0 else {
onShowMessage?("现场证据最多上传9项")
return
}
images.append(contentsOf: attachments.prefix(remaining))
notifyStateChange()
}
///
func addImage() {
images.append(WildReportAttachment(kind: .image, title: "现场图片\(images.count + 1)"))
addImages([WildReportAttachment(kind: .image, title: "现场图片\(images.count + 1)")])
}
///
func addVideos(_ attachments: [WildReportAttachment]) {
let videoRemaining = max(0, 3 - videos.count)
let evidenceRemaining = max(0, 9 - evidenceCount)
let allowedCount = min(videoRemaining, evidenceRemaining)
guard allowedCount > 0 else {
onShowMessage?(videos.count >= 3 ? "现场视频最多上传3段" : "现场证据最多上传9项")
return
}
videos.append(contentsOf: attachments.prefix(allowedCount))
notifyStateChange()
}
///
func addVideo() {
videos.append(WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)"))
addVideos([WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)")])
}
///
func addPaymentImages(_ attachments: [WildReportAttachment]) {
guard paymentImages.count < 3 else {
onShowMessage?("支付截图最多上传3张")
return
}
paymentImages.append(contentsOf: attachments.prefix(3 - paymentImages.count))
notifyStateChange()
}
///
func addPaymentImage() {
guard paymentImages.count < 3 else {
onShowMessage?("支付截图最多上传3张")
return
}
paymentImages.append(WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)"))
notifyStateChange()
addPaymentImages([WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)")])
}
///
@ -248,12 +327,13 @@ final class WildPhotographerReportSubmitViewModel {
Task {
do {
let snapshot = try await locationProvider.requestSnapshot()
let address = snapshot.address.isEmpty
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
: snapshot.address
applyResolvedLocation(address)
applyResolvedLocation(snapshot)
} catch {
applyResolvedLocation(WildPhotographerReportMockStore.fallbackLocation)
applyResolvedLocation(HomeLocationSnapshot(
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
address: WildPhotographerReportMockStore.fallbackLocation
))
}
}
}
@ -264,25 +344,78 @@ final class WildPhotographerReportSubmitViewModel {
notifyStateChange()
}
///
///
func submitReport(
api: any WildPhotographerReportServing,
uploader: any WildReportAttachmentUploading,
scenicId: Int,
scenicName: String
) async {
validationMessage = nil
guard validateBeforeSubmit(scenicId: scenicId) else { return }
guard !isSubmitting else { return }
isSubmitting = true
submitProgressText = "正在上传证据"
notifyStateChange()
defer {
isSubmitting = false
submitProgressText = ""
notifyStateChange()
}
do {
let evidenceRequests = try await uploadAttachments(
images + videos,
uploader: uploader,
scenicId: scenicId,
progressPrefix: "正在上传现场证据"
)
let paymentRequests = try await uploadAttachments(
paymentImages,
uploader: uploader,
scenicId: scenicId,
progressPrefix: "正在上传支付截图",
completedOffset: evidenceRequests.count,
totalOverride: evidenceRequests.count + paymentImages.count
)
submitProgressText = "正在提交举报"
notifyStateChange()
try await api.submitReport(buildSubmitRequest(
scenicId: scenicId,
scenicName: scenicName,
evidences: evidenceRequests,
paymentScreenshots: paymentRequests
))
let record = homeViewModel.submitReport(
reportType: selectedReportType,
description: description,
location: reportLocation,
contact: contact.isEmpty ? nil : contact,
imageCount: images.count,
videoCount: videos.count
)
let result = WildReportSubmitResult(
record: record,
imageCount: images.count,
videoCount: videos.count
)
submitResult = result
notifyStateChange()
onSubmitSuccess?(result)
} catch is CancellationError {
return
} catch {
setValidationMessage(error.localizedDescription)
}
}
/// Mock
func submitReport() {
validationMessage = nil
guard !images.isEmpty || !videos.isEmpty else {
setValidationMessage("请至少上传一项现场证据。")
return
}
guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
setValidationMessage("请填写举报说明。")
return
}
guard !isUpdatingLocation else {
setValidationMessage("正在获取定位,请稍后再提交。")
return
}
guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else {
setValidationMessage("请先更新举报位置后再提交举报。")
return
}
guard validateBeforeSubmit(scenicId: 1) else { return }
let record = homeViewModel.submitReport(
reportType: selectedReportType,
@ -304,15 +437,19 @@ final class WildPhotographerReportSubmitViewModel {
///
func resetForm() {
images = WildPhotographerReportMockStore.initialImages
videos = WildPhotographerReportMockStore.initialVideos
images = []
videos = []
paymentImages = []
selectedReportType = .suspectedWild
reportTypes = WildReportType.fallbackOptions
selectedReportType = .defaultSelected
description = ""
contact = ""
reportLocation = ""
locationConfirmed = false
isUpdatingLocation = false
isSubmitting = false
submitProgressText = ""
locationSnapshot = nil
hasRequestedInitialLocation = false
submitResult = nil
validationMessage = nil
@ -320,7 +457,23 @@ final class WildPhotographerReportSubmitViewModel {
}
func applyResolvedLocation(_ location: String) {
reportLocation = location
applyResolvedLocation(HomeLocationSnapshot(
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
address: location
))
}
func applyResolvedLocation(_ snapshot: HomeLocationSnapshot) {
let address = snapshot.address.isEmpty
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
: snapshot.address
locationSnapshot = HomeLocationSnapshot(
latitude: snapshot.latitude,
longitude: snapshot.longitude,
address: address
)
reportLocation = address
locationConfirmed = true
isUpdatingLocation = false
notifyStateChange()
@ -335,6 +488,110 @@ final class WildPhotographerReportSubmitViewModel {
private func notifyStateChange() {
onStateChange?()
}
private func uniqueReportTypes(_ types: [WildReportType]) -> [WildReportType] {
var seen: Set<Int> = []
return types.filter { seen.insert($0.value).inserted }
}
private var evidenceCount: Int {
images.count + videos.count
}
private func validateBeforeSubmit(scenicId: Int) -> Bool {
guard scenicId > 0 else {
setValidationMessage("请先选择景区后再提交举报。")
return false
}
guard !images.isEmpty || !videos.isEmpty else {
setValidationMessage("请至少上传一项现场证据。")
return false
}
guard evidenceCount <= 9 else {
setValidationMessage("现场证据最多上传9项。")
return false
}
guard paymentImages.count <= 3 else {
setValidationMessage("支付截图最多上传3张。")
return false
}
guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
setValidationMessage("请填写举报说明。")
return false
}
guard !isUpdatingLocation else {
setValidationMessage("正在获取定位,请稍后再提交。")
return false
}
guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else {
setValidationMessage("请先更新举报位置后再提交举报。")
return false
}
return true
}
private func uploadAttachments(
_ attachments: [WildReportAttachment],
uploader: any WildReportAttachmentUploading,
scenicId: Int,
progressPrefix: String,
completedOffset: Int = 0,
totalOverride: Int? = nil
) async throws -> [WildReportEvidenceRequest] {
var requests: [WildReportEvidenceRequest] = []
let total = totalOverride ?? attachments.count
for (index, attachment) in attachments.enumerated() {
let current = completedOffset + index + 1
submitProgressText = "\(progressPrefix) \(current)/\(max(total, current))"
notifyStateChange()
let data = try attachment.uploadData()
let url = try await uploader.uploadWildReportAttachment(
data: data,
fileName: attachment.fileName,
fileType: attachment.fileType,
scenicId: scenicId
) { [weak self] progress in
self?.submitProgressText = "\(progressPrefix) \(current)/\(max(total, current)) · \(progress)%"
self?.notifyStateChange()
}
requests.append(WildReportEvidenceRequest(
fileURL: url,
fileType: attachment.fileType,
fileName: attachment.fileName
))
}
return requests
}
private func buildSubmitRequest(
scenicId: Int,
scenicName: String,
evidences: [WildReportEvidenceRequest],
paymentScreenshots: [WildReportEvidenceRequest]
) -> WildReportSubmitRequest {
let locationParts = WildReportLocationParser.split(reportLocation)
let trimmedScenicName = scenicName.trimmingCharacters(in: .whitespacesAndNewlines)
let locationName = !trimmedScenicName.isEmpty ? trimmedScenicName : locationParts.scenic
let snapshot = locationSnapshot ?? HomeLocationSnapshot(
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
address: reportLocation
)
let trimmedContact = contact.trimmingCharacters(in: .whitespacesAndNewlines)
return WildReportSubmitRequest(
scenicId: scenicId,
reportType: selectedReportType.value,
desc: description.trimmingCharacters(in: .whitespacesAndNewlines),
storeUserId: nil,
contactPhone: trimmedContact.isEmpty ? nil : trimmedContact,
locationName: locationName.isEmpty ? nil : locationName,
locationAddress: locationParts.detail.isEmpty ? reportLocation : locationParts.detail,
lat: snapshot.latitude,
lng: snapshot.longitude,
evidences: evidences,
paymentScreenshots: paymentScreenshots
)
}
}
/// ViewModel
@ -480,8 +737,17 @@ final class WildPhotographerReportDetailViewModel {
/// 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)?
@ -492,23 +758,63 @@ final class WildPhotographerReportListViewModel {
}
var filteredReports: [WildReportRecord] {
let reports = homeViewModel.reports
switch selectedFilter {
case .all: return reports
case .pending: return reports.filter { $0.status == .pending }
case .processing: return reports.filter { $0.status == .processing }
case .processed: return reports.filter { $0.status == .processed }
}
reports
}
var footerTip: String { "温馨提示:为保障处理效率,请如实举报并提供有效证据" }
///
var footerDisplayText: String {
if isLoadingMore { return "正在加载更多..." }
if isLoading && reports.isEmpty { return "正在加载举报记录..." }
if !hasMore && !reports.isEmpty { return footerTip }
return footerTip
}
///
func loadInitial(api: any WildPhotographerReportServing, scenicId: Int?) async {
await reload(api: api, scenicId: scenicId)
}
///
func selectFilter(_ filter: WildReportListFilter, api: any WildPhotographerReportServing, scenicId: Int?) async {
guard selectedFilter != filter else { return }
selectedFilter = filter
await reload(api: api, scenicId: scenicId)
}
/// Mock 使
func selectFilter(_ filter: WildReportListFilter) {
selectedFilter = filter
reports = filterLocalReports(homeViewModel.reports)
notifyStateChange()
}
///
func loadMoreIfNeeded(api: any WildPhotographerReportServing, scenicId: Int?) async {
guard hasMore, !isLoading, !isLoadingMore else { return }
let nextPage = page + 1
isLoadingMore = true
errorMessage = nil
notifyStateChange()
defer {
isLoadingMore = false
notifyStateChange()
}
do {
let response = try await api.reportList(makeRequest(page: nextPage, scenicId: scenicId))
let newRecords = response.list.map { $0.toRecord() }
reports.append(contentsOf: newRecords)
page = response.page
total = response.total
hasMore = reports.count < response.total
} catch is CancellationError {
return
} catch {
errorMessage = error.localizedDescription
onShowMessage?(error.localizedDescription)
}
}
///
func shareReport(_ record: WildReportRecord) {
shareMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。"
@ -516,6 +822,55 @@ final class WildPhotographerReportListViewModel {
notifyStateChange()
}
private func reload(api: any WildPhotographerReportServing, scenicId: Int?) async {
let hadReportsBeforeReload = !reports.isEmpty
isLoading = true
isLoadingMore = false
errorMessage = nil
page = 1
hasMore = true
notifyStateChange()
defer {
isLoading = false
notifyStateChange()
}
do {
let response = try await api.reportList(makeRequest(page: 1, scenicId: scenicId))
reports = response.list.map { $0.toRecord() }
page = response.page
total = response.total
hasMore = reports.count < response.total
} catch is CancellationError {
return
} catch {
errorMessage = error.localizedDescription
hasMore = false
if !hadReportsBeforeReload {
reports = []
total = 0
}
onShowMessage?(error.localizedDescription)
}
}
private func makeRequest(page: Int, scenicId: Int?) -> WildReportListRequest {
WildReportListRequest(
scenicId: scenicId,
handleStatus: selectedFilter.handleStatusValue,
page: page,
pageSize: Self.pageSize
)
}
private func filterLocalReports(_ localReports: [WildReportRecord]) -> [WildReportRecord] {
switch selectedFilter {
case .all: return localReports
case .pending: return localReports.filter { $0.status == .pending }
case .processing: return localReports.filter { $0.status == .processing }
case .processed: return localReports.filter { $0.status == .processed }
}
}
private func notifyStateChange() {
onStateChange?()
}