Implement report supplement evidence flow
This commit is contained in:
@ -12,8 +12,12 @@ protocol WildPhotographerReportServing {
|
|||||||
func reportTypes() async throws -> WildReportTypeListResponse
|
func reportTypes() async throws -> WildReportTypeListResponse
|
||||||
/// 提交摄影师举报。
|
/// 提交摄影师举报。
|
||||||
func submitReport(_ request: WildReportSubmitRequest) async throws
|
func submitReport(_ request: WildReportSubmitRequest) async throws
|
||||||
|
/// 为已有举报补充证据。
|
||||||
|
func supplementReport(_ request: WildReportSupplementRequest) async throws
|
||||||
/// 拉取我的举报列表。
|
/// 拉取我的举报列表。
|
||||||
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse
|
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse
|
||||||
|
/// 拉取单条举报详情。
|
||||||
|
func reportDetail(id: Int) async throws -> WildReportDetailData
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 举报摄影师 API,负责和 `photog/report` 相关接口通讯。
|
/// 举报摄影师 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 {
|
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse {
|
||||||
try await client.send(
|
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 pending = "待处理"
|
||||||
case processing = "处理中"
|
case processing = "处理中"
|
||||||
case processed = "已处理"
|
case processed = "已处理"
|
||||||
|
case rejected = "驳回"
|
||||||
|
|
||||||
var displayColor: UIColor {
|
var displayColor: UIColor {
|
||||||
switch self {
|
switch self {
|
||||||
case .pending: return AppColor.warning
|
case .pending: return AppColor.warning
|
||||||
case .processing: return AppColor.primary
|
case .processing: return AppColor.primary
|
||||||
case .processed: return AppColor.success
|
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 .pending: return AppColor.warningBackground
|
||||||
case .processing: return AppColor.infoBackground
|
case .processing: return AppColor.infoBackground
|
||||||
case .processed: return AppColor.successBackground
|
case .processed: return AppColor.successBackground
|
||||||
|
case .rejected: return AppColor.dangerBackground
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension WildReportStatus {
|
extension WildReportStatus {
|
||||||
/// 后端举报处理状态码:0 待处理、1 处理中、2 已处理。
|
/// 后端举报处理状态码:0 待处理、1 处理中、2 已处理、3 驳回。
|
||||||
init(handleStatus: Int, fallbackText: String = "") {
|
init(handleStatus: Int, fallbackText: String = "") {
|
||||||
switch handleStatus {
|
switch handleStatus {
|
||||||
case 0:
|
case 0:
|
||||||
@ -143,12 +146,16 @@ extension WildReportStatus {
|
|||||||
self = .processing
|
self = .processing
|
||||||
case 2:
|
case 2:
|
||||||
self = .processed
|
self = .processed
|
||||||
|
case 3:
|
||||||
|
self = .rejected
|
||||||
default:
|
default:
|
||||||
switch fallbackText {
|
switch fallbackText {
|
||||||
case WildReportStatus.processing.rawValue:
|
case WildReportStatus.processing.rawValue:
|
||||||
self = .processing
|
self = .processing
|
||||||
case WildReportStatus.processed.rawValue:
|
case WildReportStatus.processed.rawValue:
|
||||||
self = .processed
|
self = .processed
|
||||||
|
case WildReportStatus.rejected.rawValue:
|
||||||
|
self = .rejected
|
||||||
default:
|
default:
|
||||||
self = .pending
|
self = .pending
|
||||||
}
|
}
|
||||||
@ -329,6 +336,7 @@ struct WildReportListItem: Decodable, Equatable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return WildReportRecord(
|
return WildReportRecord(
|
||||||
|
serverID: id > 0 ? id : nil,
|
||||||
id: complaintNo,
|
id: complaintNo,
|
||||||
title: title.isEmpty ? "\(reportTypeText)举报" : title,
|
title: title.isEmpty ? "\(reportTypeText)举报" : title,
|
||||||
reportType: WildReportType(value: reportType, label: reportTypeText.isEmpty ? "其他" : reportTypeText),
|
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 {
|
struct WildReportSupplementaryEvidence: Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
@ -391,6 +718,7 @@ struct WildReportRiskPoint: Hashable {
|
|||||||
|
|
||||||
/// 举报记录实体。
|
/// 举报记录实体。
|
||||||
struct WildReportRecord: Hashable {
|
struct WildReportRecord: Hashable {
|
||||||
|
var serverID: Int? = nil
|
||||||
let id: String
|
let id: String
|
||||||
let title: String
|
let title: String
|
||||||
let reportType: WildReportType
|
let reportType: WildReportType
|
||||||
@ -470,13 +798,34 @@ struct WildReportProgressStep: Hashable {
|
|||||||
|
|
||||||
/// 举报人材料预览项。
|
/// 举报人材料预览项。
|
||||||
enum WildReportReporterMediaItem: Hashable {
|
enum WildReportReporterMediaItem: Hashable {
|
||||||
case image(index: Int)
|
case image(index: Int, url: String?, fileName: String?, fileTypeText: String?)
|
||||||
case video(index: Int)
|
case video(index: Int, url: String?, fileName: String?, fileTypeText: String?)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .image(let index): return "reporter-image-\(index)"
|
case .image(let index, let url, _, _): return "reporter-image-\(index)-\(url ?? "")"
|
||||||
case .video(let index): return "reporter-video-\(index)"
|
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
|
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。
|
/// 举报详情页 ViewModel。
|
||||||
final class WildPhotographerReportDetailViewModel {
|
final class WildPhotographerReportDetailViewModel {
|
||||||
let record: WildReportRecord
|
private let initialRecord: WildReportRecord
|
||||||
private let homeViewModel: WildPhotographerReportHomeViewModel?
|
private let homeViewModel: WildPhotographerReportHomeViewModel?
|
||||||
|
|
||||||
|
private(set) var detail: WildReportDetailData?
|
||||||
|
private(set) var isLoadingDetail = false
|
||||||
|
private(set) var detailErrorMessage: String?
|
||||||
private(set) var actionMessage: String?
|
private(set) var actionMessage: String?
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
|
||||||
init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel? = nil) {
|
init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel? = nil) {
|
||||||
self.record = record
|
self.initialRecord = record
|
||||||
self.homeViewModel = homeViewModel
|
self.homeViewModel = homeViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var record: WildReportRecord { detail?.toRecord(fallback: initialRecord) ?? initialRecord }
|
||||||
var heroTitle: String { record.title.isEmpty ? "摄影师举报" : record.title }
|
var heroTitle: String { record.title.isEmpty ? "摄影师举报" : record.title }
|
||||||
var scenicAreaName: String { WildReportLocationParser.split(record.location).scenic }
|
var scenicAreaName: String { WildReportLocationParser.split(record.location).scenic }
|
||||||
var detailAddress: String {
|
var detailAddress: String {
|
||||||
@ -639,19 +643,42 @@ final class WildPhotographerReportDetailViewModel {
|
|||||||
return detail.contains("附近") ? detail : "\(detail)附近"
|
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 descriptionText: String { record.description }
|
||||||
var contactText: String { record.contactDisplayText }
|
var contactText: String { record.contactDisplayText }
|
||||||
|
var handleRemarkText: String { detail?.handleRemark.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" }
|
||||||
var supplementaryEvidences: [WildReportSupplementaryEvidence] {
|
var supplementaryEvidences: [WildReportSupplementaryEvidence] {
|
||||||
homeViewModel?.supplementaryEvidences(for: record.id) ?? []
|
if let detail {
|
||||||
|
return detail.supplementRecords
|
||||||
|
}
|
||||||
|
return homeViewModel?.supplementaryEvidences(for: record.id) ?? []
|
||||||
}
|
}
|
||||||
var reporterMediaItems: [WildReportReporterMediaItem] {
|
var reporterMediaItems: [WildReportReporterMediaItem] {
|
||||||
let images = (0..<record.imageCount).map { WildReportReporterMediaItem.image(index: $0) }
|
if let detail {
|
||||||
let videos = (0..<record.videoCount).map { WildReportReporterMediaItem.video(index: $0) }
|
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
|
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 imageCountText: String { "\(record.imageCount)张" }
|
||||||
var videoCountText: String { "\(record.videoCount)段" }
|
var videoCountText: String { "\(record.videoCount)段" }
|
||||||
|
var paymentScreenshotSummary: String { "\(paymentScreenshotItems.count)张" }
|
||||||
var reporterMediaSummary: String {
|
var reporterMediaSummary: String {
|
||||||
switch (record.imageCount, record.videoCount) {
|
switch (record.imageCount, record.videoCount) {
|
||||||
case (0, 0): return "0项"
|
case (0, 0): return "0项"
|
||||||
@ -660,10 +687,35 @@ final class WildPhotographerReportDetailViewModel {
|
|||||||
default: return "\(record.imageCount)张 \(record.videoCount)段"
|
default: return "\(record.imageCount)张 \(record.videoCount)段"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var progressSteps: [WildReportProgressStep] { Self.buildProgressSteps(for: record) }
|
var progressSteps: [WildReportProgressStep] {
|
||||||
var showHandlerInfo: Bool { record.status == .processed && record.handlerInfo != nil }
|
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 }
|
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) {
|
func showMediaPreview(kind: String) {
|
||||||
showActionMessage("演示环境暂不支持预览\(kind),可在提交页查看上传内容。")
|
showActionMessage("演示环境暂不支持预览\(kind),可在提交页查看上传内容。")
|
||||||
@ -697,16 +749,37 @@ final class WildPhotographerReportDetailViewModel {
|
|||||||
makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .current),
|
makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .current),
|
||||||
makeStep(id: "4", title: "待结果反馈", date: nil, state: .pending, pendingText: "预计 \(expectedFeedback) 前")
|
makeStep(id: "4", title: "待结果反馈", date: nil, state: .pending, pendingText: "预计 \(expectedFeedback) 前")
|
||||||
]
|
]
|
||||||
case .processed:
|
case .processed, .rejected:
|
||||||
return [
|
return [
|
||||||
makeStep(id: "1", title: "已提交", date: baseDate, state: .completed),
|
makeStep(id: "1", title: "已提交", date: baseDate, state: .completed),
|
||||||
makeStep(id: "2", title: "已派工作人员前往", date: baseDate.addingTimeInterval(22 * 60), 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: "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(
|
private static func makeStep(
|
||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
@ -883,16 +956,20 @@ final class WildReportSupplementEvidenceViewModel {
|
|||||||
private(set) var videos: [WildReportAttachment] = []
|
private(set) var videos: [WildReportAttachment] = []
|
||||||
private(set) var validationMessage: String?
|
private(set) var validationMessage: String?
|
||||||
private(set) var didSubmit = false
|
private(set) var didSubmit = false
|
||||||
|
private(set) var isSubmitting = false
|
||||||
|
private(set) var submitProgressText = ""
|
||||||
|
|
||||||
let reportID: String
|
let reportID: String
|
||||||
|
let serverID: Int?
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
var onShowMessage: ((String) -> Void)?
|
||||||
var onSubmitSuccess: (() -> Void)?
|
var onSubmitSuccess: (() -> Void)?
|
||||||
|
|
||||||
private let homeViewModel: WildPhotographerReportHomeViewModel
|
private let homeViewModel: WildPhotographerReportHomeViewModel
|
||||||
|
|
||||||
init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) {
|
init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel) {
|
||||||
self.reportID = reportID
|
self.reportID = record.id
|
||||||
|
self.serverID = record.serverID
|
||||||
self.homeViewModel = homeViewModel
|
self.homeViewModel = homeViewModel
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -902,24 +979,38 @@ final class WildReportSupplementEvidenceViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加演示用补充图片。
|
/// 添加本地补充图片。
|
||||||
func addImage() {
|
func addImages(_ attachments: [WildReportAttachment]) {
|
||||||
guard images.count < 9 else {
|
let remaining = max(0, 9 - evidenceCount)
|
||||||
onShowMessage?("补充图片最多上传9张")
|
guard remaining > 0 else {
|
||||||
|
onShowMessage?("补充证据最多上传9项")
|
||||||
return
|
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()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加演示用补充视频。
|
/// 添加演示用补充视频。
|
||||||
func addVideo() {
|
func addVideo() {
|
||||||
guard videos.count < 3 else {
|
addVideos([WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)")])
|
||||||
onShowMessage?("补充视频最多上传3段")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
videos.append(WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)"))
|
|
||||||
notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除指定附件。
|
/// 删除指定附件。
|
||||||
@ -930,16 +1021,40 @@ final class WildReportSupplementEvidenceViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 校验并提交补充证据。
|
/// 校验并提交补充证据。
|
||||||
func submit() {
|
func submit(
|
||||||
|
api: any WildPhotographerReportServing,
|
||||||
|
uploader: any WildReportAttachmentUploading,
|
||||||
|
scenicId: Int
|
||||||
|
) async {
|
||||||
validationMessage = nil
|
validationMessage = nil
|
||||||
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmedText.isEmpty || !images.isEmpty || !videos.isEmpty else {
|
guard validateBeforeSubmit(trimmedText: trimmedText, scenicId: scenicId) else { return }
|
||||||
validationMessage = "请至少填写补充说明或上传一项证据。"
|
guard !isSubmitting else { return }
|
||||||
onShowMessage?(validationMessage ?? "")
|
|
||||||
|
isSubmitting = true
|
||||||
|
submitProgressText = evidenceCount > 0 ? "正在上传补充证据" : "正在提交补充证据"
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isSubmitting = false
|
||||||
|
submitProgressText = ""
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
homeViewModel.appendSupplementaryEvidence(
|
||||||
reportID: reportID,
|
reportID: reportID,
|
||||||
text: trimmedText,
|
text: trimmedText,
|
||||||
@ -949,6 +1064,74 @@ final class WildReportSupplementEvidenceViewModel {
|
|||||||
didSubmit = true
|
didSubmit = true
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
onSubmitSuccess?()
|
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()
|
||||||
|
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() {
|
private func notifyStateChange() {
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
- 网络入口:`NetworkServices.shared.wildPhotographerReportAPI`
|
- 网络入口:`NetworkServices.shared.wildPhotographerReportAPI`
|
||||||
- 后端成功码:`100000`
|
- 后端成功码:`100000`
|
||||||
- 响应包裹:工程统一 `APIEnvelope<T>`
|
- 响应包裹:工程统一 `APIEnvelope<T>`
|
||||||
- 当前状态:“举报类型”、“提交举报”和“我的举报列表”已接接口;详情、补充证据、风险地图仍使用 Mock 数据闭环。
|
- 当前状态:“举报类型”、“提交举报”、“我的举报列表”、“举报详情”和“补充证据提交”已接接口;风险地图仍使用 Mock 数据闭环。
|
||||||
|
|
||||||
## 1. 获取举报类型
|
## 1. 获取举报类型
|
||||||
|
|
||||||
@ -269,7 +269,7 @@ struct WildReportListResponse: Decodable, Equatable {
|
|||||||
- 切换筛选时重置为第一页,待处理传 `handle_status=0`,处理中传 `handle_status=1`,已处理传 `handle_status=2`。
|
- 切换筛选时重置为第一页,待处理传 `handle_status=0`,处理中传 `handle_status=1`,已处理传 `handle_status=2`。
|
||||||
- 滚动到底部时,如果仍有更多数据,则请求下一页并追加。
|
- 滚动到底部时,如果仍有更多数据,则请求下一页并追加。
|
||||||
- 首屏失败显示 toast 和空态;加载更多失败只 toast,并保留已有列表。
|
- 首屏失败显示 toast 和空态;加载更多失败只 toast,并保留已有列表。
|
||||||
- 列表接口暂不返回媒体数量,`WildReportRecord.imageCount/videoCount` 暂按 0 处理,后续详情接口补齐。
|
- 列表接口暂不返回媒体数量,`WildReportRecord.imageCount/videoCount` 暂按 0 处理,进入详情页后由详情接口补齐。
|
||||||
|
|
||||||
### 当前调用位置
|
### 当前调用位置
|
||||||
|
|
||||||
@ -278,12 +278,100 @@ struct WildReportListResponse: Decodable, Equatable {
|
|||||||
- `WildPhotographerReportListViewModel.loadMoreIfNeeded(api:scenicId:)`
|
- `WildPhotographerReportListViewModel.loadMoreIfNeeded(api:scenicId:)`
|
||||||
- `WildPhotographerReportAPI.reportList(_:)`
|
- `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 阶段业务
|
## Mock 阶段业务
|
||||||
|
|
||||||
以下业务当前仍由 `WildPhotographerReportMockStore` 和 ViewModel 本地逻辑完成,暂未接真实接口:
|
以下业务当前仍由 `WildPhotographerReportMockStore` 和 ViewModel 本地逻辑完成,暂未接真实接口:
|
||||||
|
|
||||||
- 举报详情
|
|
||||||
- 补充证据
|
|
||||||
- 附近风险地图
|
- 附近风险地图
|
||||||
- 分享、媒体预览、地图导航演示提示
|
- 分享、媒体预览、地图导航演示提示
|
||||||
|
|
||||||
|
|||||||
@ -3,8 +3,11 @@
|
|||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import PhotosUI
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
/// 举报摄影师模块 UI 工具,统一卡片、标签和按钮样式。
|
/// 举报摄影师模块 UI 工具,统一卡片、标签和按钮样式。
|
||||||
enum WildReportUI {
|
enum WildReportUI {
|
||||||
@ -182,6 +185,322 @@ final class WildReportAttachmentChipView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 举报模块本地媒体选择目标。
|
||||||
|
enum WildReportPickerTarget: Equatable {
|
||||||
|
case image
|
||||||
|
case video
|
||||||
|
case payment
|
||||||
|
|
||||||
|
init(kind: WildReportAttachmentKind) {
|
||||||
|
switch kind {
|
||||||
|
case .image: self = .image
|
||||||
|
case .video: self = .video
|
||||||
|
case .payment: self = .payment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var attachmentKind: WildReportAttachmentKind {
|
||||||
|
switch self {
|
||||||
|
case .image: return .image
|
||||||
|
case .video: return .video
|
||||||
|
case .payment: return .payment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var titlePrefix: String {
|
||||||
|
switch self {
|
||||||
|
case .image: return "现场图片"
|
||||||
|
case .video: return "现场视频"
|
||||||
|
case .payment: return "支付截图"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var limitMessage: String {
|
||||||
|
switch self {
|
||||||
|
case .image: return "现场证据最多上传9项"
|
||||||
|
case .video: return "现场视频最多上传3段"
|
||||||
|
case .payment: return "支付截图最多上传3张"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报模块 PHPicker 结果加载器,将图片/视频保存为可预览和待上传的本地文件。
|
||||||
|
enum WildReportLocalMediaLoader {
|
||||||
|
static func load(
|
||||||
|
results: [PHPickerResult],
|
||||||
|
target: WildReportPickerTarget,
|
||||||
|
titlePrefix: String? = nil
|
||||||
|
) async throws -> [WildReportAttachment] {
|
||||||
|
var attachments: [WildReportAttachment] = []
|
||||||
|
for result in results {
|
||||||
|
let attachment = target == .video
|
||||||
|
? try await loadVideo(from: result.itemProvider, target: target, titlePrefix: titlePrefix)
|
||||||
|
: try await loadImage(from: result.itemProvider, target: target, titlePrefix: titlePrefix)
|
||||||
|
attachments.append(attachment)
|
||||||
|
}
|
||||||
|
return attachments
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadImage(
|
||||||
|
from provider: NSItemProvider,
|
||||||
|
target: WildReportPickerTarget,
|
||||||
|
titlePrefix: String?
|
||||||
|
) async throws -> WildReportAttachment {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
guard provider.canLoadObject(ofClass: UIImage.self) else {
|
||||||
|
continuation.resume(throwing: OSSUploadError.unsupportedFileType)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
provider.loadObject(ofClass: UIImage.self) { object, error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let image = object as? UIImage,
|
||||||
|
let data = image.jpegData(compressionQuality: 0.9) else {
|
||||||
|
continuation.resume(throwing: OSSUploadError.emptyFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let fileName = "report_image_\(UUID().uuidString).jpg"
|
||||||
|
let url = try write(data: data, fileName: fileName)
|
||||||
|
continuation.resume(returning: WildReportAttachment(
|
||||||
|
kind: target.attachmentKind,
|
||||||
|
title: titlePrefix ?? target.titlePrefix,
|
||||||
|
localFileURL: url,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadVideo(
|
||||||
|
from provider: NSItemProvider,
|
||||||
|
target: WildReportPickerTarget,
|
||||||
|
titlePrefix: String?
|
||||||
|
) async throws -> WildReportAttachment {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
|
||||||
|
if let error {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let url else {
|
||||||
|
continuation.resume(throwing: OSSUploadError.emptyFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
|
||||||
|
let fileName = "report_video_\(UUID().uuidString).\(ext)"
|
||||||
|
let targetURL = try temporaryFileURL(fileName: fileName)
|
||||||
|
if FileManager.default.fileExists(atPath: targetURL.path) {
|
||||||
|
try FileManager.default.removeItem(at: targetURL)
|
||||||
|
}
|
||||||
|
try FileManager.default.copyItem(at: url, to: targetURL)
|
||||||
|
continuation.resume(returning: WildReportAttachment(
|
||||||
|
kind: target.attachmentKind,
|
||||||
|
title: titlePrefix ?? target.titlePrefix,
|
||||||
|
localFileURL: targetURL,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func write(data: Data, fileName: String) throws -> URL {
|
||||||
|
let url = try temporaryFileURL(fileName: fileName)
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func temporaryFileURL(fileName: String) throws -> URL {
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("wild_report_media", isDirectory: true)
|
||||||
|
if !FileManager.default.fileExists(atPath: directory.path) {
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
return directory.appendingPathComponent(fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报模块附件缩略 tile,优先展示本地图片或视频封面。
|
||||||
|
final class WildReportAttachmentTileView: UIControl {
|
||||||
|
private let gradientLayer = CAGradientLayer()
|
||||||
|
private let previewAction: () -> Void
|
||||||
|
private let removeAction: () -> Void
|
||||||
|
private let tileSize: CGFloat
|
||||||
|
|
||||||
|
init(
|
||||||
|
attachment: WildReportAttachment,
|
||||||
|
tileSize: CGFloat = 58,
|
||||||
|
onPreview: @escaping () -> Void,
|
||||||
|
onRemove: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.previewAction = onPreview
|
||||||
|
self.removeAction = onRemove
|
||||||
|
self.tileSize = tileSize
|
||||||
|
super.init(frame: .zero)
|
||||||
|
layer.cornerRadius = 10
|
||||||
|
clipsToBounds = false
|
||||||
|
|
||||||
|
gradientLayer.cornerRadius = 10
|
||||||
|
let colors: [UIColor] = attachment.kind == .video
|
||||||
|
? [UIColor(hex: 0xB7D8FF), UIColor(hex: 0x4C8FEF)]
|
||||||
|
: [UIColor(hex: 0xCFE6FF), UIColor(hex: 0x7EB4FF)]
|
||||||
|
gradientLayer.colors = colors.map(\.cgColor)
|
||||||
|
gradientLayer.startPoint = CGPoint(x: 0, y: 0)
|
||||||
|
gradientLayer.endPoint = CGPoint(x: 1, y: 1)
|
||||||
|
layer.insertSublayer(gradientLayer, at: 0)
|
||||||
|
|
||||||
|
let thumbnailView = UIImageView()
|
||||||
|
thumbnailView.image = WildReportAttachmentThumbnailFactory.thumbnail(for: attachment)
|
||||||
|
thumbnailView.contentMode = .scaleAspectFill
|
||||||
|
thumbnailView.clipsToBounds = true
|
||||||
|
thumbnailView.layer.cornerRadius = 10
|
||||||
|
thumbnailView.isUserInteractionEnabled = false
|
||||||
|
thumbnailView.isHidden = thumbnailView.image == nil
|
||||||
|
addSubview(thumbnailView)
|
||||||
|
thumbnailView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
let icon = UIImageView(image: UIImage(systemName: attachment.kind == .video ? "play.circle.fill" : "mountain.2.fill"))
|
||||||
|
icon.tintColor = UIColor.white.withAlphaComponent(0.95)
|
||||||
|
icon.contentMode = .scaleAspectFit
|
||||||
|
icon.isHidden = attachment.kind != .video && thumbnailView.image != nil
|
||||||
|
addSubview(icon)
|
||||||
|
icon.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.size.equalTo(attachment.kind == .video ? 30 : 26)
|
||||||
|
}
|
||||||
|
|
||||||
|
let removeButton = UIButton(type: .system)
|
||||||
|
removeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
|
||||||
|
removeButton.setPreferredSymbolConfiguration(UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold), forImageIn: .normal)
|
||||||
|
removeButton.tintColor = UIColor(hex: 0x8D93A1)
|
||||||
|
removeButton.backgroundColor = .white
|
||||||
|
removeButton.layer.cornerRadius = 14
|
||||||
|
removeButton.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
removeButton.layer.shadowOpacity = 0.10
|
||||||
|
removeButton.layer.shadowRadius = 4
|
||||||
|
removeButton.layer.shadowOffset = CGSize(width: 0, height: 2)
|
||||||
|
removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside)
|
||||||
|
addSubview(removeButton)
|
||||||
|
removeButton.snp.makeConstraints { make in
|
||||||
|
make.top.trailing.equalToSuperview().inset(-2)
|
||||||
|
make.size.equalTo(34)
|
||||||
|
}
|
||||||
|
|
||||||
|
addTarget(self, action: #selector(previewTapped), for: .touchUpInside)
|
||||||
|
snp.makeConstraints { make in
|
||||||
|
make.size.equalTo(tileSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
gradientLayer.frame = bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func previewTapped() {
|
||||||
|
previewAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func removeTapped() {
|
||||||
|
removeAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报模块附件封面生成器,负责从本地图片或视频文件提取缩略图。
|
||||||
|
enum WildReportAttachmentThumbnailFactory {
|
||||||
|
static func thumbnail(for attachment: WildReportAttachment) -> UIImage? {
|
||||||
|
guard let url = attachment.localFileURL else { return nil }
|
||||||
|
switch attachment.kind {
|
||||||
|
case .image, .payment:
|
||||||
|
return UIImage(contentsOfFile: url.path)
|
||||||
|
case .video:
|
||||||
|
return videoThumbnail(url: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func videoThumbnail(url: URL) -> UIImage? {
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
let generator = AVAssetImageGenerator(asset: asset)
|
||||||
|
generator.appliesPreferredTrackTransform = true
|
||||||
|
generator.maximumSize = CGSize(width: 160, height: 160)
|
||||||
|
do {
|
||||||
|
let cgImage = try generator.copyCGImage(at: CMTime(seconds: 0.1, preferredTimescale: 600), actualTime: nil)
|
||||||
|
return UIImage(cgImage: cgImage)
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报模块添加附件 tile,白底虚线加号。
|
||||||
|
final class WildReportAddAttachmentTileView: UIControl {
|
||||||
|
private let tapAction: () -> Void
|
||||||
|
private let borderLayer = CAShapeLayer()
|
||||||
|
private let tileSize: CGFloat
|
||||||
|
|
||||||
|
init(
|
||||||
|
tileSize: CGFloat = 58,
|
||||||
|
borderColor: UIColor = UIColor(hex: 0xCDD5E1),
|
||||||
|
iconColor: UIColor = UIColor(hex: 0x8D93A1),
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
self.tapAction = action
|
||||||
|
self.tileSize = tileSize
|
||||||
|
super.init(frame: .zero)
|
||||||
|
backgroundColor = .white
|
||||||
|
layer.cornerRadius = 10
|
||||||
|
|
||||||
|
borderLayer.strokeColor = borderColor.cgColor
|
||||||
|
borderLayer.fillColor = UIColor.clear.cgColor
|
||||||
|
borderLayer.lineWidth = 1
|
||||||
|
borderLayer.lineDashPattern = [5, 4]
|
||||||
|
layer.addSublayer(borderLayer)
|
||||||
|
|
||||||
|
let icon = UIImageView(image: UIImage(systemName: "plus"))
|
||||||
|
icon.tintColor = iconColor
|
||||||
|
icon.contentMode = .scaleAspectFit
|
||||||
|
addSubview(icon)
|
||||||
|
icon.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.size.equalTo(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
addTarget(self, action: #selector(tapped), for: .touchUpInside)
|
||||||
|
snp.makeConstraints { make in
|
||||||
|
make.size.equalTo(tileSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
borderLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func tapped() {
|
||||||
|
tapAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 举报模块空状态视图。
|
/// 举报模块空状态视图。
|
||||||
final class WildReportEmptyView: UIView {
|
final class WildReportEmptyView: UIView {
|
||||||
init(text: String) {
|
init(text: String) {
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Kingfisher
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@ -136,7 +137,7 @@ final class WildPhotographerReportListViewController: BaseViewController {
|
|||||||
|
|
||||||
private func openDetail(_ record: WildReportRecord) {
|
private func openDetail(_ record: WildReportRecord) {
|
||||||
navigationController?.pushViewController(
|
navigationController?.pushViewController(
|
||||||
WildPhotographerReportDetailViewController(record: record, homeViewModel: viewModel.homeViewModel),
|
WildPhotographerReportDetailViewController(record: record, homeViewModel: viewModel.homeViewModel, api: api),
|
||||||
animated: true
|
animated: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -514,14 +515,20 @@ private final class WildReportListLoadingView: UIView {
|
|||||||
final class WildPhotographerReportDetailViewController: BaseViewController {
|
final class WildPhotographerReportDetailViewController: BaseViewController {
|
||||||
private let viewModel: WildPhotographerReportDetailViewModel
|
private let viewModel: WildPhotographerReportDetailViewModel
|
||||||
private let homeViewModel: WildPhotographerReportHomeViewModel
|
private let homeViewModel: WildPhotographerReportHomeViewModel
|
||||||
|
private let api: any WildPhotographerReportServing
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let stack = UIStackView()
|
private let stack = UIStackView()
|
||||||
private let bottomBar = UIView()
|
private let bottomBar = UIView()
|
||||||
private let bottomButtonRow = UIStackView()
|
private let bottomButtonRow = UIStackView()
|
||||||
|
|
||||||
init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel) {
|
init(
|
||||||
|
record: WildReportRecord,
|
||||||
|
homeViewModel: WildPhotographerReportHomeViewModel,
|
||||||
|
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
|
||||||
|
) {
|
||||||
self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel)
|
self.viewModel = WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel)
|
||||||
self.homeViewModel = homeViewModel
|
self.homeViewModel = homeViewModel
|
||||||
|
self.api = api
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -593,7 +600,17 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
Task {
|
||||||
|
await viewModel.loadDetailIfNeeded(api: api)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override func bindActions() {
|
override func bindActions() {
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.rebuildContent() }
|
||||||
|
}
|
||||||
viewModel.onShowMessage = { [weak self] message in
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
Task { @MainActor in self?.showToast(message) }
|
Task { @MainActor in self?.showToast(message) }
|
||||||
}
|
}
|
||||||
@ -705,6 +722,10 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
|||||||
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报说明", value: viewModel.descriptionText, multiline: true))
|
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报说明", value: viewModel.descriptionText, multiline: true))
|
||||||
card.stack.addArrangedSubview(makeInsetDivider())
|
card.stack.addArrangedSubview(makeInsetDivider())
|
||||||
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "微信号/手机号", value: viewModel.contactText))
|
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "微信号/手机号", value: viewModel.contactText))
|
||||||
|
if !viewModel.handleRemarkText.isEmpty {
|
||||||
|
card.stack.addArrangedSubview(makeInsetDivider())
|
||||||
|
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "处理备注", value: viewModel.handleRemarkText, multiline: true))
|
||||||
|
}
|
||||||
return card
|
return card
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -715,6 +736,10 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
|||||||
card.stack.addArrangedSubview(makeReporterMaterialsSection())
|
card.stack.addArrangedSubview(makeReporterMaterialsSection())
|
||||||
card.stack.addArrangedSubview(makeDivider())
|
card.stack.addArrangedSubview(makeDivider())
|
||||||
}
|
}
|
||||||
|
if !viewModel.paymentScreenshotItems.isEmpty {
|
||||||
|
card.stack.addArrangedSubview(makePaymentScreenshotSection())
|
||||||
|
card.stack.addArrangedSubview(makeDivider())
|
||||||
|
}
|
||||||
card.stack.addArrangedSubview(makeLocationSection())
|
card.stack.addArrangedSubview(makeLocationSection())
|
||||||
return card
|
return card
|
||||||
}
|
}
|
||||||
@ -819,6 +844,43 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
|||||||
return section
|
return section
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makePaymentScreenshotSection() -> UIView {
|
||||||
|
let section = UIStackView()
|
||||||
|
section.axis = .vertical
|
||||||
|
section.spacing = 12
|
||||||
|
section.addArrangedSubview(makeSectionHeader(
|
||||||
|
iconName: "creditcard.fill",
|
||||||
|
title: "支付截图",
|
||||||
|
subtitle: viewModel.paymentScreenshotSummary,
|
||||||
|
chevron: true,
|
||||||
|
action: #selector(paymentPreviewTapped)
|
||||||
|
))
|
||||||
|
|
||||||
|
let mediaScroll = UIScrollView()
|
||||||
|
mediaScroll.showsHorizontalScrollIndicator = false
|
||||||
|
let mediaStack = UIStackView()
|
||||||
|
mediaStack.axis = .horizontal
|
||||||
|
mediaStack.spacing = 10
|
||||||
|
mediaScroll.addSubview(mediaStack)
|
||||||
|
mediaStack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
make.height.equalTo(mediaScroll.frameLayoutGuide)
|
||||||
|
}
|
||||||
|
viewModel.paymentScreenshotItems.forEach { item in
|
||||||
|
let tile = WildReportDetailMediaThumbnailView(reporterItem: item)
|
||||||
|
tile.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reporterMediaTapped(_:))))
|
||||||
|
tile.snp.makeConstraints { make in
|
||||||
|
make.size.equalTo(108)
|
||||||
|
}
|
||||||
|
mediaStack.addArrangedSubview(tile)
|
||||||
|
}
|
||||||
|
mediaScroll.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(108)
|
||||||
|
}
|
||||||
|
section.addArrangedSubview(mediaScroll)
|
||||||
|
return section
|
||||||
|
}
|
||||||
|
|
||||||
private func makeCompletionMediaSection(info: WildReportHandlerInfo) -> UIView {
|
private func makeCompletionMediaSection(info: WildReportHandlerInfo) -> UIView {
|
||||||
let section = UIStackView()
|
let section = UIStackView()
|
||||||
section.axis = .vertical
|
section.axis = .vertical
|
||||||
@ -996,6 +1058,7 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
|||||||
|
|
||||||
@objc private func shareTapped() { viewModel.shareReport() }
|
@objc private func shareTapped() { viewModel.shareReport() }
|
||||||
@objc private func previewTapped() { viewModel.showMediaPreview(kind: "图片/视频") }
|
@objc private func previewTapped() { viewModel.showMediaPreview(kind: "图片/视频") }
|
||||||
|
@objc private func paymentPreviewTapped() { viewModel.showMediaPreview(kind: "支付截图") }
|
||||||
@objc private func locationTapped() { viewModel.showLocationPreview() }
|
@objc private func locationTapped() { viewModel.showLocationPreview() }
|
||||||
|
|
||||||
@objc private func reporterMediaTapped(_ recognizer: UITapGestureRecognizer) {
|
@objc private func reporterMediaTapped(_ recognizer: UITapGestureRecognizer) {
|
||||||
@ -1010,7 +1073,17 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
|||||||
|
|
||||||
@objc private func supplementTapped() {
|
@objc private func supplementTapped() {
|
||||||
navigationController?.pushViewController(
|
navigationController?.pushViewController(
|
||||||
WildReportSupplementEvidenceViewController(reportID: viewModel.record.id, homeViewModel: homeViewModel),
|
WildReportSupplementEvidenceViewController(
|
||||||
|
record: viewModel.record,
|
||||||
|
homeViewModel: homeViewModel,
|
||||||
|
api: api,
|
||||||
|
onSupplementSuccess: { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
Task {
|
||||||
|
await self.viewModel.loadDetailIfNeeded(api: self.api, force: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
animated: true
|
animated: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -1107,24 +1180,25 @@ private final class WildReportDetailInfoRowView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 举报详情页材料缩略图,用渐变与系统图标模拟图片、视频和处理凭证。
|
/// 举报详情页材料缩略图,展示远程图片、视频和处理凭证。
|
||||||
private final class WildReportDetailMediaThumbnailView: UIView {
|
private final class WildReportDetailMediaThumbnailView: UIView {
|
||||||
let previewKindText: String
|
let previewKindText: String
|
||||||
private let gradient = CAGradientLayer()
|
private let gradient = CAGradientLayer()
|
||||||
|
private let remoteImageView = UIImageView()
|
||||||
private let iconView = UIImageView()
|
private let iconView = UIImageView()
|
||||||
private let footerLabel = PaddingLabel(insets: UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8))
|
private let footerLabel = PaddingLabel(insets: UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8))
|
||||||
private let playView = UIImageView(image: UIImage(systemName: "play.circle.fill"))
|
private let playView = UIImageView(image: UIImage(systemName: "play.circle.fill"))
|
||||||
|
|
||||||
init(reporterItem: WildReportReporterMediaItem) {
|
init(reporterItem: WildReportReporterMediaItem) {
|
||||||
switch reporterItem {
|
switch reporterItem {
|
||||||
case .image(let index):
|
case .image(let index, let url, let fileName, let fileTypeText):
|
||||||
previewKindText = "第\(index + 1)张举报图片"
|
previewKindText = fileName?.isEmpty == false ? fileName! : "第\(index + 1)张举报图片"
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
configure(kind: .image(index: index), footerText: "图片 \(index + 1)")
|
configure(kind: .image(index: index), footerText: fileTypeText ?? "图片 \(index + 1)", imageURL: url)
|
||||||
case .video(let index):
|
case .video(let index, _, let fileName, let fileTypeText):
|
||||||
previewKindText = "第\(index + 1)段举报视频"
|
previewKindText = fileName?.isEmpty == false ? fileName! : "第\(index + 1)段举报视频"
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
configure(kind: .video(index: index, duration: "00:32"), footerText: "视频 \(index + 1)")
|
configure(kind: .video(index: index, duration: fileTypeText ?? "视频"), footerText: fileTypeText ?? "视频 \(index + 1)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1151,7 +1225,7 @@ private final class WildReportDetailMediaThumbnailView: UIView {
|
|||||||
gradient.frame = bounds
|
gradient.frame = bounds
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configure(kind: Kind, footerText: String) {
|
private func configure(kind: Kind, footerText: String, imageURL: String? = nil) {
|
||||||
clipsToBounds = true
|
clipsToBounds = true
|
||||||
layer.cornerRadius = 14
|
layer.cornerRadius = 14
|
||||||
gradient.colors = kind.gradientColors
|
gradient.colors = kind.gradientColors
|
||||||
@ -1159,9 +1233,20 @@ private final class WildReportDetailMediaThumbnailView: UIView {
|
|||||||
gradient.endPoint = CGPoint(x: 1, y: 1)
|
gradient.endPoint = CGPoint(x: 1, y: 1)
|
||||||
layer.insertSublayer(gradient, at: 0)
|
layer.insertSublayer(gradient, at: 0)
|
||||||
|
|
||||||
|
if let imageURL, let url = URL(string: imageURL) {
|
||||||
|
remoteImageView.contentMode = .scaleAspectFill
|
||||||
|
remoteImageView.clipsToBounds = true
|
||||||
|
remoteImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
|
||||||
|
addSubview(remoteImageView)
|
||||||
|
remoteImageView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
iconView.image = UIImage(systemName: kind.iconName)
|
iconView.image = UIImage(systemName: kind.iconName)
|
||||||
iconView.tintColor = UIColor.white.withAlphaComponent(0.92)
|
iconView.tintColor = UIColor.white.withAlphaComponent(0.92)
|
||||||
iconView.contentMode = .scaleAspectFit
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
iconView.isHidden = imageURL != nil
|
||||||
addSubview(iconView)
|
addSubview(iconView)
|
||||||
|
|
||||||
footerLabel.text = footerText
|
footerLabel.text = footerText
|
||||||
|
|||||||
@ -3,12 +3,10 @@
|
|||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
|
|
||||||
import AVFoundation
|
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import QuickLook
|
import QuickLook
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
import UniformTypeIdentifiers
|
|
||||||
|
|
||||||
/// 提交举报页面,提供类型、证据、说明、定位和联系方式表单。
|
/// 提交举报页面,提供类型、证据、说明、定位和联系方式表单。
|
||||||
final class WildPhotographerReportSubmitViewController: BaseViewController {
|
final class WildPhotographerReportSubmitViewController: BaseViewController {
|
||||||
@ -686,137 +684,6 @@ extension WildPhotographerReportSubmitViewController: UIGestureRecognizerDelegat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 提交页本地媒体选择目标。
|
|
||||||
private enum WildReportPickerTarget: Equatable {
|
|
||||||
case image
|
|
||||||
case video
|
|
||||||
case payment
|
|
||||||
|
|
||||||
init(kind: WildReportAttachmentKind) {
|
|
||||||
switch kind {
|
|
||||||
case .image: self = .image
|
|
||||||
case .video: self = .video
|
|
||||||
case .payment: self = .payment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var attachmentKind: WildReportAttachmentKind {
|
|
||||||
switch self {
|
|
||||||
case .image: return .image
|
|
||||||
case .video: return .video
|
|
||||||
case .payment: return .payment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var titlePrefix: String {
|
|
||||||
switch self {
|
|
||||||
case .image: return "现场图片"
|
|
||||||
case .video: return "现场视频"
|
|
||||||
case .payment: return "支付截图"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var limitMessage: String {
|
|
||||||
switch self {
|
|
||||||
case .image: return "现场证据最多上传9项"
|
|
||||||
case .video: return "现场视频最多上传3段"
|
|
||||||
case .payment: return "支付截图最多上传3张"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 提交页 PHPicker 结果加载器,将图片/视频保存为可预览和待上传的本地文件。
|
|
||||||
private enum WildReportLocalMediaLoader {
|
|
||||||
static func load(results: [PHPickerResult], target: WildReportPickerTarget) async throws -> [WildReportAttachment] {
|
|
||||||
var attachments: [WildReportAttachment] = []
|
|
||||||
for result in results {
|
|
||||||
let attachment = target == .video
|
|
||||||
? try await loadVideo(from: result.itemProvider, target: target)
|
|
||||||
: try await loadImage(from: result.itemProvider, target: target)
|
|
||||||
attachments.append(attachment)
|
|
||||||
}
|
|
||||||
return attachments
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func loadImage(from provider: NSItemProvider, target: WildReportPickerTarget) async throws -> WildReportAttachment {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
guard provider.canLoadObject(ofClass: UIImage.self) else {
|
|
||||||
continuation.resume(throwing: OSSUploadError.unsupportedFileType)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
provider.loadObject(ofClass: UIImage.self) { object, error in
|
|
||||||
if let error {
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let image = object as? UIImage,
|
|
||||||
let data = image.jpegData(compressionQuality: 0.9) else {
|
|
||||||
continuation.resume(throwing: OSSUploadError.emptyFile)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
let fileName = "report_image_\(UUID().uuidString).jpg"
|
|
||||||
let url = try write(data: data, fileName: fileName)
|
|
||||||
continuation.resume(returning: WildReportAttachment(
|
|
||||||
kind: target.attachmentKind,
|
|
||||||
title: target.titlePrefix,
|
|
||||||
localFileURL: url,
|
|
||||||
fileName: fileName
|
|
||||||
))
|
|
||||||
} catch {
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func loadVideo(from provider: NSItemProvider, target: WildReportPickerTarget) async throws -> WildReportAttachment {
|
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
|
||||||
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
|
|
||||||
if let error {
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let url else {
|
|
||||||
continuation.resume(throwing: OSSUploadError.emptyFile)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
let ext = url.pathExtension.isEmpty ? "mp4" : url.pathExtension
|
|
||||||
let fileName = "report_video_\(UUID().uuidString).\(ext)"
|
|
||||||
let targetURL = try temporaryFileURL(fileName: fileName)
|
|
||||||
if FileManager.default.fileExists(atPath: targetURL.path) {
|
|
||||||
try FileManager.default.removeItem(at: targetURL)
|
|
||||||
}
|
|
||||||
try FileManager.default.copyItem(at: url, to: targetURL)
|
|
||||||
continuation.resume(returning: WildReportAttachment(
|
|
||||||
kind: target.attachmentKind,
|
|
||||||
title: target.titlePrefix,
|
|
||||||
localFileURL: targetURL,
|
|
||||||
fileName: fileName
|
|
||||||
))
|
|
||||||
} catch {
|
|
||||||
continuation.resume(throwing: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func write(data: Data, fileName: String) throws -> URL {
|
|
||||||
let url = try temporaryFileURL(fileName: fileName)
|
|
||||||
try data.write(to: url, options: .atomic)
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func temporaryFileURL(fileName: String) throws -> URL {
|
|
||||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("wild_report_media", isDirectory: true)
|
|
||||||
if !FileManager.default.fileExists(atPath: directory.path) {
|
|
||||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
||||||
}
|
|
||||||
return directory.appendingPathComponent(fileName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 提交页步骤标题,复刻参考页左侧蓝色竖条与选填标识。
|
/// 提交页步骤标题,复刻参考页左侧蓝色竖条与选填标识。
|
||||||
private final class WildReportSectionTitleView: UIView {
|
private final class WildReportSectionTitleView: UIView {
|
||||||
init(index: Int, title: String, optional: Bool) {
|
init(index: Int, title: String, optional: Bool) {
|
||||||
@ -899,169 +766,6 @@ private final class WildReportTypeOptionButton: UIControl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 提交页附件缩略 tile,优先展示本地图片或视频封面。
|
|
||||||
private final class WildReportAttachmentTileView: UIControl {
|
|
||||||
private let gradientLayer = CAGradientLayer()
|
|
||||||
private let previewAction: () -> Void
|
|
||||||
private let removeAction: () -> Void
|
|
||||||
|
|
||||||
init(
|
|
||||||
attachment: WildReportAttachment,
|
|
||||||
onPreview: @escaping () -> Void,
|
|
||||||
onRemove: @escaping () -> Void
|
|
||||||
) {
|
|
||||||
self.previewAction = onPreview
|
|
||||||
self.removeAction = onRemove
|
|
||||||
super.init(frame: .zero)
|
|
||||||
layer.cornerRadius = 10
|
|
||||||
clipsToBounds = false
|
|
||||||
|
|
||||||
gradientLayer.cornerRadius = 10
|
|
||||||
let colors: [UIColor] = attachment.kind == .video
|
|
||||||
? [UIColor(hex: 0xB7D8FF), UIColor(hex: 0x4C8FEF)]
|
|
||||||
: [UIColor(hex: 0xCFE6FF), UIColor(hex: 0x7EB4FF)]
|
|
||||||
gradientLayer.colors = colors.map(\.cgColor)
|
|
||||||
gradientLayer.startPoint = CGPoint(x: 0, y: 0)
|
|
||||||
gradientLayer.endPoint = CGPoint(x: 1, y: 1)
|
|
||||||
layer.insertSublayer(gradientLayer, at: 0)
|
|
||||||
|
|
||||||
let thumbnailView = UIImageView()
|
|
||||||
thumbnailView.image = WildReportAttachmentThumbnailFactory.thumbnail(for: attachment)
|
|
||||||
thumbnailView.contentMode = .scaleAspectFill
|
|
||||||
thumbnailView.clipsToBounds = true
|
|
||||||
thumbnailView.layer.cornerRadius = 10
|
|
||||||
thumbnailView.isUserInteractionEnabled = false
|
|
||||||
thumbnailView.isHidden = thumbnailView.image == nil
|
|
||||||
addSubview(thumbnailView)
|
|
||||||
thumbnailView.snp.makeConstraints { make in
|
|
||||||
make.edges.equalToSuperview()
|
|
||||||
}
|
|
||||||
|
|
||||||
let icon = UIImageView(image: UIImage(systemName: attachment.kind == .video ? "play.circle.fill" : "mountain.2.fill"))
|
|
||||||
icon.tintColor = UIColor.white.withAlphaComponent(0.95)
|
|
||||||
icon.contentMode = .scaleAspectFit
|
|
||||||
icon.isHidden = attachment.kind != .video && thumbnailView.image != nil
|
|
||||||
addSubview(icon)
|
|
||||||
icon.snp.makeConstraints { make in
|
|
||||||
make.center.equalToSuperview()
|
|
||||||
make.size.equalTo(attachment.kind == .video ? 30 : 26)
|
|
||||||
}
|
|
||||||
|
|
||||||
let removeButton = UIButton(type: .system)
|
|
||||||
removeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
|
|
||||||
removeButton.setPreferredSymbolConfiguration(UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold), forImageIn: .normal)
|
|
||||||
removeButton.tintColor = UIColor(hex: 0x8D93A1)
|
|
||||||
removeButton.backgroundColor = .white
|
|
||||||
removeButton.layer.cornerRadius = 14
|
|
||||||
removeButton.layer.shadowColor = UIColor.black.cgColor
|
|
||||||
removeButton.layer.shadowOpacity = 0.10
|
|
||||||
removeButton.layer.shadowRadius = 4
|
|
||||||
removeButton.layer.shadowOffset = CGSize(width: 0, height: 2)
|
|
||||||
removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside)
|
|
||||||
addSubview(removeButton)
|
|
||||||
removeButton.snp.makeConstraints { make in
|
|
||||||
make.top.trailing.equalToSuperview().inset(-2)
|
|
||||||
make.size.equalTo(34)
|
|
||||||
}
|
|
||||||
|
|
||||||
addTarget(self, action: #selector(previewTapped), for: .touchUpInside)
|
|
||||||
snp.makeConstraints { make in
|
|
||||||
make.size.equalTo(58)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
gradientLayer.frame = bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func previewTapped() {
|
|
||||||
previewAction()
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func removeTapped() {
|
|
||||||
removeAction()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 提交页附件封面生成器,负责从本地图片或视频文件提取缩略图。
|
|
||||||
private enum WildReportAttachmentThumbnailFactory {
|
|
||||||
static func thumbnail(for attachment: WildReportAttachment) -> UIImage? {
|
|
||||||
guard let url = attachment.localFileURL else { return nil }
|
|
||||||
switch attachment.kind {
|
|
||||||
case .image, .payment:
|
|
||||||
return UIImage(contentsOfFile: url.path)
|
|
||||||
case .video:
|
|
||||||
return videoThumbnail(url: url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func videoThumbnail(url: URL) -> UIImage? {
|
|
||||||
let asset = AVURLAsset(url: url)
|
|
||||||
let generator = AVAssetImageGenerator(asset: asset)
|
|
||||||
generator.appliesPreferredTrackTransform = true
|
|
||||||
generator.maximumSize = CGSize(width: 160, height: 160)
|
|
||||||
do {
|
|
||||||
let cgImage = try generator.copyCGImage(at: CMTime(seconds: 0.1, preferredTimescale: 600), actualTime: nil)
|
|
||||||
return UIImage(cgImage: cgImage)
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 提交页添加附件 tile,白底虚线加号。
|
|
||||||
private final class WildReportAddAttachmentTileView: UIControl {
|
|
||||||
private let tapAction: () -> Void
|
|
||||||
private let borderLayer = CAShapeLayer()
|
|
||||||
|
|
||||||
init(action: @escaping () -> Void) {
|
|
||||||
self.tapAction = action
|
|
||||||
super.init(frame: .zero)
|
|
||||||
backgroundColor = .white
|
|
||||||
layer.cornerRadius = 10
|
|
||||||
|
|
||||||
borderLayer.strokeColor = UIColor(hex: 0xCDD5E1).cgColor
|
|
||||||
borderLayer.fillColor = UIColor.clear.cgColor
|
|
||||||
borderLayer.lineWidth = 1
|
|
||||||
borderLayer.lineDashPattern = [5, 4]
|
|
||||||
layer.addSublayer(borderLayer)
|
|
||||||
|
|
||||||
let icon = UIImageView(image: UIImage(systemName: "plus"))
|
|
||||||
icon.tintColor = UIColor(hex: 0x8D93A1)
|
|
||||||
icon.contentMode = .scaleAspectFit
|
|
||||||
addSubview(icon)
|
|
||||||
icon.snp.makeConstraints { make in
|
|
||||||
make.center.equalToSuperview()
|
|
||||||
make.size.equalTo(30)
|
|
||||||
}
|
|
||||||
|
|
||||||
addTarget(self, action: #selector(tapped), for: .touchUpInside)
|
|
||||||
snp.makeConstraints { make in
|
|
||||||
make.size.equalTo(58)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func layoutSubviews() {
|
|
||||||
super.layoutSubviews()
|
|
||||||
borderLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 10).cgPath
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func tapped() {
|
|
||||||
tapAction()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 举报成功页面,展示编号、上传摘要和处理进度。
|
/// 举报成功页面,展示编号、上传摘要和处理进度。
|
||||||
final class WildPhotographerReportSuccessViewController: BaseViewController {
|
final class WildPhotographerReportSuccessViewController: BaseViewController {
|
||||||
private let viewModel: WildPhotographerReportSuccessViewModel
|
private let viewModel: WildPhotographerReportSuccessViewModel
|
||||||
|
|||||||
@ -3,19 +3,40 @@
|
|||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import PhotosUI
|
||||||
|
import QuickLook
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 补充证据页面,允许为已有举报追加说明和 Mock 附件。
|
/// 补充证据页面,允许为已有举报追加说明、图片和视频证据。
|
||||||
final class WildReportSupplementEvidenceViewController: BaseViewController {
|
final class WildReportSupplementEvidenceViewController: BaseViewController {
|
||||||
private let viewModel: WildReportSupplementEvidenceViewModel
|
private let viewModel: WildReportSupplementEvidenceViewModel
|
||||||
private let scrollView = UIScrollView()
|
private let api: any WildPhotographerReportServing
|
||||||
private let stack = UIStackView()
|
private let uploader: any WildReportAttachmentUploading
|
||||||
private let textView = UITextView()
|
private let onSupplementSuccess: (() -> Void)?
|
||||||
private let submitButton = AppButton(title: "提交补充证据")
|
|
||||||
|
|
||||||
init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) {
|
private let scrollView = UIScrollView()
|
||||||
self.viewModel = WildReportSupplementEvidenceViewModel(reportID: reportID, homeViewModel: homeViewModel)
|
private let contentStack = UIStackView()
|
||||||
|
private let textView = UITextView()
|
||||||
|
private let placeholderLabel = UILabel()
|
||||||
|
private let countLabel = UILabel()
|
||||||
|
private let submitButton = UIButton(type: .system)
|
||||||
|
|
||||||
|
private var pickerTarget: WildReportPickerTarget = .image
|
||||||
|
private var previewURL: URL?
|
||||||
|
private weak var activeInputView: UIView?
|
||||||
|
|
||||||
|
init(
|
||||||
|
record: WildReportRecord,
|
||||||
|
homeViewModel: WildPhotographerReportHomeViewModel,
|
||||||
|
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
|
||||||
|
uploader: any WildReportAttachmentUploading = NetworkServices.shared.ossUploadService,
|
||||||
|
onSupplementSuccess: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
|
self.viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
|
||||||
|
self.api = api
|
||||||
|
self.uploader = uploader
|
||||||
|
self.onSupplementSuccess = onSupplementSuccess
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,105 +45,421 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "补充证据"
|
title = "补充证据"
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
view.backgroundColor = AppColor.pageBackgroundSoft
|
view.backgroundColor = UIColor(hex: 0xF8FAFF)
|
||||||
scrollView.keyboardDismissMode = .interactive
|
scrollView.keyboardDismissMode = .interactive
|
||||||
stack.axis = .vertical
|
scrollView.showsVerticalScrollIndicator = false
|
||||||
stack.spacing = AppSpacing.sm
|
contentStack.axis = .vertical
|
||||||
textView.font = .app(.body)
|
contentStack.spacing = 14
|
||||||
|
|
||||||
|
textView.font = .systemFont(ofSize: 15)
|
||||||
textView.textColor = AppColor.textPrimary
|
textView.textColor = AppColor.textPrimary
|
||||||
textView.backgroundColor = AppColor.inputBackground
|
textView.backgroundColor = .clear
|
||||||
textView.layer.cornerRadius = AppRadius.md
|
textView.textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 34, right: 8)
|
||||||
textView.textContainerInset = UIEdgeInsets(top: 12, left: 10, bottom: 12, right: 10)
|
|
||||||
textView.delegate = self
|
textView.delegate = self
|
||||||
|
|
||||||
|
placeholderLabel.text = "请补充新的线索说明..."
|
||||||
|
placeholderLabel.font = .systemFont(ofSize: 15)
|
||||||
|
placeholderLabel.textColor = UIColor(hex: 0x9CA3AF)
|
||||||
|
|
||||||
|
countLabel.font = .systemFont(ofSize: 14)
|
||||||
|
countLabel.textColor = UIColor(hex: 0x6B7280)
|
||||||
|
countLabel.textAlignment = .right
|
||||||
|
|
||||||
|
submitButton.setTitle("提交补充证据", for: .normal)
|
||||||
|
submitButton.titleLabel?.font = .systemFont(ofSize: 18, weight: .bold)
|
||||||
|
submitButton.setTitleColor(.white, for: .normal)
|
||||||
|
submitButton.backgroundColor = AppColor.primary
|
||||||
|
submitButton.layer.cornerRadius = 14
|
||||||
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||||
|
|
||||||
view.addSubview(scrollView)
|
view.addSubview(scrollView)
|
||||||
scrollView.addSubview(stack)
|
scrollView.addSubview(contentStack)
|
||||||
view.addSubview(submitButton)
|
view.addSubview(submitButton)
|
||||||
|
|
||||||
|
let dismissTap = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
|
||||||
|
dismissTap.cancelsTouchesInView = false
|
||||||
|
dismissTap.delegate = self
|
||||||
|
scrollView.addGestureRecognizer(dismissTap)
|
||||||
|
registerKeyboardNotifications()
|
||||||
rebuildContent()
|
rebuildContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
submitButton.snp.makeConstraints { make in
|
submitButton.snp.makeConstraints { make in
|
||||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
make.leading.trailing.equalTo(view.safeAreaLayoutGuide).inset(18)
|
||||||
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.sm)
|
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(12)
|
||||||
|
make.height.equalTo(52)
|
||||||
}
|
}
|
||||||
scrollView.snp.makeConstraints { make in
|
scrollView.snp.makeConstraints { make in
|
||||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||||
make.bottom.equalTo(submitButton.snp.top).offset(-AppSpacing.sm)
|
make.bottom.equalTo(submitButton.snp.top).offset(-12)
|
||||||
}
|
}
|
||||||
stack.snp.makeConstraints { make in
|
contentStack.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: AppSpacing.sm, left: AppSpacing.md, bottom: AppSpacing.lg, right: AppSpacing.md))
|
make.edges.equalTo(scrollView.contentLayoutGuide).inset(UIEdgeInsets(top: 16, left: 18, bottom: 28, right: 18))
|
||||||
make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.md * 2)
|
make.width.equalTo(scrollView.frameLayoutGuide).offset(-36)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func bindActions() {
|
override func bindActions() {
|
||||||
viewModel.onStateChange = { [weak self] in
|
viewModel.onStateChange = { [weak self] in
|
||||||
Task { @MainActor in self?.rebuildContent() }
|
Task { @MainActor in
|
||||||
|
guard let self else { return }
|
||||||
|
if self.textView.isFirstResponder {
|
||||||
|
self.updateTextInputState()
|
||||||
|
self.updateSubmitButton()
|
||||||
|
} else {
|
||||||
|
self.rebuildContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
viewModel.onShowMessage = { [weak self] message in
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
Task { @MainActor in self?.showToast(message) }
|
Task { @MainActor in self?.showToast(message) }
|
||||||
}
|
}
|
||||||
viewModel.onSubmitSuccess = { [weak self] in
|
viewModel.onSubmitSuccess = { [weak self] in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.showToast("补充证据已提交")
|
guard let self else { return }
|
||||||
self?.navigationController?.popViewController(animated: true)
|
self.onSupplementSuccess?()
|
||||||
|
self.showToast("补充证据已提交")
|
||||||
|
self.navigationController?.popViewController(animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func rebuildContent() {
|
private func rebuildContent() {
|
||||||
stack.arrangedSubviews.forEach { view in
|
contentStack.arrangedSubviews.forEach { view in
|
||||||
stack.removeArrangedSubview(view)
|
contentStack.removeArrangedSubview(view)
|
||||||
view.removeFromSuperview()
|
view.removeFromSuperview()
|
||||||
}
|
}
|
||||||
let textCard = WildReportCardView(spacing: AppSpacing.sm)
|
contentStack.addArrangedSubview(makeTextCard())
|
||||||
textCard.stack.addArrangedSubview(WildReportUI.label("补充说明", font: .app(.title), color: AppColor.textPrimary))
|
contentStack.addArrangedSubview(makeUploadCard(
|
||||||
|
icon: "photo.fill",
|
||||||
|
title: "上传图片",
|
||||||
|
subtitle: "支持 JPG、PNG 格式,最多 9 张",
|
||||||
|
attachments: viewModel.images,
|
||||||
|
maxCount: 9,
|
||||||
|
kind: .image
|
||||||
|
))
|
||||||
|
contentStack.addArrangedSubview(makeUploadCard(
|
||||||
|
icon: "video.fill",
|
||||||
|
title: "上传视频",
|
||||||
|
subtitle: "支持 MP4 格式,最多 3 段",
|
||||||
|
attachments: viewModel.videos,
|
||||||
|
maxCount: 3,
|
||||||
|
kind: .video
|
||||||
|
))
|
||||||
|
updateSubmitButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTextCard() -> UIView {
|
||||||
|
let card = cardView(spacing: 10)
|
||||||
|
card.stack.addArrangedSubview(WildReportUI.label("补充说明", font: .systemFont(ofSize: 17, weight: .bold), color: AppColor.textPrimary))
|
||||||
textView.text = viewModel.text
|
textView.text = viewModel.text
|
||||||
textCard.stack.addArrangedSubview(textView)
|
|
||||||
textView.snp.makeConstraints { make in make.height.equalTo(150) }
|
|
||||||
let count = WildReportUI.label("\(viewModel.text.count)/500", font: .app(.caption), color: AppColor.textTertiary)
|
|
||||||
count.textAlignment = .right
|
|
||||||
textCard.stack.addArrangedSubview(count)
|
|
||||||
stack.addArrangedSubview(textCard)
|
|
||||||
|
|
||||||
let attachmentCard = WildReportCardView(spacing: AppSpacing.sm)
|
let inputContainer = UIView()
|
||||||
attachmentCard.stack.addArrangedSubview(WildReportUI.label("补充材料", font: .app(.title), color: AppColor.textPrimary))
|
inputContainer.backgroundColor = UIColor(hex: 0xFBFCFE)
|
||||||
let row = UIStackView()
|
inputContainer.layer.cornerRadius = 12
|
||||||
row.axis = .horizontal
|
inputContainer.layer.borderWidth = 1
|
||||||
row.spacing = AppSpacing.sm
|
inputContainer.layer.borderColor = UIColor(hex: 0xCDD5E1).cgColor
|
||||||
row.distribution = .fillEqually
|
inputContainer.addSubview(textView)
|
||||||
let imageButton = WildReportUI.iconButton(title: "添加图片", imageName: "photo", style: .secondary)
|
inputContainer.addSubview(placeholderLabel)
|
||||||
imageButton.addTarget(self, action: #selector(addImageTapped), for: .touchUpInside)
|
inputContainer.addSubview(countLabel)
|
||||||
let videoButton = WildReportUI.iconButton(title: "添加视频", imageName: "video", style: .secondary)
|
|
||||||
videoButton.addTarget(self, action: #selector(addVideoTapped), for: .touchUpInside)
|
textView.snp.remakeConstraints { make in
|
||||||
[imageButton, videoButton].forEach { button in
|
make.edges.equalToSuperview()
|
||||||
button.snp.makeConstraints { make in make.height.equalTo(44) }
|
make.height.greaterThanOrEqualTo(132)
|
||||||
row.addArrangedSubview(button)
|
|
||||||
}
|
}
|
||||||
attachmentCard.stack.addArrangedSubview(row)
|
placeholderLabel.snp.remakeConstraints { make in
|
||||||
(viewModel.images + viewModel.videos).forEach { attachment in
|
make.top.equalToSuperview().offset(18)
|
||||||
let chip = WildReportAttachmentChipView(attachment: attachment)
|
make.leading.equalToSuperview().offset(14)
|
||||||
chip.onRemove = { [weak self] in self?.viewModel.removeAttachment(attachment) }
|
make.trailing.lessThanOrEqualToSuperview().inset(14)
|
||||||
attachmentCard.stack.addArrangedSubview(chip)
|
|
||||||
}
|
}
|
||||||
stack.addArrangedSubview(attachmentCard)
|
countLabel.snp.remakeConstraints { make in
|
||||||
|
make.trailing.bottom.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
card.stack.addArrangedSubview(inputContainer)
|
||||||
|
updateTextInputState()
|
||||||
|
return card
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func addImageTapped() { viewModel.addImage() }
|
private func makeUploadCard(
|
||||||
@objc private func addVideoTapped() { viewModel.addVideo() }
|
icon: String,
|
||||||
@objc private func submitTapped() { viewModel.submit() }
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
attachments: [WildReportAttachment],
|
||||||
|
maxCount: Int,
|
||||||
|
kind: WildReportAttachmentKind
|
||||||
|
) -> UIView {
|
||||||
|
let card = cardView(spacing: 12)
|
||||||
|
let header = UIStackView()
|
||||||
|
header.axis = .horizontal
|
||||||
|
header.alignment = .center
|
||||||
|
header.spacing = 10
|
||||||
|
|
||||||
|
let iconView = UIImageView(image: UIImage(systemName: icon))
|
||||||
|
iconView.tintColor = AppColor.primary
|
||||||
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
iconView.snp.makeConstraints { make in
|
||||||
|
make.size.equalTo(26)
|
||||||
|
}
|
||||||
|
|
||||||
|
let textStack = UIStackView()
|
||||||
|
textStack.axis = .vertical
|
||||||
|
textStack.spacing = 4
|
||||||
|
textStack.addArrangedSubview(WildReportUI.label(title, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary))
|
||||||
|
textStack.addArrangedSubview(WildReportUI.label(subtitle, font: .systemFont(ofSize: 12), color: AppColor.textSecondary, lines: 0))
|
||||||
|
|
||||||
|
header.addArrangedSubview(iconView)
|
||||||
|
header.addArrangedSubview(textStack)
|
||||||
|
header.addArrangedSubview(UIView())
|
||||||
|
card.stack.addArrangedSubview(header)
|
||||||
|
|
||||||
|
let scroll = UIScrollView()
|
||||||
|
scroll.showsHorizontalScrollIndicator = false
|
||||||
|
let tiles = UIStackView()
|
||||||
|
tiles.axis = .horizontal
|
||||||
|
tiles.spacing = 10
|
||||||
|
scroll.addSubview(tiles)
|
||||||
|
tiles.snp.makeConstraints { make in
|
||||||
|
make.edges.equalTo(scroll.contentLayoutGuide).inset(UIEdgeInsets(top: 2, left: 0, bottom: 2, right: 0))
|
||||||
|
make.height.equalTo(scroll.frameLayoutGuide).offset(-4)
|
||||||
|
}
|
||||||
|
|
||||||
|
attachments.forEach { attachment in
|
||||||
|
tiles.addArrangedSubview(WildReportAttachmentTileView(
|
||||||
|
attachment: attachment,
|
||||||
|
tileSize: 72,
|
||||||
|
onPreview: { [weak self] in self?.previewAttachment(attachment) },
|
||||||
|
onRemove: { [weak self] in self?.viewModel.removeAttachment(attachment) }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
if canAddAttachment(kind: kind, currentCount: attachments.count, maxCount: maxCount) {
|
||||||
|
tiles.addArrangedSubview(WildReportAddAttachmentTileView(
|
||||||
|
tileSize: 72,
|
||||||
|
borderColor: AppColor.primary,
|
||||||
|
iconColor: AppColor.primary
|
||||||
|
) { [weak self] in
|
||||||
|
self?.presentMediaPicker(for: WildReportPickerTarget(kind: kind))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
scroll.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(76)
|
||||||
|
}
|
||||||
|
card.stack.addArrangedSubview(scroll)
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cardView(spacing: CGFloat) -> WildReportCardView {
|
||||||
|
let card = WildReportCardView(spacing: spacing, inset: 16)
|
||||||
|
card.layer.cornerRadius = 16
|
||||||
|
card.layer.borderColor = AppColor.cardOutline.cgColor
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateTextInputState() {
|
||||||
|
placeholderLabel.isHidden = !viewModel.text.isEmpty
|
||||||
|
countLabel.text = "\(viewModel.text.count)/500"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateSubmitButton() {
|
||||||
|
let title = viewModel.isSubmitting && !viewModel.submitProgressText.isEmpty
|
||||||
|
? viewModel.submitProgressText
|
||||||
|
: "提交补充证据"
|
||||||
|
submitButton.setTitle(title, for: .normal)
|
||||||
|
submitButton.isEnabled = !viewModel.isSubmitting
|
||||||
|
submitButton.alpha = viewModel.isSubmitting ? 0.78 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private func canAddAttachment(kind: WildReportAttachmentKind, currentCount: Int, maxCount: Int) -> Bool {
|
||||||
|
let ownRemaining = max(0, maxCount - currentCount)
|
||||||
|
let evidenceRemaining = max(0, 9 - viewModel.images.count - viewModel.videos.count)
|
||||||
|
switch kind {
|
||||||
|
case .image:
|
||||||
|
return min(ownRemaining, evidenceRemaining) > 0
|
||||||
|
case .video:
|
||||||
|
return min(ownRemaining, evidenceRemaining) > 0
|
||||||
|
case .payment:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentMediaPicker(for target: WildReportPickerTarget) {
|
||||||
|
dismissKeyboard()
|
||||||
|
let remaining = remainingSelectionCount(for: target)
|
||||||
|
guard remaining > 0 else {
|
||||||
|
showToast(target == .video ? "补充视频最多上传3段" : "补充证据最多上传9项")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pickerTarget = target
|
||||||
|
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||||
|
configuration.selectionLimit = remaining
|
||||||
|
configuration.filter = target == .video ? .videos : .images
|
||||||
|
let picker = PHPickerViewController(configuration: configuration)
|
||||||
|
picker.delegate = self
|
||||||
|
present(picker, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func remainingSelectionCount(for target: WildReportPickerTarget) -> Int {
|
||||||
|
switch target {
|
||||||
|
case .image:
|
||||||
|
return max(0, 9 - viewModel.images.count - viewModel.videos.count)
|
||||||
|
case .video:
|
||||||
|
return max(0, min(3 - viewModel.videos.count, 9 - viewModel.images.count - viewModel.videos.count))
|
||||||
|
case .payment:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func previewAttachment(_ attachment: WildReportAttachment) {
|
||||||
|
guard let url = attachment.localFileURL else {
|
||||||
|
showToast("当前附件暂无本地预览")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
previewURL = url
|
||||||
|
let preview = QLPreviewController()
|
||||||
|
preview.dataSource = self
|
||||||
|
present(preview, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func submitTapped() {
|
||||||
|
Task {
|
||||||
|
await viewModel.submit(
|
||||||
|
api: api,
|
||||||
|
uploader: uploader,
|
||||||
|
scenicId: AppStore.shared.currentScenicId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func dismissKeyboard() {
|
||||||
|
view.endEditing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerKeyboardNotifications() {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(keyboardWillChangeFrame(_:)),
|
||||||
|
name: UIResponder.keyboardWillChangeFrameNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(keyboardWillHide(_:)),
|
||||||
|
name: UIResponder.keyboardWillHideNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func keyboardWillChangeFrame(_ notification: Notification) {
|
||||||
|
guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
|
||||||
|
let keyboardFrameInView = view.convert(keyboardFrame, from: nil)
|
||||||
|
let overlap = max(0, view.bounds.maxY - keyboardFrameInView.minY)
|
||||||
|
updateScrollInsets(bottom: overlap + 12, notification: notification)
|
||||||
|
scrollActiveInputIntoView(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func keyboardWillHide(_ notification: Notification) {
|
||||||
|
updateScrollInsets(bottom: 0, notification: notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateScrollInsets(bottom: CGFloat, notification: Notification) {
|
||||||
|
let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25
|
||||||
|
let curveRaw = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt ?? UIView.AnimationOptions.curveEaseInOut.rawValue
|
||||||
|
let options = UIView.AnimationOptions(rawValue: curveRaw << 16)
|
||||||
|
UIView.animate(withDuration: duration, delay: 0, options: options) {
|
||||||
|
self.scrollView.contentInset.bottom = bottom
|
||||||
|
self.scrollView.verticalScrollIndicatorInsets.bottom = bottom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scrollActiveInputIntoView(animated: Bool) {
|
||||||
|
guard let activeInputView else { return }
|
||||||
|
view.layoutIfNeeded()
|
||||||
|
let rect = activeInputView.convert(activeInputView.bounds, to: scrollView).insetBy(dx: 0, dy: -24)
|
||||||
|
scrollView.scrollRectToVisible(rect, animated: animated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WildReportSupplementEvidenceViewController: PHPickerViewControllerDelegate {
|
||||||
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
guard !results.isEmpty else { return }
|
||||||
|
let target = pickerTarget
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let attachments = try await WildReportLocalMediaLoader.load(
|
||||||
|
results: results,
|
||||||
|
target: target,
|
||||||
|
titlePrefix: target == .video ? "补充视频" : "补充图片"
|
||||||
|
)
|
||||||
|
await MainActor.run {
|
||||||
|
switch target {
|
||||||
|
case .image:
|
||||||
|
viewModel.addImages(attachments)
|
||||||
|
case .video:
|
||||||
|
viewModel.addVideos(attachments)
|
||||||
|
case .payment:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { showToast(error.localizedDescription) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WildReportSupplementEvidenceViewController: QLPreviewControllerDataSource {
|
||||||
|
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
|
||||||
|
previewURL == nil ? 0 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
|
||||||
|
previewURL! as NSURL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension WildReportSupplementEvidenceViewController: UITextViewDelegate {
|
extension WildReportSupplementEvidenceViewController: UITextViewDelegate {
|
||||||
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||||
|
activeInputView = textView
|
||||||
|
DispatchQueue.main.async { [weak self] in self?.scrollActiveInputIntoView(animated: true) }
|
||||||
|
}
|
||||||
|
|
||||||
func textViewDidChange(_ textView: UITextView) {
|
func textViewDidChange(_ textView: UITextView) {
|
||||||
viewModel.updateText(textView.text)
|
viewModel.updateText(textView.text)
|
||||||
|
if textView.text != viewModel.text {
|
||||||
|
textView.text = viewModel.text
|
||||||
|
}
|
||||||
|
updateTextInputState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func textViewDidEndEditing(_ textView: UITextView) {
|
||||||
|
if activeInputView === textView {
|
||||||
|
activeInputView = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WildReportSupplementEvidenceViewController: UIGestureRecognizerDelegate {
|
||||||
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||||
|
var touchedView = touch.view
|
||||||
|
while let currentView = touchedView {
|
||||||
|
if currentView is UIControl || currentView is UITextView {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
touchedView = currentView.superview
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -115,6 +115,129 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertEqual(response.list.first?.complaintNo, "JB20260708917")
|
XCTAssertEqual(response.list.first?.complaintNo, "JB20260708917")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testReportDetailAPILoadsRemoteDetail() async throws {
|
||||||
|
let json = Data("""
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 32,
|
||||||
|
"complaint_no": "JB20260708917",
|
||||||
|
"title": "私下收款举报",
|
||||||
|
"report_type": 1,
|
||||||
|
"report_type_text": "私下收款",
|
||||||
|
"desc": "我在观景台附近看到私下收款",
|
||||||
|
"supplements": [],
|
||||||
|
"report_contents": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"id": 0,
|
||||||
|
"desc": "我在观景台附近看到私下收款",
|
||||||
|
"time": "2026-07-08 16:06:59",
|
||||||
|
"time_label": "16:06:59",
|
||||||
|
"evidences": [
|
||||||
|
{
|
||||||
|
"file_type": 1,
|
||||||
|
"file_type_text": "图片",
|
||||||
|
"file_url": "https://example.com/report.jpg",
|
||||||
|
"file_name": "report.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scenic_id": 128,
|
||||||
|
"scenic_name": "伊犁那拉提景区-5A",
|
||||||
|
"store_user_id": 0,
|
||||||
|
"contact_phone": "13800138000",
|
||||||
|
"location_name": "伊犁那拉提景区-5A",
|
||||||
|
"location_address": "江苏省扬州市邗江区瘦西湖路",
|
||||||
|
"latitude": "32.4299428",
|
||||||
|
"longitude": "119.4403263",
|
||||||
|
"handle_status": 0,
|
||||||
|
"handle_status_text": "待处理",
|
||||||
|
"handle_remark": "",
|
||||||
|
"evidence_summary": { "image_count": 1, "video_count": 1 },
|
||||||
|
"evidences": [
|
||||||
|
{
|
||||||
|
"file_type": 1,
|
||||||
|
"file_type_text": "图片",
|
||||||
|
"file_url": "https://example.com/report.jpg",
|
||||||
|
"file_name": "report.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_type": 2,
|
||||||
|
"file_type_text": "视频",
|
||||||
|
"file_url": "https://example.com/report.mov",
|
||||||
|
"file_name": "report.mov"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"payment_screenshots": [
|
||||||
|
{
|
||||||
|
"file_type": 5,
|
||||||
|
"file_type_text": "支付截图",
|
||||||
|
"file_url": "https://example.com/payment.jpg",
|
||||||
|
"file_name": "payment.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2026-07-08 16:06:59",
|
||||||
|
"timeline": [
|
||||||
|
{ "time": "2026-07-08 16:06:59", "content": "摄影师提交举报" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": "2026-07-08 16:31:50"
|
||||||
|
}
|
||||||
|
""".utf8)
|
||||||
|
let session = MockURLSession(responses: [json])
|
||||||
|
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
let response = try await api.reportDetail(id: 32)
|
||||||
|
|
||||||
|
let components = URLComponents(url: try XCTUnwrap(session.requests.first?.url), resolvingAgainstBaseURL: false)
|
||||||
|
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/detail")
|
||||||
|
XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "id", value: "32")) == true)
|
||||||
|
XCTAssertEqual(response.complaintNo, "JB20260708917")
|
||||||
|
XCTAssertEqual(response.evidences.count, 2)
|
||||||
|
XCTAssertEqual(response.paymentScreenshots.first?.fileName, "payment.jpg")
|
||||||
|
XCTAssertEqual(response.timeline.first?.content, "摄影师提交举报")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSupplementReportAPISendsRequestBody() async throws {
|
||||||
|
let json = Data("""
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": null,
|
||||||
|
"time": "2026-07-08 17:31:50"
|
||||||
|
}
|
||||||
|
""".utf8)
|
||||||
|
let session = MockURLSession(responses: [json])
|
||||||
|
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
try await api.supplementReport(WildReportSupplementRequest(
|
||||||
|
id: 32,
|
||||||
|
desc: "补充说明",
|
||||||
|
evidences: [
|
||||||
|
WildReportEvidenceRequest(
|
||||||
|
fileURL: "https://example.com/supplement.jpg",
|
||||||
|
fileType: 1,
|
||||||
|
fileName: "supplement.jpg"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
))
|
||||||
|
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.httpMethod, "POST")
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/supplement")
|
||||||
|
let bodyData = try XCTUnwrap(request.httpBody)
|
||||||
|
let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
|
||||||
|
XCTAssertEqual(body["id"] as? Int, 32)
|
||||||
|
XCTAssertEqual(body["desc"] as? String, "补充说明")
|
||||||
|
let evidences = try XCTUnwrap(body["evidences"] as? [[String: Any]])
|
||||||
|
XCTAssertEqual(evidences.first?["file_url"] as? String, "https://example.com/supplement.jpg")
|
||||||
|
XCTAssertEqual(evidences.first?["file_type"] as? Int, 1)
|
||||||
|
XCTAssertEqual(evidences.first?["file_name"] as? String, "supplement.jpg")
|
||||||
|
}
|
||||||
|
|
||||||
func testReportListItemMapsToRecord() {
|
func testReportListItemMapsToRecord() {
|
||||||
let item = makeReportListItem(
|
let item = makeReportListItem(
|
||||||
complaintNo: "JB20260708917",
|
complaintNo: "JB20260708917",
|
||||||
@ -136,6 +259,31 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertEqual(record.submitTime, "2026-07-08 16:06:59")
|
XCTAssertEqual(record.submitTime, "2026-07-08 16:06:59")
|
||||||
XCTAssertEqual(record.imageCount, 0)
|
XCTAssertEqual(record.imageCount, 0)
|
||||||
XCTAssertEqual(record.videoCount, 0)
|
XCTAssertEqual(record.videoCount, 0)
|
||||||
|
XCTAssertEqual(record.serverID, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReportDetailMapsToRecordAndSupplementData() {
|
||||||
|
let detail = makeReportDetailData(handleStatus: 3, handleStatusText: "驳回")
|
||||||
|
|
||||||
|
let record = detail.toRecord()
|
||||||
|
|
||||||
|
XCTAssertEqual(record.serverID, 32)
|
||||||
|
XCTAssertEqual(record.id, "JB20260708917")
|
||||||
|
XCTAssertEqual(record.reportType, WildReportType(value: 1, label: "私下收款"))
|
||||||
|
XCTAssertEqual(record.status, .rejected)
|
||||||
|
XCTAssertEqual(record.location, "伊犁那拉提景区-5A · 江苏省扬州市邗江区瘦西湖路")
|
||||||
|
XCTAssertEqual(record.description, "正式详情说明")
|
||||||
|
XCTAssertEqual(record.phoneNumber, "13800138000")
|
||||||
|
XCTAssertEqual(record.imageCount, 1)
|
||||||
|
XCTAssertEqual(record.videoCount, 1)
|
||||||
|
XCTAssertEqual(detail.supplementRecords.first?.summaryText, "补充说明 · 1张图片")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReportStatusMapsRejected() {
|
||||||
|
XCTAssertEqual(WildReportStatus(handleStatus: 0), .pending)
|
||||||
|
XCTAssertEqual(WildReportStatus(handleStatus: 1), .processing)
|
||||||
|
XCTAssertEqual(WildReportStatus(handleStatus: 2), .processed)
|
||||||
|
XCTAssertEqual(WildReportStatus(handleStatus: 3), .rejected)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testReportListAllFilterOmitsHandleStatus() {
|
func testReportListAllFilterOmitsHandleStatus() {
|
||||||
@ -384,21 +532,165 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
|
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSupplementEvidenceValidationAndSuccess() {
|
func testSupplementEvidenceValidationAndSuccess() async throws {
|
||||||
let homeViewModel = WildPhotographerReportHomeViewModel()
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
let reportID = homeViewModel.reports[0].id
|
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
|
||||||
let viewModel = WildReportSupplementEvidenceViewModel(reportID: reportID, homeViewModel: homeViewModel)
|
let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
let uploader = MockWildReportAttachmentUploader()
|
||||||
|
|
||||||
viewModel.submit()
|
await viewModel.submit(api: api, uploader: uploader, scenicId: 128)
|
||||||
XCTAssertEqual(viewModel.validationMessage, "请至少填写补充说明或上传一项证据。")
|
XCTAssertEqual(viewModel.validationMessage, "请至少填写补充说明或上传一项证据。")
|
||||||
|
|
||||||
viewModel.updateText("补充说明")
|
viewModel.updateText("补充说明")
|
||||||
viewModel.addImage()
|
viewModel.addImages([try makeAttachment(kind: .image, fileName: "supplement.jpg")])
|
||||||
viewModel.submit()
|
viewModel.addVideos([try makeAttachment(kind: .video, fileName: "supplement.mov")])
|
||||||
|
await viewModel.submit(api: api, uploader: uploader, scenicId: 128)
|
||||||
|
|
||||||
XCTAssertTrue(viewModel.didSubmit)
|
XCTAssertTrue(viewModel.didSubmit)
|
||||||
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: reportID).first?.text, "补充说明")
|
XCTAssertEqual(uploader.uploadedFileNames, ["supplement.jpg", "supplement.mov"])
|
||||||
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: reportID).first?.imageCount, 1)
|
XCTAssertEqual(api.supplementRequests.first?.id, 32)
|
||||||
|
XCTAssertEqual(api.supplementRequests.first?.desc, "补充说明")
|
||||||
|
XCTAssertEqual(api.supplementRequests.first?.evidences?.map(\.fileType), [1, 2])
|
||||||
|
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: record.id).first?.text, "补充说明")
|
||||||
|
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: record.id).first?.imageCount, 1)
|
||||||
|
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: record.id).first?.videoCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSupplementEvidenceFailsWithoutServerID() async {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let record = WildReportRecord(
|
||||||
|
serverID: nil,
|
||||||
|
id: "JBLOCAL",
|
||||||
|
title: "摄影师举报",
|
||||||
|
reportType: WildReportType(value: 2, label: "疑似打野"),
|
||||||
|
status: .pending,
|
||||||
|
location: "测试景区",
|
||||||
|
submitTime: "2026-07-08 16:06:59",
|
||||||
|
description: "本地记录",
|
||||||
|
wechatID: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
imageCount: 1,
|
||||||
|
videoCount: 0,
|
||||||
|
handlerInfo: nil
|
||||||
|
)
|
||||||
|
let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
|
||||||
|
viewModel.updateText("补充说明")
|
||||||
|
|
||||||
|
await viewModel.submit(
|
||||||
|
api: MockWildPhotographerReportAPI(),
|
||||||
|
uploader: MockWildReportAttachmentUploader(),
|
||||||
|
scenicId: 128
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.validationMessage, "当前举报暂未同步到服务器,无法补充证据")
|
||||||
|
XCTAssertFalse(viewModel.didSubmit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSupplementEvidenceTextAndAttachmentLimits() throws {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
|
||||||
|
let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
|
||||||
|
|
||||||
|
viewModel.updateText(String(repeating: "测", count: 520))
|
||||||
|
XCTAssertEqual(viewModel.text.count, 500)
|
||||||
|
|
||||||
|
let images = try (0..<10).map { try makeAttachment(kind: .image, fileName: "image\($0).jpg") }
|
||||||
|
viewModel.addImages(images)
|
||||||
|
XCTAssertEqual(viewModel.images.count, 9)
|
||||||
|
|
||||||
|
viewModel.removeAttachment(viewModel.images[0])
|
||||||
|
let videos = try (0..<4).map { try makeAttachment(kind: .video, fileName: "video\($0).mov") }
|
||||||
|
viewModel.addVideos(videos)
|
||||||
|
XCTAssertEqual(viewModel.videos.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSupplementEvidenceUploadFailureDoesNotSubmit() async throws {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
|
||||||
|
let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
let uploader = MockWildReportAttachmentUploader()
|
||||||
|
uploader.error = URLError(.cannotWriteToFile)
|
||||||
|
viewModel.addImages([try makeAttachment(kind: .image, fileName: "supplement.jpg")])
|
||||||
|
|
||||||
|
await viewModel.submit(api: api, uploader: uploader, scenicId: 128)
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.didSubmit)
|
||||||
|
XCTAssertTrue(api.supplementRequests.isEmpty)
|
||||||
|
XCTAssertEqual(viewModel.validationMessage, URLError(.cannotWriteToFile).localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSupplementEvidenceAPIErrorDoesNotSucceed() async throws {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
|
||||||
|
let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
api.supplementError = URLError(.badServerResponse)
|
||||||
|
viewModel.addImages([try makeAttachment(kind: .image, fileName: "supplement.jpg")])
|
||||||
|
|
||||||
|
await viewModel.submit(
|
||||||
|
api: api,
|
||||||
|
uploader: MockWildReportAttachmentUploader(),
|
||||||
|
scenicId: 128
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertFalse(viewModel.didSubmit)
|
||||||
|
XCTAssertEqual(api.supplementRequests.count, 0)
|
||||||
|
XCTAssertEqual(viewModel.validationMessage, URLError(.badServerResponse).localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDetailViewModelLoadsRemoteDetailWhenServerIDExists() async {
|
||||||
|
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
|
||||||
|
let viewModel = WildPhotographerReportDetailViewModel(record: record)
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
api.reportDetailResponses = [makeReportDetailData()]
|
||||||
|
|
||||||
|
await viewModel.loadDetailIfNeeded(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.reportDetailRequests, [32])
|
||||||
|
XCTAssertEqual(viewModel.record.description, "正式详情说明")
|
||||||
|
XCTAssertEqual(viewModel.reporterMediaItems.count, 2)
|
||||||
|
XCTAssertEqual(viewModel.paymentScreenshotItems.count, 1)
|
||||||
|
XCTAssertEqual(viewModel.progressSteps.first?.title, "摄影师提交举报")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDetailViewModelFailureKeepsInitialRecord() async {
|
||||||
|
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
|
||||||
|
let viewModel = WildPhotographerReportDetailViewModel(record: record)
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
api.reportDetailError = URLError(.badServerResponse)
|
||||||
|
var messages: [String] = []
|
||||||
|
viewModel.onShowMessage = { messages.append($0) }
|
||||||
|
|
||||||
|
await viewModel.loadDetailIfNeeded(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.record.description, "测试说明")
|
||||||
|
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDetailViewModelWithoutServerIDSkipsRequest() async {
|
||||||
|
let record = WildReportRecord(
|
||||||
|
id: "JBLOCAL",
|
||||||
|
title: "摄影师举报",
|
||||||
|
reportType: WildReportType(value: 2, label: "疑似打野"),
|
||||||
|
status: .pending,
|
||||||
|
location: "测试景区",
|
||||||
|
submitTime: "2026-07-08 16:06:59",
|
||||||
|
description: "本地提交记录",
|
||||||
|
wechatID: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
imageCount: 1,
|
||||||
|
videoCount: 0,
|
||||||
|
handlerInfo: nil
|
||||||
|
)
|
||||||
|
let viewModel = WildPhotographerReportDetailViewModel(record: record)
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
|
||||||
|
await viewModel.loadDetailIfNeeded(api: api)
|
||||||
|
|
||||||
|
XCTAssertTrue(api.reportDetailRequests.isEmpty)
|
||||||
|
XCTAssertEqual(viewModel.record.description, "本地提交记录")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -410,6 +702,11 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
|
|||||||
var reportListResponses: [WildReportListResponse] = []
|
var reportListResponses: [WildReportListResponse] = []
|
||||||
var reportListRequests: [WildReportListRequest] = []
|
var reportListRequests: [WildReportListRequest] = []
|
||||||
var reportListError: Error?
|
var reportListError: Error?
|
||||||
|
var reportDetailResponses: [WildReportDetailData] = []
|
||||||
|
var reportDetailRequests: [Int] = []
|
||||||
|
var reportDetailError: Error?
|
||||||
|
var supplementRequests: [WildReportSupplementRequest] = []
|
||||||
|
var supplementError: Error?
|
||||||
|
|
||||||
func reportTypes() async throws -> WildReportTypeListResponse {
|
func reportTypes() async throws -> WildReportTypeListResponse {
|
||||||
if let error { throw error }
|
if let error { throw error }
|
||||||
@ -421,6 +718,11 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
|
|||||||
submitRequests.append(request)
|
submitRequests.append(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func supplementReport(_ request: WildReportSupplementRequest) async throws {
|
||||||
|
if let supplementError { throw supplementError }
|
||||||
|
supplementRequests.append(request)
|
||||||
|
}
|
||||||
|
|
||||||
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse {
|
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse {
|
||||||
if let reportListError { throw reportListError }
|
if let reportListError { throw reportListError }
|
||||||
reportListRequests.append(request)
|
reportListRequests.append(request)
|
||||||
@ -429,6 +731,13 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
|
|||||||
}
|
}
|
||||||
return reportListResponses.removeFirst()
|
return reportListResponses.removeFirst()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reportDetail(id: Int) async throws -> WildReportDetailData {
|
||||||
|
if let reportDetailError { throw reportDetailError }
|
||||||
|
reportDetailRequests.append(id)
|
||||||
|
guard !reportDetailResponses.isEmpty else { return WildReportDetailData(id: id) }
|
||||||
|
return reportDetailResponses.removeFirst()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -462,6 +771,7 @@ private func makeAttachment(kind: WildReportAttachmentKind, fileName: String) th
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeReportListItem(
|
private func makeReportListItem(
|
||||||
|
id: Int = 32,
|
||||||
complaintNo: String,
|
complaintNo: String,
|
||||||
title: String = "疑似打野摄影师举报",
|
title: String = "疑似打野摄影师举报",
|
||||||
reportType: Int = 2,
|
reportType: Int = 2,
|
||||||
@ -470,7 +780,7 @@ private func makeReportListItem(
|
|||||||
handleStatusText: String = "待处理"
|
handleStatusText: String = "待处理"
|
||||||
) -> WildReportListItem {
|
) -> WildReportListItem {
|
||||||
WildReportListItem(
|
WildReportListItem(
|
||||||
id: Int(complaintNo.filter(\.isNumber)) ?? 1,
|
id: id,
|
||||||
complaintNo: complaintNo,
|
complaintNo: complaintNo,
|
||||||
title: title,
|
title: title,
|
||||||
reportType: reportType,
|
reportType: reportType,
|
||||||
@ -486,3 +796,73 @@ private func makeReportListItem(
|
|||||||
createdAt: "2026-07-08 16:06:59"
|
createdAt: "2026-07-08 16:06:59"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeReportDetailData(
|
||||||
|
handleStatus: Int = 0,
|
||||||
|
handleStatusText: String = "待处理"
|
||||||
|
) -> WildReportDetailData {
|
||||||
|
WildReportDetailData(
|
||||||
|
id: 32,
|
||||||
|
complaintNo: "JB20260708917",
|
||||||
|
title: "私下收款举报",
|
||||||
|
reportType: 1,
|
||||||
|
reportTypeText: "私下收款",
|
||||||
|
desc: "正式详情说明",
|
||||||
|
supplements: [
|
||||||
|
WildReportDetailContent(
|
||||||
|
type: "supplement",
|
||||||
|
id: 7,
|
||||||
|
desc: "补充说明",
|
||||||
|
time: "2026-07-08 17:06:59",
|
||||||
|
timeLabel: "17:06:59",
|
||||||
|
evidences: [
|
||||||
|
WildReportDetailEvidence(
|
||||||
|
fileType: 1,
|
||||||
|
fileTypeText: "图片",
|
||||||
|
fileURL: "https://example.com/supplement.jpg",
|
||||||
|
fileName: "supplement.jpg"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
reportContents: [],
|
||||||
|
scenicId: 128,
|
||||||
|
scenicName: "伊犁那拉提景区-5A",
|
||||||
|
storeUserId: 0,
|
||||||
|
contactPhone: "13800138000",
|
||||||
|
locationName: "伊犁那拉提景区-5A",
|
||||||
|
locationAddress: "江苏省扬州市邗江区瘦西湖路",
|
||||||
|
latitude: "32.4299428",
|
||||||
|
longitude: "119.4403263",
|
||||||
|
handleStatus: handleStatus,
|
||||||
|
handleStatusText: handleStatusText,
|
||||||
|
handleRemark: "处理备注",
|
||||||
|
evidenceSummary: WildReportEvidenceSummary(imageCount: 1, videoCount: 1),
|
||||||
|
evidences: [
|
||||||
|
WildReportDetailEvidence(
|
||||||
|
fileType: 1,
|
||||||
|
fileTypeText: "图片",
|
||||||
|
fileURL: "https://example.com/report.jpg",
|
||||||
|
fileName: "report.jpg"
|
||||||
|
),
|
||||||
|
WildReportDetailEvidence(
|
||||||
|
fileType: 2,
|
||||||
|
fileTypeText: "视频",
|
||||||
|
fileURL: "https://example.com/report.mov",
|
||||||
|
fileName: "report.mov"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
paymentScreenshots: [
|
||||||
|
WildReportDetailEvidence(
|
||||||
|
fileType: 5,
|
||||||
|
fileTypeText: "支付截图",
|
||||||
|
fileURL: "https://example.com/payment.jpg",
|
||||||
|
fileName: "payment.jpg"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
timeline: [
|
||||||
|
WildReportDetailTimelineItem(time: "2026-07-08 16:06:59", content: "摄影师提交举报")
|
||||||
|
],
|
||||||
|
createdAt: "2026-07-08 16:06:59"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user