Implement report supplement evidence flow
This commit is contained in:
@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user