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

@ -12,8 +12,12 @@ protocol WildPhotographerReportServing {
func reportTypes() async throws -> WildReportTypeListResponse
///
func submitReport(_ request: WildReportSubmitRequest) async throws
///
func supplementReport(_ request: WildReportSupplementRequest) async throws
///
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse
///
func reportDetail(id: Int) async throws -> WildReportDetailData
}
/// API `photog/report`
@ -44,6 +48,17 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing {
)
}
///
func supplementReport(_ request: WildReportSupplementRequest) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/report/supplement",
body: request
)
)
}
///
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse {
try await client.send(
@ -54,6 +69,24 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing {
)
)
}
///
func reportDetail(id: Int) async throws -> WildReportDetailData {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/report/detail",
queryItems: [URLQueryItem(name: "id", value: String(id))]
)
)
}
}
///
struct WildReportSupplementRequest: Encodable, Equatable {
let id: Int
let desc: String?
let evidences: [WildReportEvidenceRequest]?
}
///

View File

@ -115,12 +115,14 @@ enum WildReportStatus: String, Hashable, CaseIterable {
case pending = "待处理"
case processing = "处理中"
case processed = "已处理"
case rejected = "驳回"
var displayColor: UIColor {
switch self {
case .pending: return AppColor.warning
case .processing: return AppColor.primary
case .processed: return AppColor.success
case .rejected: return AppColor.danger
}
}
@ -129,12 +131,13 @@ enum WildReportStatus: String, Hashable, CaseIterable {
case .pending: return AppColor.warningBackground
case .processing: return AppColor.infoBackground
case .processed: return AppColor.successBackground
case .rejected: return AppColor.dangerBackground
}
}
}
extension WildReportStatus {
/// 0 1 2
/// 0 1 2 3
init(handleStatus: Int, fallbackText: String = "") {
switch handleStatus {
case 0:
@ -143,12 +146,16 @@ extension WildReportStatus {
self = .processing
case 2:
self = .processed
case 3:
self = .rejected
default:
switch fallbackText {
case WildReportStatus.processing.rawValue:
self = .processing
case WildReportStatus.processed.rawValue:
self = .processed
case WildReportStatus.rejected.rawValue:
self = .rejected
default:
self = .pending
}
@ -329,6 +336,7 @@ struct WildReportListItem: Decodable, Equatable, Hashable {
}
return WildReportRecord(
serverID: id > 0 ? id : nil,
id: complaintNo,
title: title.isEmpty ? "\(reportTypeText)举报" : title,
reportType: WildReportType(value: reportType, label: reportTypeText.isEmpty ? "其他" : reportTypeText),
@ -345,6 +353,325 @@ struct WildReportListItem: Decodable, Equatable, Hashable {
}
}
///
struct WildReportEvidenceSummary: Decodable, Equatable, Hashable {
let imageCount: Int
let videoCount: Int
enum CodingKeys: String, CodingKey {
case imageCount = "image_count"
case videoCount = "video_count"
}
init(imageCount: Int = 0, videoCount: Int = 0) {
self.imageCount = imageCount
self.videoCount = videoCount
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
imageCount = try container.decodeIfPresent(Int.self, forKey: .imageCount) ?? 0
videoCount = try container.decodeIfPresent(Int.self, forKey: .videoCount) ?? 0
}
}
///
struct WildReportDetailEvidence: Decodable, Equatable, Hashable {
let fileType: Int
let fileTypeText: String
let fileURL: String
let fileName: String
enum CodingKeys: String, CodingKey {
case fileType = "file_type"
case fileTypeText = "file_type_text"
case fileURL = "file_url"
case fileName = "file_name"
}
init(fileType: Int = 0, fileTypeText: String = "", fileURL: String = "", fileName: String = "") {
self.fileType = fileType
self.fileTypeText = fileTypeText
self.fileURL = fileURL
self.fileName = fileName
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
fileType = try container.decodeIfPresent(Int.self, forKey: .fileType) ?? 0
fileTypeText = try container.decodeIfPresent(String.self, forKey: .fileTypeText) ?? ""
fileURL = try container.decodeIfPresent(String.self, forKey: .fileURL) ?? ""
fileName = try container.decodeIfPresent(String.self, forKey: .fileName) ?? ""
}
var isVideo: Bool { fileType == 2 }
var isImage: Bool { fileType == 1 || fileType == 5 }
}
///
struct WildReportDetailContent: Decodable, Equatable, Hashable {
let type: String
let id: Int
let desc: String
let time: String
let timeLabel: String
let evidences: [WildReportDetailEvidence]
enum CodingKeys: String, CodingKey {
case type
case id
case desc
case time
case timeLabel = "time_label"
case evidences
}
init(
type: String = "",
id: Int = 0,
desc: String = "",
time: String = "",
timeLabel: String = "",
evidences: [WildReportDetailEvidence] = []
) {
self.type = type
self.id = id
self.desc = desc
self.time = time
self.timeLabel = timeLabel
self.evidences = evidences
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
type = try container.decodeIfPresent(String.self, forKey: .type) ?? ""
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
desc = try container.decodeIfPresent(String.self, forKey: .desc) ?? ""
time = try container.decodeIfPresent(String.self, forKey: .time) ?? ""
timeLabel = try container.decodeIfPresent(String.self, forKey: .timeLabel) ?? ""
evidences = try container.decodeIfPresent([WildReportDetailEvidence].self, forKey: .evidences) ?? []
}
var imageCount: Int { evidences.filter(\.isImage).count }
var videoCount: Int { evidences.filter(\.isVideo).count }
}
///
struct WildReportDetailTimelineItem: Decodable, Equatable, Hashable {
let time: String
let content: String
enum CodingKeys: String, CodingKey {
case time
case content
}
init(time: String = "", content: String = "") {
self.time = time
self.content = content
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
time = try container.decodeIfPresent(String.self, forKey: .time) ?? ""
content = try container.decodeIfPresent(String.self, forKey: .content) ?? ""
}
}
///
struct WildReportDetailData: Decodable, Equatable, Hashable {
let id: Int
let complaintNo: String
let title: String
let reportType: Int
let reportTypeText: String
let desc: String
let supplements: [WildReportDetailContent]
let reportContents: [WildReportDetailContent]
let scenicId: Int
let scenicName: String
let storeUserId: Int
let contactPhone: String
let locationName: String
let locationAddress: String
let latitude: String
let longitude: String
let handleStatus: Int
let handleStatusText: String
let handleRemark: String
let evidenceSummary: WildReportEvidenceSummary?
let evidences: [WildReportDetailEvidence]
let paymentScreenshots: [WildReportDetailEvidence]
let timeline: [WildReportDetailTimelineItem]
let createdAt: String
enum CodingKeys: String, CodingKey {
case id
case complaintNo = "complaint_no"
case title
case reportType = "report_type"
case reportTypeText = "report_type_text"
case desc
case supplements
case reportContents = "report_contents"
case scenicId = "scenic_id"
case scenicName = "scenic_name"
case storeUserId = "store_user_id"
case contactPhone = "contact_phone"
case locationName = "location_name"
case locationAddress = "location_address"
case latitude
case longitude
case handleStatus = "handle_status"
case handleStatusText = "handle_status_text"
case handleRemark = "handle_remark"
case evidenceSummary = "evidence_summary"
case evidences
case paymentScreenshots = "payment_screenshots"
case timeline
case createdAt = "created_at"
}
init(
id: Int = 0,
complaintNo: String = "",
title: String = "",
reportType: Int = 0,
reportTypeText: String = "",
desc: String = "",
supplements: [WildReportDetailContent] = [],
reportContents: [WildReportDetailContent] = [],
scenicId: Int = 0,
scenicName: String = "",
storeUserId: Int = 0,
contactPhone: String = "",
locationName: String = "",
locationAddress: String = "",
latitude: String = "",
longitude: String = "",
handleStatus: Int = 0,
handleStatusText: String = "",
handleRemark: String = "",
evidenceSummary: WildReportEvidenceSummary? = nil,
evidences: [WildReportDetailEvidence] = [],
paymentScreenshots: [WildReportDetailEvidence] = [],
timeline: [WildReportDetailTimelineItem] = [],
createdAt: String = ""
) {
self.id = id
self.complaintNo = complaintNo
self.title = title
self.reportType = reportType
self.reportTypeText = reportTypeText
self.desc = desc
self.supplements = supplements
self.reportContents = reportContents
self.scenicId = scenicId
self.scenicName = scenicName
self.storeUserId = storeUserId
self.contactPhone = contactPhone
self.locationName = locationName
self.locationAddress = locationAddress
self.latitude = latitude
self.longitude = longitude
self.handleStatus = handleStatus
self.handleStatusText = handleStatusText
self.handleRemark = handleRemark
self.evidenceSummary = evidenceSummary
self.evidences = evidences
self.paymentScreenshots = paymentScreenshots
self.timeline = timeline
self.createdAt = createdAt
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
complaintNo = try container.decodeIfPresent(String.self, forKey: .complaintNo) ?? ""
title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
reportType = try container.decodeIfPresent(Int.self, forKey: .reportType) ?? 0
reportTypeText = try container.decodeIfPresent(String.self, forKey: .reportTypeText) ?? ""
desc = try container.decodeIfPresent(String.self, forKey: .desc) ?? ""
supplements = try container.decodeIfPresent([WildReportDetailContent].self, forKey: .supplements) ?? []
reportContents = try container.decodeIfPresent([WildReportDetailContent].self, forKey: .reportContents) ?? []
scenicId = try container.decodeIfPresent(Int.self, forKey: .scenicId) ?? 0
scenicName = try container.decodeIfPresent(String.self, forKey: .scenicName) ?? ""
storeUserId = try container.decodeIfPresent(Int.self, forKey: .storeUserId) ?? 0
contactPhone = try container.decodeIfPresent(String.self, forKey: .contactPhone) ?? ""
locationName = try container.decodeIfPresent(String.self, forKey: .locationName) ?? ""
locationAddress = try container.decodeIfPresent(String.self, forKey: .locationAddress) ?? ""
latitude = try container.decodeIfPresent(String.self, forKey: .latitude) ?? ""
longitude = try container.decodeIfPresent(String.self, forKey: .longitude) ?? ""
handleStatus = try container.decodeIfPresent(Int.self, forKey: .handleStatus) ?? 0
handleStatusText = try container.decodeIfPresent(String.self, forKey: .handleStatusText) ?? ""
handleRemark = try container.decodeIfPresent(String.self, forKey: .handleRemark) ?? ""
evidenceSummary = try container.decodeIfPresent(WildReportEvidenceSummary.self, forKey: .evidenceSummary)
evidences = try container.decodeIfPresent([WildReportDetailEvidence].self, forKey: .evidences) ?? []
paymentScreenshots = try container.decodeIfPresent([WildReportDetailEvidence].self, forKey: .paymentScreenshots) ?? []
timeline = try container.decodeIfPresent([WildReportDetailTimelineItem].self, forKey: .timeline) ?? []
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
}
var displayLocation: String {
let scenic = scenicName.trimmingCharacters(in: .whitespacesAndNewlines)
let detail = (locationAddress.nonEmpty ?? locationName).trimmingCharacters(in: .whitespacesAndNewlines)
if !scenic.isEmpty, !detail.isEmpty, scenic != detail {
return "\(scenic) · \(detail)"
}
return detail.nonEmpty ?? scenic
}
var displayStatusText: String {
handleStatusText.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? WildReportStatus(handleStatus: handleStatus).rawValue
}
var imageCount: Int {
evidenceSummary?.imageCount ?? evidences.filter(\.isImage).count
}
var videoCount: Int {
evidenceSummary?.videoCount ?? evidences.filter(\.isVideo).count
}
var supplementRecords: [WildReportSupplementaryEvidence] {
let contents = supplements.isEmpty
? reportContents.filter { $0.type == "supplement" }
: supplements
return contents.map { content in
WildReportSupplementaryEvidence(
id: content.id > 0 ? String(content.id) : content.time,
submitTime: content.time,
text: content.desc,
imageCount: content.imageCount,
videoCount: content.videoCount
)
}
}
/// UIKit
func toRecord(fallback: WildReportRecord? = nil) -> WildReportRecord {
WildReportRecord(
serverID: id > 0 ? id : fallback?.serverID,
id: complaintNo.nonEmpty ?? fallback?.id ?? "",
title: title.nonEmpty ?? fallback?.title ?? "摄影师举报",
reportType: WildReportType(
value: reportType > 0 ? reportType : fallback?.reportType.value ?? 4,
label: reportTypeText.nonEmpty ?? fallback?.reportType.label ?? "其他"
),
status: WildReportStatus(handleStatus: handleStatus, fallbackText: handleStatusText),
location: displayLocation.nonEmpty ?? fallback?.location ?? "",
submitTime: createdAt.nonEmpty ?? fallback?.submitTime ?? "",
description: desc.nonEmpty ?? fallback?.description ?? "",
wechatID: fallback?.wechatID ?? "",
phoneNumber: contactPhone.nonEmpty ?? fallback?.phoneNumber ?? "",
imageCount: imageCount,
videoCount: videoCount,
handlerInfo: fallback?.handlerInfo
)
}
}
///
struct WildReportSupplementaryEvidence: Hashable {
let id: String
@ -391,6 +718,7 @@ struct WildReportRiskPoint: Hashable {
///
struct WildReportRecord: Hashable {
var serverID: Int? = nil
let id: String
let title: String
let reportType: WildReportType
@ -470,13 +798,34 @@ struct WildReportProgressStep: Hashable {
///
enum WildReportReporterMediaItem: Hashable {
case image(index: Int)
case video(index: Int)
case image(index: Int, url: String?, fileName: String?, fileTypeText: String?)
case video(index: Int, url: String?, fileName: String?, fileTypeText: String?)
var id: String {
switch self {
case .image(let index): return "reporter-image-\(index)"
case .video(let index): return "reporter-video-\(index)"
case .image(let index, let url, _, _): return "reporter-image-\(index)-\(url ?? "")"
case .video(let index, let url, _, _): return "reporter-video-\(index)-\(url ?? "")"
}
}
var fileURL: String? {
switch self {
case .image(_, let url, _, _), .video(_, let url, _, _):
return url
}
}
var fileName: String? {
switch self {
case .image(_, _, let fileName, _), .video(_, _, let fileName, _):
return fileName
}
}
var fileTypeText: String? {
switch self {
case .image(_, _, _, let fileTypeText), .video(_, _, _, let fileTypeText):
return fileTypeText
}
}
}
@ -601,3 +950,10 @@ extension CLLocationCoordinate2D: @retroactive Equatable {
lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude
}
}
private extension String {
var nonEmpty: String? {
let text = trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}

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() {

View File

@ -9,7 +9,7 @@
- 网络入口:`NetworkServices.shared.wildPhotographerReportAPI`
- 后端成功码:`100000`
- 响应包裹:工程统一 `APIEnvelope<T>`
- 当前状态:“举报类型”、“提交举报”“我的举报列表”已接接口;详情、补充证据、风险地图仍使用 Mock 数据闭环。
- 当前状态:“举报类型”、“提交举报”“我的举报列表”、“举报详情”和“补充证据提交”已接接口;风险地图仍使用 Mock 数据闭环。
## 1. 获取举报类型
@ -269,7 +269,7 @@ struct WildReportListResponse: Decodable, Equatable {
- 切换筛选时重置为第一页,待处理传 `handle_status=0`,处理中传 `handle_status=1`,已处理传 `handle_status=2`
- 滚动到底部时,如果仍有更多数据,则请求下一页并追加。
- 首屏失败显示 toast 和空态;加载更多失败只 toast并保留已有列表。
- 列表接口暂不返回媒体数量,`WildReportRecord.imageCount/videoCount` 暂按 0 处理,后续详情接口补齐。
- 列表接口暂不返回媒体数量,`WildReportRecord.imageCount/videoCount` 暂按 0 处理,进入详情页后由详情接口补齐。
### 当前调用位置
@ -278,12 +278,100 @@ struct WildReportListResponse: Decodable, Equatable {
- `WildPhotographerReportListViewModel.loadMoreIfNeeded(api:scenicId:)`
- `WildPhotographerReportAPI.reportList(_:)`
## 4. 举报详情
### 用途
“举报详情”页面进入时调用。列表页传入 `WildReportRecord.serverID`,详情接口返回完整举报信息、现场证据、支付截图、补充内容和处理时间线。
### 请求
```http
GET /api/yf-handset-app/photog/report/detail?id=32
```
### 请求参数
| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `id` | integer | 必填,举报记录数值 ID来自列表接口返回的 `id` |
### 关键响应字段
- `complaint_no`:页面展示的举报编号。
- `report_type` / `report_type_text`:举报类型。
- `desc`:初始举报说明。
- `evidences`:初始现场证据,详情页“举报人材料”区域展示。
- `payment_screenshots`:支付截图,详情页单独展示。
- `supplements` / `report_contents`:补充内容记录,优先使用 `supplements`,为空时从 `report_contents` 过滤 `type=supplement`
- `location_name` / `location_address`:详情页位置展示。
- `handle_status` / `handle_status_text`处理状态iOS 支持 0 待处理、1 处理中、2 已处理、3 驳回。
- `handle_remark`:处理备注,有值时在详情信息卡展示。
- `timeline`:处理进度时间线;为空时使用本地状态推导兜底。
### iOS 模型
```swift
struct WildReportDetailData: Decodable, Equatable, Hashable
struct WildReportDetailEvidence: Decodable, Equatable, Hashable
struct WildReportDetailContent: Decodable, Equatable, Hashable
struct WildReportDetailTimelineItem: Decodable, Equatable, Hashable
```
### UI 行为
- 详情页先展示列表带入的基础记录,再异步请求详情接口刷新完整数据。
- 如果传入记录没有 `serverID`,说明来自本地提交成功页等临时记录,不请求详情接口。
- 详情接口失败时保留现有基础信息,并 toast 错误。
- 图片证据使用 Kingfisher 加载网络图;视频证据展示视频样式缩略块和文件信息。
### 当前调用位置
- `WildPhotographerReportDetailViewController.viewDidLoad()`
- `WildPhotographerReportDetailViewModel.loadDetailIfNeeded(api:)`
- `WildPhotographerReportAPI.reportDetail(id:)`
## 5. 补充证据提交
### 请求
```http
POST /api/yf-handset-app/photog/report/supplement
```
### 请求体
```json
{
"id": 32,
"desc": "补充说明",
"evidences": [
{
"file_url": "https://example.com/supplement.jpg",
"file_type": 1,
"file_name": "supplement.jpg"
}
]
}
```
### 字段说明
- `id`:服务端举报 ID必填需大于等于 1本地临时记录没有 `serverID` 时不允许补充提交。
- `desc`:补充说明,选填,最多 500 字;与 `evidences` 至少填写一项。
- `evidences`:补充附件,选填,最多 9 项;附件先通过 OSS `photog_report` 模块上传后再提交 URL。
- `file_type`沿用举报提交接口约定1 图片2 视频。
### 当前调用位置
- `WildReportSupplementEvidenceViewModel.submit(api:uploader:scenicId:)`
- `WildPhotographerReportAPI.supplementReport(_:)`
- `WildReportSupplementEvidenceViewController.submitTapped()`
## Mock 阶段业务
以下业务当前仍由 `WildPhotographerReportMockStore` 和 ViewModel 本地逻辑完成,暂未接真实接口:
- 举报详情
- 补充证据
- 附近风险地图
- 分享、媒体预览、地图导航演示提示