Implement report supplement evidence flow

This commit is contained in:
2026-07-08 17:23:28 +08:00
parent 290a01e699
commit fd5dc2bbe6
9 changed files with 1906 additions and 421 deletions

View File

@ -620,18 +620,22 @@ final class WildPhotographerReportSuccessViewModel {
/// ViewModel
final class WildPhotographerReportDetailViewModel {
let record: WildReportRecord
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.record = record
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 {
@ -639,19 +643,42 @@ final class WildPhotographerReportDetailViewModel {
return detail.contains("附近") ? detail : "\(detail)附近"
}
var displayStatusText: String { record.status.rawValue }
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] {
homeViewModel?.supplementaryEvidences(for: record.id) ?? []
if let detail {
return detail.supplementRecords
}
return homeViewModel?.supplementaryEvidences(for: record.id) ?? []
}
var reporterMediaItems: [WildReportReporterMediaItem] {
let images = (0..<record.imageCount).map { WildReportReporterMediaItem.image(index: $0) }
let videos = (0..<record.videoCount).map { WildReportReporterMediaItem.video(index: $0) }
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项"
@ -660,10 +687,35 @@ final class WildPhotographerReportDetailViewModel {
default: return "\(record.imageCount)\(record.videoCount)"
}
}
var progressSteps: [WildReportProgressStep] { Self.buildProgressSteps(for: record) }
var showHandlerInfo: Bool { record.status == .processed && record.handlerInfo != nil }
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),可在提交页查看上传内容。")
@ -697,16 +749,37 @@ final class WildPhotographerReportDetailViewModel {
makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .current),
makeStep(id: "4", title: "待结果反馈", date: nil, state: .pending, pendingText: "预计 \(expectedFeedback)")
]
case .processed:
case .processed, .rejected:
return [
makeStep(id: "1", title: "已提交", date: baseDate, state: .completed),
makeStep(id: "2", title: "已派工作人员前往", date: baseDate.addingTimeInterval(22 * 60), state: .completed),
makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .completed),
makeStep(id: "4", title: "已反馈核查结果", date: baseDate.addingTimeInterval(3 * 3_600), state: .completed)
makeStep(
id: "4",
title: record.status == .rejected ? "已驳回举报" : "已反馈核查结果",
date: baseDate.addingTimeInterval(3 * 3_600),
state: .completed
)
]
}
}
private static func buildProgressSteps(
from timeline: [WildReportDetailTimelineItem],
status: WildReportStatus
) -> [WildReportProgressStep] {
timeline.enumerated().map { index, item in
let isLast = index == timeline.count - 1
let lastIsOpen = isLast && (status == .pending || status == .processing)
return WildReportProgressStep(
id: "\(index)-\(item.time)",
title: item.content.isEmpty ? "处理进度" : item.content,
timeText: item.time,
state: lastIsOpen ? .current : .completed
)
}
}
private static func makeStep(
id: String,
title: String,
@ -883,16 +956,20 @@ final class WildReportSupplementEvidenceViewModel {
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(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) {
self.reportID = reportID
init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel) {
self.reportID = record.id
self.serverID = record.serverID
self.homeViewModel = homeViewModel
}
@ -902,24 +979,38 @@ final class WildReportSupplementEvidenceViewModel {
notifyStateChange()
}
///
func addImage() {
guard images.count < 9 else {
onShowMessage?("补充图片最多上传9张")
///
func addImages(_ attachments: [WildReportAttachment]) {
let remaining = max(0, 9 - evidenceCount)
guard remaining > 0 else {
onShowMessage?("补充证据最多上传9项")
return
}
images.append(WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)"))
images.append(contentsOf: attachments.prefix(remaining))
notifyStateChange()
}
///
func addImage() {
addImages([WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)")])
}
///
func addVideos(_ attachments: [WildReportAttachment]) {
let videoRemaining = max(0, 3 - videos.count)
let evidenceRemaining = max(0, 9 - evidenceCount)
let allowedCount = min(videoRemaining, evidenceRemaining)
guard allowedCount > 0 else {
onShowMessage?(videos.count >= 3 ? "补充视频最多上传3段" : "补充证据最多上传9项")
return
}
videos.append(contentsOf: attachments.prefix(allowedCount))
notifyStateChange()
}
///
func addVideo() {
guard videos.count < 3 else {
onShowMessage?("补充视频最多上传3段")
return
}
videos.append(WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)"))
notifyStateChange()
addVideos([WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)")])
}
///
@ -930,25 +1021,117 @@ final class WildReportSupplementEvidenceViewModel {
}
///
func submit() {
func submit(
api: any WildPhotographerReportServing,
uploader: any WildReportAttachmentUploading,
scenicId: Int
) async {
validationMessage = nil
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedText.isEmpty || !images.isEmpty || !videos.isEmpty else {
validationMessage = "请至少填写补充说明或上传一项证据。"
onShowMessage?(validationMessage ?? "")
guard validateBeforeSubmit(trimmedText: trimmedText, scenicId: scenicId) else { return }
guard !isSubmitting else { return }
isSubmitting = true
submitProgressText = evidenceCount > 0 ? "正在上传补充证据" : "正在提交补充证据"
notifyStateChange()
defer {
isSubmitting = false
submitProgressText = ""
notifyStateChange()
return
}
homeViewModel.appendSupplementaryEvidence(
reportID: reportID,
text: trimmedText,
imageCount: images.count,
videoCount: videos.count
)
didSubmit = true
do {
let evidenceRequests = try await uploadAttachments(
images + videos,
uploader: uploader,
scenicId: scenicId
)
submitProgressText = "正在提交补充证据"
notifyStateChange()
try await api.supplementReport(WildReportSupplementRequest(
id: serverID ?? 0,
desc: trimmedText.isEmpty ? nil : trimmedText,
evidences: evidenceRequests.isEmpty ? nil : evidenceRequests
))
homeViewModel.appendSupplementaryEvidence(
reportID: reportID,
text: trimmedText,
imageCount: images.count,
videoCount: videos.count
)
didSubmit = true
notifyStateChange()
onSubmitSuccess?()
} catch is CancellationError {
return
} catch {
setValidationMessage(error.localizedDescription)
}
}
private var evidenceCount: Int {
images.count + videos.count
}
private func validateBeforeSubmit(trimmedText: String, scenicId: Int) -> Bool {
guard let serverID, serverID >= 1 else {
setValidationMessage("当前举报暂未同步到服务器,无法补充证据")
return false
}
guard !trimmedText.isEmpty || evidenceCount > 0 else {
setValidationMessage("请至少填写补充说明或上传一项证据。")
return false
}
guard evidenceCount <= 9 else {
setValidationMessage("补充证据最多上传9项。")
return false
}
guard videos.count <= 3 else {
setValidationMessage("补充视频最多上传3段。")
return false
}
if evidenceCount > 0, scenicId <= 0 {
setValidationMessage("请先选择景区后再提交补充证据。")
return false
}
return true
}
private func setValidationMessage(_ message: String) {
validationMessage = message
notifyStateChange()
onSubmitSuccess?()
onShowMessage?(message)
}
private func uploadAttachments(
_ attachments: [WildReportAttachment],
uploader: any WildReportAttachmentUploading,
scenicId: Int
) async throws -> [WildReportEvidenceRequest] {
var requests: [WildReportEvidenceRequest] = []
for (index, attachment) in attachments.enumerated() {
let current = index + 1
submitProgressText = "正在上传补充证据 \(current)/\(attachments.count)"
notifyStateChange()
let data = try attachment.uploadData()
let url = try await uploader.uploadWildReportAttachment(
data: data,
fileName: attachment.fileName,
fileType: attachment.fileType,
scenicId: scenicId
) { [weak self] progress in
self?.submitProgressText = "正在上传补充证据 \(current)/\(attachments.count) · \(progress)%"
self?.notifyStateChange()
}
requests.append(WildReportEvidenceRequest(
fileURL: url,
fileType: attachment.fileType,
fileName: attachment.fileName
))
}
return requests
}
private func notifyStateChange() {