Implement report supplement evidence flow
This commit is contained in:
@ -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]?
|
||||
}
|
||||
|
||||
/// 提交举报接口请求体。
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 本地逻辑完成,暂未接真实接口:
|
||||
|
||||
- 举报详情
|
||||
- 补充证据
|
||||
- 附近风险地图
|
||||
- 分享、媒体预览、地图导航演示提示
|
||||
|
||||
|
||||
Reference in New Issue
Block a user