接入举报摄影师正式接口
This commit is contained in:
@ -24,6 +24,7 @@ final class NetworkServices {
|
|||||||
let travelAlbumAPI: TravelAlbumAPI
|
let travelAlbumAPI: TravelAlbumAPI
|
||||||
let sampleManagementAPI: SampleManagementAPI
|
let sampleManagementAPI: SampleManagementAPI
|
||||||
let liveAPI: LiveAPI
|
let liveAPI: LiveAPI
|
||||||
|
let wildPhotographerReportAPI: WildPhotographerReportAPI
|
||||||
let uploadAPI: UploadAPI
|
let uploadAPI: UploadAPI
|
||||||
let ossUploadService: OSSUploadService
|
let ossUploadService: OSSUploadService
|
||||||
|
|
||||||
@ -43,6 +44,7 @@ final class NetworkServices {
|
|||||||
travelAlbumAPI = TravelAlbumAPI(client: client)
|
travelAlbumAPI = TravelAlbumAPI(client: client)
|
||||||
sampleManagementAPI = SampleManagementAPI(client: client)
|
sampleManagementAPI = SampleManagementAPI(client: client)
|
||||||
liveAPI = LiveAPI(client: client)
|
liveAPI = LiveAPI(client: client)
|
||||||
|
wildPhotographerReportAPI = WildPhotographerReportAPI(client: client)
|
||||||
uploadAPI = UploadAPI(client: client)
|
uploadAPI = UploadAPI(client: client)
|
||||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||||
client.bindAuthTokenProvider {
|
client.bindAuthTokenProvider {
|
||||||
@ -67,6 +69,7 @@ final class NetworkServices {
|
|||||||
travelAlbumAPI = TravelAlbumAPI(client: apiClient)
|
travelAlbumAPI = TravelAlbumAPI(client: apiClient)
|
||||||
sampleManagementAPI = SampleManagementAPI(client: apiClient)
|
sampleManagementAPI = SampleManagementAPI(client: apiClient)
|
||||||
liveAPI = LiveAPI(client: apiClient)
|
liveAPI = LiveAPI(client: apiClient)
|
||||||
|
wildPhotographerReportAPI = WildPhotographerReportAPI(client: apiClient)
|
||||||
uploadAPI = UploadAPI(client: apiClient)
|
uploadAPI = UploadAPI(client: apiClient)
|
||||||
ossUploadService = OSSUploadService(configService: uploadAPI)
|
ossUploadService = OSSUploadService(configService: uploadAPI)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -144,6 +144,24 @@ final class OSSUploadService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 上传举报摄影师附件,对齐举报提交接口的证据与支付截图。
|
||||||
|
func uploadWildReportAttachment(
|
||||||
|
data: Data,
|
||||||
|
fileName: String,
|
||||||
|
fileType: Int,
|
||||||
|
scenicId: Int,
|
||||||
|
onProgress: @escaping (Int) -> Void
|
||||||
|
) async throws -> String {
|
||||||
|
try await uploadFile(
|
||||||
|
data: data,
|
||||||
|
fileName: fileName,
|
||||||
|
fileType: fileType,
|
||||||
|
scenicId: scenicId,
|
||||||
|
moduleType: "photog_report",
|
||||||
|
onProgress: onProgress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func uploadFile(
|
private func uploadFile(
|
||||||
data: Data,
|
data: Data,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
@ -244,6 +262,8 @@ enum OSSUploadPolicy {
|
|||||||
return "live_covers/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "live_covers/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
case "alive_album":
|
case "alive_album":
|
||||||
return "alive_album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "alive_album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
|
case "photog_report":
|
||||||
|
return "photog_report/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
default:
|
default:
|
||||||
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -167,7 +167,7 @@ struct PhotographerProfileInfoResponse: Decodable, Equatable {
|
|||||||
holidayEndTime = try container.decodeLossyString(forKey: .holidayEndTime)
|
holidayEndTime = try container.decodeLossyString(forKey: .holidayEndTime)
|
||||||
businessRange = try container.decodeLossyIntArray(forKey: .businessRange)
|
businessRange = try container.decodeLossyIntArray(forKey: .businessRange)
|
||||||
acceptOrderStatus = try container.decodeLossyInt(forKey: .acceptOrderStatus) ?? 0
|
acceptOrderStatus = try container.decodeLossyInt(forKey: .acceptOrderStatus) ?? 0
|
||||||
schedule = try container.decodeIfPresent([String: [PhotographerSchedule]].self, forKey: .schedule) ?? [:]
|
schedule = try container.decodeScheduleMap(forKey: .schedule)
|
||||||
virtualPhone = try container.decodeLossyString(forKey: .virtualPhone)
|
virtualPhone = try container.decodeLossyString(forKey: .virtualPhone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -612,4 +612,15 @@ private extension KeyedDecodingContainer {
|
|||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeScheduleMap(forKey key: Key) throws -> [String: [PhotographerSchedule]] {
|
||||||
|
if let values = try? decodeIfPresent([String: [PhotographerSchedule]].self, forKey: key) {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
if let values = try? decodeIfPresent([PhotographerSchedule].self, forKey: key) {
|
||||||
|
return Dictionary(grouping: values) { $0.scheduleDate }
|
||||||
|
.filter { !$0.key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
|
}
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,99 @@
|
|||||||
|
//
|
||||||
|
// WildPhotographerReportAPI.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 举报摄影师服务协议,封装举报类型等后端接口。
|
||||||
|
@MainActor
|
||||||
|
protocol WildPhotographerReportServing {
|
||||||
|
/// 拉取提交举报可选类型。
|
||||||
|
func reportTypes() async throws -> WildReportTypeListResponse
|
||||||
|
/// 提交摄影师举报。
|
||||||
|
func submitReport(_ request: WildReportSubmitRequest) async throws
|
||||||
|
/// 拉取我的举报列表。
|
||||||
|
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 举报摄影师 API,负责和 `photog/report` 相关接口通讯。
|
||||||
|
@MainActor
|
||||||
|
final class WildPhotographerReportAPI: WildPhotographerReportServing {
|
||||||
|
private let client: APIClient
|
||||||
|
|
||||||
|
/// 初始化举报摄影师 API。
|
||||||
|
init(client: APIClient) {
|
||||||
|
self.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取提交举报可选类型。
|
||||||
|
func reportTypes() async throws -> WildReportTypeListResponse {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(method: .get, path: "/api/yf-handset-app/photog/report/types")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交摄影师举报。
|
||||||
|
func submitReport(_ request: WildReportSubmitRequest) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: "/api/yf-handset-app/photog/report/submit",
|
||||||
|
body: request
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取我的举报列表。
|
||||||
|
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "/api/yf-handset-app/photog/report/list",
|
||||||
|
queryItems: request.queryItems
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交举报接口请求体。
|
||||||
|
struct WildReportSubmitRequest: Encodable, Equatable {
|
||||||
|
let scenicId: Int
|
||||||
|
let reportType: Int
|
||||||
|
let desc: String
|
||||||
|
let storeUserId: Int?
|
||||||
|
let contactPhone: String?
|
||||||
|
let locationName: String?
|
||||||
|
let locationAddress: String?
|
||||||
|
let lat: Double
|
||||||
|
let lng: Double
|
||||||
|
let evidences: [WildReportEvidenceRequest]
|
||||||
|
let paymentScreenshots: [WildReportEvidenceRequest]
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case scenicId = "scenic_id"
|
||||||
|
case reportType = "report_type"
|
||||||
|
case desc
|
||||||
|
case storeUserId = "store_user_id"
|
||||||
|
case contactPhone = "contact_phone"
|
||||||
|
case locationName = "location_name"
|
||||||
|
case locationAddress = "location_address"
|
||||||
|
case lat
|
||||||
|
case lng
|
||||||
|
case evidences
|
||||||
|
case paymentScreenshots = "payment_screenshots"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交举报接口中的附件项。
|
||||||
|
struct WildReportEvidenceRequest: Encodable, Equatable {
|
||||||
|
let fileURL: String
|
||||||
|
let fileType: Int
|
||||||
|
let fileName: String?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case fileURL = "file_url"
|
||||||
|
case fileType = "file_type"
|
||||||
|
case fileName = "file_name"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -32,31 +32,81 @@ enum WildReportAttachmentKind: Hashable {
|
|||||||
case payment
|
case payment
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 举报类型,用于提交页单选并写入 Mock 举报记录。
|
/// 举报类型接口返回的单项数据,由后端动态配置。
|
||||||
enum WildReportType: String, CaseIterable, Hashable {
|
struct WildReportType: Decodable, Hashable {
|
||||||
case suspectedWild = "疑似打野"
|
let value: Int
|
||||||
case noWorkBadge = "未戴工牌"
|
let label: String
|
||||||
case other = "其他"
|
|
||||||
|
|
||||||
var iconName: String {
|
static let defaultSelected = WildReportType(value: 2, label: "疑似打野")
|
||||||
switch self {
|
|
||||||
case .suspectedWild: return "person.crop.circle.badge.exclamationmark"
|
static let fallbackOptions = [
|
||||||
case .noWorkBadge: return "lanyardcard"
|
WildReportType(value: 1, label: "私下收款"),
|
||||||
case .other: return "ellipsis.circle"
|
WildReportType(value: 2, label: "疑似打野"),
|
||||||
|
WildReportType(value: 3, label: "未戴工牌"),
|
||||||
|
WildReportType(value: 4, label: "其他")
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 举报类型接口返回的列表包裹。
|
||||||
|
struct WildReportTypeListResponse: Decodable, Hashable {
|
||||||
|
let list: [WildReportType]
|
||||||
|
|
||||||
|
init(list: [WildReportType]) {
|
||||||
|
self.list = list
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 举报附件实体,表示一条演示用的上传项。
|
/// 举报附件实体,表示一条本地待上传或演示用的上传项。
|
||||||
struct WildReportAttachment: Hashable {
|
struct WildReportAttachment: Hashable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let kind: WildReportAttachmentKind
|
let kind: WildReportAttachmentKind
|
||||||
let title: String
|
let title: String
|
||||||
|
let localFileURL: URL?
|
||||||
|
let fileName: String
|
||||||
|
let fileType: Int
|
||||||
|
let uploadedURL: String?
|
||||||
|
|
||||||
init(id: UUID = UUID(), kind: WildReportAttachmentKind, title: String) {
|
init(
|
||||||
|
id: UUID = UUID(),
|
||||||
|
kind: WildReportAttachmentKind,
|
||||||
|
title: String,
|
||||||
|
localFileURL: URL? = nil,
|
||||||
|
fileName: String? = nil,
|
||||||
|
uploadedURL: String? = nil
|
||||||
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
self.title = title
|
self.title = title
|
||||||
|
self.localFileURL = localFileURL
|
||||||
|
self.fileName = fileName ?? "\(title).\(kind.defaultFileExtension)"
|
||||||
|
self.fileType = kind.reportFileType
|
||||||
|
self.uploadedURL = uploadedURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WildReportAttachment {
|
||||||
|
/// 读取待上传附件的本地文件数据。
|
||||||
|
func uploadData() throws -> Data {
|
||||||
|
guard let localFileURL else { throw OSSUploadError.emptyFile }
|
||||||
|
return try Data(contentsOf: localFileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WildReportAttachmentKind {
|
||||||
|
/// 提交举报接口的文件类型:1 图片,2 视频。
|
||||||
|
var reportFileType: Int {
|
||||||
|
switch self {
|
||||||
|
case .image, .payment: return 1
|
||||||
|
case .video: return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地附件默认扩展名。
|
||||||
|
var defaultFileExtension: String {
|
||||||
|
switch self {
|
||||||
|
case .image, .payment: return "jpg"
|
||||||
|
case .video: return "mp4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,6 +133,29 @@ enum WildReportStatus: String, Hashable, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension WildReportStatus {
|
||||||
|
/// 后端举报处理状态码:0 待处理、1 处理中、2 已处理。
|
||||||
|
init(handleStatus: Int, fallbackText: String = "") {
|
||||||
|
switch handleStatus {
|
||||||
|
case 0:
|
||||||
|
self = .pending
|
||||||
|
case 1:
|
||||||
|
self = .processing
|
||||||
|
case 2:
|
||||||
|
self = .processed
|
||||||
|
default:
|
||||||
|
switch fallbackText {
|
||||||
|
case WildReportStatus.processing.rawValue:
|
||||||
|
self = .processing
|
||||||
|
case WildReportStatus.processed.rawValue:
|
||||||
|
self = .processed
|
||||||
|
default:
|
||||||
|
self = .pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 我的举报列表筛选条件。
|
/// 我的举报列表筛选条件。
|
||||||
enum WildReportListFilter: String, CaseIterable, Hashable {
|
enum WildReportListFilter: String, CaseIterable, Hashable {
|
||||||
case all = "全部"
|
case all = "全部"
|
||||||
@ -91,6 +164,187 @@ enum WildReportListFilter: String, CaseIterable, Hashable {
|
|||||||
case processed = "已处理"
|
case processed = "已处理"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension WildReportListFilter {
|
||||||
|
/// 后端列表筛选状态码:全部不传,0 待处理、1 处理中、2 已处理。
|
||||||
|
var handleStatusValue: Int? {
|
||||||
|
switch self {
|
||||||
|
case .all: return nil
|
||||||
|
case .pending: return 0
|
||||||
|
case .processing: return 1
|
||||||
|
case .processed: return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 我的举报列表接口请求参数。
|
||||||
|
struct WildReportListRequest: Equatable {
|
||||||
|
let scenicId: Int?
|
||||||
|
let handleStatus: Int?
|
||||||
|
let page: Int
|
||||||
|
let pageSize: Int
|
||||||
|
|
||||||
|
init(
|
||||||
|
scenicId: Int?,
|
||||||
|
handleStatus: Int?,
|
||||||
|
page: Int = 1,
|
||||||
|
pageSize: Int = 10
|
||||||
|
) {
|
||||||
|
self.scenicId = scenicId
|
||||||
|
self.handleStatus = handleStatus
|
||||||
|
self.page = page
|
||||||
|
self.pageSize = pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
var queryItems: [URLQueryItem] {
|
||||||
|
var items: [URLQueryItem] = []
|
||||||
|
if let scenicId, scenicId > 0 {
|
||||||
|
items.append(URLQueryItem(name: "scenic_id", value: String(scenicId)))
|
||||||
|
}
|
||||||
|
if let handleStatus {
|
||||||
|
items.append(URLQueryItem(name: "handle_status", value: String(handleStatus)))
|
||||||
|
}
|
||||||
|
items.append(URLQueryItem(name: "page", value: String(max(1, page))))
|
||||||
|
items.append(URLQueryItem(name: "page_size", value: String(min(max(1, pageSize), 50))))
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 我的举报列表接口响应体。
|
||||||
|
struct WildReportListResponse: Decodable, Equatable {
|
||||||
|
let list: [WildReportListItem]
|
||||||
|
let total: Int
|
||||||
|
let page: Int
|
||||||
|
let pageSize: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case list
|
||||||
|
case total
|
||||||
|
case page
|
||||||
|
case pageSize = "page_size"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(list: [WildReportListItem], total: Int, page: Int, pageSize: Int) {
|
||||||
|
self.list = list
|
||||||
|
self.total = total
|
||||||
|
self.page = page
|
||||||
|
self.pageSize = pageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 我的举报列表接口单条举报数据。
|
||||||
|
struct WildReportListItem: Decodable, Equatable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
let complaintNo: String
|
||||||
|
let title: String
|
||||||
|
let reportType: Int
|
||||||
|
let reportTypeText: String
|
||||||
|
let desc: String
|
||||||
|
let scenicId: Int
|
||||||
|
let scenicName: String
|
||||||
|
let locationName: String
|
||||||
|
let latitude: String
|
||||||
|
let longitude: String
|
||||||
|
let handleStatus: Int
|
||||||
|
let handleStatusText: String
|
||||||
|
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 scenicId = "scenic_id"
|
||||||
|
case scenicName = "scenic_name"
|
||||||
|
case locationName = "location_name"
|
||||||
|
case latitude
|
||||||
|
case longitude
|
||||||
|
case handleStatus = "handle_status"
|
||||||
|
case handleStatusText = "handle_status_text"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: Int,
|
||||||
|
complaintNo: String,
|
||||||
|
title: String,
|
||||||
|
reportType: Int,
|
||||||
|
reportTypeText: String,
|
||||||
|
desc: String,
|
||||||
|
scenicId: Int,
|
||||||
|
scenicName: String,
|
||||||
|
locationName: String,
|
||||||
|
latitude: String,
|
||||||
|
longitude: String,
|
||||||
|
handleStatus: Int,
|
||||||
|
handleStatusText: String,
|
||||||
|
createdAt: String
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.complaintNo = complaintNo
|
||||||
|
self.title = title
|
||||||
|
self.reportType = reportType
|
||||||
|
self.reportTypeText = reportTypeText
|
||||||
|
self.desc = desc
|
||||||
|
self.scenicId = scenicId
|
||||||
|
self.scenicName = scenicName
|
||||||
|
self.locationName = locationName
|
||||||
|
self.latitude = latitude
|
||||||
|
self.longitude = longitude
|
||||||
|
self.handleStatus = handleStatus
|
||||||
|
self.handleStatusText = handleStatusText
|
||||||
|
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) ?? ""
|
||||||
|
scenicId = try container.decodeIfPresent(Int.self, forKey: .scenicId) ?? 0
|
||||||
|
scenicName = try container.decodeIfPresent(String.self, forKey: .scenicName) ?? ""
|
||||||
|
locationName = try container.decodeIfPresent(String.self, forKey: .locationName) ?? ""
|
||||||
|
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) ?? ""
|
||||||
|
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 转换为当前 UIKit 列表和详情页复用的举报记录实体。
|
||||||
|
func toRecord() -> WildReportRecord {
|
||||||
|
let scenic = scenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let location = locationName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let displayLocation: String
|
||||||
|
if !scenic.isEmpty, !location.isEmpty, scenic != location {
|
||||||
|
displayLocation = "\(scenic) · \(location)"
|
||||||
|
} else if !location.isEmpty {
|
||||||
|
displayLocation = location
|
||||||
|
} else {
|
||||||
|
displayLocation = scenic
|
||||||
|
}
|
||||||
|
|
||||||
|
return WildReportRecord(
|
||||||
|
id: complaintNo,
|
||||||
|
title: title.isEmpty ? "\(reportTypeText)举报" : title,
|
||||||
|
reportType: WildReportType(value: reportType, label: reportTypeText.isEmpty ? "其他" : reportTypeText),
|
||||||
|
status: WildReportStatus(handleStatus: handleStatus, fallbackText: handleStatusText),
|
||||||
|
location: displayLocation,
|
||||||
|
submitTime: createdAt,
|
||||||
|
description: desc,
|
||||||
|
wechatID: "",
|
||||||
|
phoneNumber: "",
|
||||||
|
imageCount: 0,
|
||||||
|
videoCount: 0,
|
||||||
|
handlerInfo: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 补充证据记录实体。
|
/// 补充证据记录实体。
|
||||||
struct WildReportSupplementaryEvidence: Hashable {
|
struct WildReportSupplementaryEvidence: Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
|
|||||||
@ -56,7 +56,7 @@ enum WildPhotographerReportMockStore {
|
|||||||
WildReportRecord(
|
WildReportRecord(
|
||||||
id: "JB20260618001",
|
id: "JB20260618001",
|
||||||
title: "摄影师举报",
|
title: "摄影师举报",
|
||||||
reportType: .suspectedWild,
|
reportType: WildReportType(value: 2, label: "疑似打野"),
|
||||||
status: .processing,
|
status: .processing,
|
||||||
location: "那拉提景区 · 空中草原入口附近",
|
location: "那拉提景区 · 空中草原入口附近",
|
||||||
submitTime: "2026-06-18 10:24",
|
submitTime: "2026-06-18 10:24",
|
||||||
@ -70,7 +70,7 @@ enum WildPhotographerReportMockStore {
|
|||||||
WildReportRecord(
|
WildReportRecord(
|
||||||
id: "JB20260617008",
|
id: "JB20260617008",
|
||||||
title: "摄影师举报",
|
title: "摄影师举报",
|
||||||
reportType: .suspectedWild,
|
reportType: WildReportType(value: 2, label: "疑似打野"),
|
||||||
status: .processed,
|
status: .processed,
|
||||||
location: "那拉提景区 · 河谷草原游客中心",
|
location: "那拉提景区 · 河谷草原游客中心",
|
||||||
submitTime: "2026-06-17 16:42",
|
submitTime: "2026-06-17 16:42",
|
||||||
@ -91,7 +91,7 @@ enum WildPhotographerReportMockStore {
|
|||||||
WildReportRecord(
|
WildReportRecord(
|
||||||
id: "JB20260616003",
|
id: "JB20260616003",
|
||||||
title: "摄影师举报",
|
title: "摄影师举报",
|
||||||
reportType: .noWorkBadge,
|
reportType: WildReportType(value: 3, label: "未戴工牌"),
|
||||||
status: .pending,
|
status: .pending,
|
||||||
location: "那拉提景区 · 盘龙古道停车区",
|
location: "那拉提景区 · 盘龙古道停车区",
|
||||||
submitTime: "2026-06-16 09:15",
|
submitTime: "2026-06-16 09:15",
|
||||||
@ -105,7 +105,7 @@ enum WildPhotographerReportMockStore {
|
|||||||
WildReportRecord(
|
WildReportRecord(
|
||||||
id: "JB20260615002",
|
id: "JB20260615002",
|
||||||
title: "摄影师举报",
|
title: "摄影师举报",
|
||||||
reportType: .other,
|
reportType: WildReportType(value: 4, label: "其他"),
|
||||||
status: .processed,
|
status: .processed,
|
||||||
location: "那拉提景区 · 雪莲谷步道",
|
location: "那拉提景区 · 雪莲谷步道",
|
||||||
submitTime: "2026-06-15 14:30",
|
submitTime: "2026-06-15 14:30",
|
||||||
|
|||||||
@ -7,6 +7,21 @@ import Foundation
|
|||||||
import MapKit
|
import MapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
|
/// 举报摄影师附件上传协议,便于提交页注入 OSS 服务与单元测试替身。
|
||||||
|
@MainActor
|
||||||
|
protocol WildReportAttachmentUploading {
|
||||||
|
/// 上传举报附件并返回 OSS 文件 URL。
|
||||||
|
func uploadWildReportAttachment(
|
||||||
|
data: Data,
|
||||||
|
fileName: String,
|
||||||
|
fileType: Int,
|
||||||
|
scenicId: Int,
|
||||||
|
onProgress: @escaping (Int) -> Void
|
||||||
|
) async throws -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
extension OSSUploadService: WildReportAttachmentUploading {}
|
||||||
|
|
||||||
/// 举报摄影师首页 ViewModel,持有 Mock 举报数据并供子页面共享。
|
/// 举报摄影师首页 ViewModel,持有 Mock 举报数据并供子页面共享。
|
||||||
final class WildPhotographerReportHomeViewModel {
|
final class WildPhotographerReportHomeViewModel {
|
||||||
private(set) var pageState: WildReportPageState = .normal
|
private(set) var pageState: WildReportPageState = .normal
|
||||||
@ -138,11 +153,15 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
private(set) var images: [WildReportAttachment]
|
private(set) var images: [WildReportAttachment]
|
||||||
private(set) var videos: [WildReportAttachment]
|
private(set) var videos: [WildReportAttachment]
|
||||||
private(set) var paymentImages: [WildReportAttachment] = []
|
private(set) var paymentImages: [WildReportAttachment] = []
|
||||||
private(set) var selectedReportType: WildReportType = .suspectedWild
|
private(set) var reportTypes: [WildReportType] = WildReportType.fallbackOptions
|
||||||
|
private(set) var selectedReportType: WildReportType = .defaultSelected
|
||||||
private(set) var description = ""
|
private(set) var description = ""
|
||||||
private(set) var contact = ""
|
private(set) var contact = ""
|
||||||
private(set) var reportLocation = ""
|
private(set) var reportLocation = ""
|
||||||
|
private(set) var isLoadingReportTypes = false
|
||||||
private(set) var isUpdatingLocation = false
|
private(set) var isUpdatingLocation = false
|
||||||
|
private(set) var isSubmitting = false
|
||||||
|
private(set) var submitProgressText = ""
|
||||||
private(set) var locationConfirmed = false
|
private(set) var locationConfirmed = false
|
||||||
private(set) var submitResult: WildReportSubmitResult?
|
private(set) var submitResult: WildReportSubmitResult?
|
||||||
private(set) var validationMessage: String?
|
private(set) var validationMessage: String?
|
||||||
@ -154,6 +173,7 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
private let homeViewModel: WildPhotographerReportHomeViewModel
|
private let homeViewModel: WildPhotographerReportHomeViewModel
|
||||||
private let locationProvider: any LocationProviding
|
private let locationProvider: any LocationProviding
|
||||||
private var hasRequestedInitialLocation = false
|
private var hasRequestedInitialLocation = false
|
||||||
|
private var locationSnapshot: HomeLocationSnapshot?
|
||||||
|
|
||||||
var locationTitleText: String {
|
var locationTitleText: String {
|
||||||
if reportLocation.isEmpty || isUpdatingLocation {
|
if reportLocation.isEmpty || isUpdatingLocation {
|
||||||
@ -178,8 +198,8 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
) {
|
) {
|
||||||
self.homeViewModel = homeViewModel
|
self.homeViewModel = homeViewModel
|
||||||
self.locationProvider = locationProvider
|
self.locationProvider = locationProvider
|
||||||
self.images = WildPhotographerReportMockStore.initialImages
|
self.images = []
|
||||||
self.videos = WildPhotographerReportMockStore.initialVideos
|
self.videos = []
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 首次进入页面时自动请求定位。
|
/// 首次进入页面时自动请求定位。
|
||||||
@ -189,6 +209,38 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
refreshCurrentLocation()
|
refreshCurrentLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 从服务端加载举报类型,失败时回退到本地兜底选项。
|
||||||
|
func loadReportTypes(api: any WildPhotographerReportServing) async {
|
||||||
|
guard !isLoadingReportTypes else { return }
|
||||||
|
isLoadingReportTypes = true
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isLoadingReportTypes = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await api.reportTypes()
|
||||||
|
let loadedTypes = uniqueReportTypes(response.list)
|
||||||
|
reportTypes = loadedTypes.isEmpty ? WildReportType.fallbackOptions : loadedTypes
|
||||||
|
if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) {
|
||||||
|
selectedReportType = updatedType
|
||||||
|
} else {
|
||||||
|
selectedReportType = reportTypes.first ?? .defaultSelected
|
||||||
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
reportTypes = WildReportType.fallbackOptions
|
||||||
|
if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) {
|
||||||
|
selectedReportType = updatedType
|
||||||
|
} else {
|
||||||
|
selectedReportType = .defaultSelected
|
||||||
|
}
|
||||||
|
onShowMessage?("举报类型获取失败,已使用默认类型")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 选择举报类型。
|
/// 选择举报类型。
|
||||||
func selectReportType(_ type: WildReportType) {
|
func selectReportType(_ type: WildReportType) {
|
||||||
selectedReportType = type
|
selectedReportType = type
|
||||||
@ -207,26 +259,53 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 添加本地现场图片。
|
||||||
|
func addImages(_ attachments: [WildReportAttachment]) {
|
||||||
|
let remaining = max(0, 9 - evidenceCount)
|
||||||
|
guard remaining > 0 else {
|
||||||
|
onShowMessage?("现场证据最多上传9项")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
images.append(contentsOf: attachments.prefix(remaining))
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
/// 添加演示用现场图片。
|
/// 添加演示用现场图片。
|
||||||
func addImage() {
|
func addImage() {
|
||||||
images.append(WildReportAttachment(kind: .image, title: "现场图片\(images.count + 1)"))
|
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() {
|
||||||
videos.append(WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)"))
|
addVideos([WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)")])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加本地支付截图。
|
||||||
|
func addPaymentImages(_ attachments: [WildReportAttachment]) {
|
||||||
|
guard paymentImages.count < 3 else {
|
||||||
|
onShowMessage?("支付截图最多上传3张")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
paymentImages.append(contentsOf: attachments.prefix(3 - paymentImages.count))
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加演示用支付截图。
|
/// 添加演示用支付截图。
|
||||||
func addPaymentImage() {
|
func addPaymentImage() {
|
||||||
guard paymentImages.count < 3 else {
|
addPaymentImages([WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)")])
|
||||||
onShowMessage?("支付截图最多上传3张")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
paymentImages.append(WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)"))
|
|
||||||
notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除指定附件。
|
/// 删除指定附件。
|
||||||
@ -248,12 +327,13 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let snapshot = try await locationProvider.requestSnapshot()
|
let snapshot = try await locationProvider.requestSnapshot()
|
||||||
let address = snapshot.address.isEmpty
|
applyResolvedLocation(snapshot)
|
||||||
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
|
|
||||||
: snapshot.address
|
|
||||||
applyResolvedLocation(address)
|
|
||||||
} catch {
|
} catch {
|
||||||
applyResolvedLocation(WildPhotographerReportMockStore.fallbackLocation)
|
applyResolvedLocation(HomeLocationSnapshot(
|
||||||
|
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
|
||||||
|
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
|
||||||
|
address: WildPhotographerReportMockStore.fallbackLocation
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -264,25 +344,78 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 校验表单并提交举报。
|
/// 校验表单并提交举报到后端。
|
||||||
|
func submitReport(
|
||||||
|
api: any WildPhotographerReportServing,
|
||||||
|
uploader: any WildReportAttachmentUploading,
|
||||||
|
scenicId: Int,
|
||||||
|
scenicName: String
|
||||||
|
) async {
|
||||||
|
validationMessage = nil
|
||||||
|
guard validateBeforeSubmit(scenicId: scenicId) else { return }
|
||||||
|
guard !isSubmitting else { return }
|
||||||
|
|
||||||
|
isSubmitting = true
|
||||||
|
submitProgressText = "正在上传证据"
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isSubmitting = false
|
||||||
|
submitProgressText = ""
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let evidenceRequests = try await uploadAttachments(
|
||||||
|
images + videos,
|
||||||
|
uploader: uploader,
|
||||||
|
scenicId: scenicId,
|
||||||
|
progressPrefix: "正在上传现场证据"
|
||||||
|
)
|
||||||
|
let paymentRequests = try await uploadAttachments(
|
||||||
|
paymentImages,
|
||||||
|
uploader: uploader,
|
||||||
|
scenicId: scenicId,
|
||||||
|
progressPrefix: "正在上传支付截图",
|
||||||
|
completedOffset: evidenceRequests.count,
|
||||||
|
totalOverride: evidenceRequests.count + paymentImages.count
|
||||||
|
)
|
||||||
|
submitProgressText = "正在提交举报"
|
||||||
|
notifyStateChange()
|
||||||
|
|
||||||
|
try await api.submitReport(buildSubmitRequest(
|
||||||
|
scenicId: scenicId,
|
||||||
|
scenicName: scenicName,
|
||||||
|
evidences: evidenceRequests,
|
||||||
|
paymentScreenshots: paymentRequests
|
||||||
|
))
|
||||||
|
|
||||||
|
let record = homeViewModel.submitReport(
|
||||||
|
reportType: selectedReportType,
|
||||||
|
description: description,
|
||||||
|
location: reportLocation,
|
||||||
|
contact: contact.isEmpty ? nil : contact,
|
||||||
|
imageCount: images.count,
|
||||||
|
videoCount: videos.count
|
||||||
|
)
|
||||||
|
let result = WildReportSubmitResult(
|
||||||
|
record: record,
|
||||||
|
imageCount: images.count,
|
||||||
|
videoCount: videos.count
|
||||||
|
)
|
||||||
|
submitResult = result
|
||||||
|
notifyStateChange()
|
||||||
|
onSubmitSuccess?(result)
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
setValidationMessage(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验表单并写入本地 Mock 记录。
|
||||||
func submitReport() {
|
func submitReport() {
|
||||||
validationMessage = nil
|
validationMessage = nil
|
||||||
guard !images.isEmpty || !videos.isEmpty else {
|
guard validateBeforeSubmit(scenicId: 1) else { return }
|
||||||
setValidationMessage("请至少上传一项现场证据。")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
||||||
setValidationMessage("请填写举报说明。")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard !isUpdatingLocation else {
|
|
||||||
setValidationMessage("正在获取定位,请稍后再提交。")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else {
|
|
||||||
setValidationMessage("请先更新举报位置后再提交举报。")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let record = homeViewModel.submitReport(
|
let record = homeViewModel.submitReport(
|
||||||
reportType: selectedReportType,
|
reportType: selectedReportType,
|
||||||
@ -304,15 +437,19 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
|
|
||||||
/// 重置表单到初始状态。
|
/// 重置表单到初始状态。
|
||||||
func resetForm() {
|
func resetForm() {
|
||||||
images = WildPhotographerReportMockStore.initialImages
|
images = []
|
||||||
videos = WildPhotographerReportMockStore.initialVideos
|
videos = []
|
||||||
paymentImages = []
|
paymentImages = []
|
||||||
selectedReportType = .suspectedWild
|
reportTypes = WildReportType.fallbackOptions
|
||||||
|
selectedReportType = .defaultSelected
|
||||||
description = ""
|
description = ""
|
||||||
contact = ""
|
contact = ""
|
||||||
reportLocation = ""
|
reportLocation = ""
|
||||||
locationConfirmed = false
|
locationConfirmed = false
|
||||||
isUpdatingLocation = false
|
isUpdatingLocation = false
|
||||||
|
isSubmitting = false
|
||||||
|
submitProgressText = ""
|
||||||
|
locationSnapshot = nil
|
||||||
hasRequestedInitialLocation = false
|
hasRequestedInitialLocation = false
|
||||||
submitResult = nil
|
submitResult = nil
|
||||||
validationMessage = nil
|
validationMessage = nil
|
||||||
@ -320,7 +457,23 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyResolvedLocation(_ location: String) {
|
func applyResolvedLocation(_ location: String) {
|
||||||
reportLocation = location
|
applyResolvedLocation(HomeLocationSnapshot(
|
||||||
|
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
|
||||||
|
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
|
||||||
|
address: location
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyResolvedLocation(_ snapshot: HomeLocationSnapshot) {
|
||||||
|
let address = snapshot.address.isEmpty
|
||||||
|
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
|
||||||
|
: snapshot.address
|
||||||
|
locationSnapshot = HomeLocationSnapshot(
|
||||||
|
latitude: snapshot.latitude,
|
||||||
|
longitude: snapshot.longitude,
|
||||||
|
address: address
|
||||||
|
)
|
||||||
|
reportLocation = address
|
||||||
locationConfirmed = true
|
locationConfirmed = true
|
||||||
isUpdatingLocation = false
|
isUpdatingLocation = false
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
@ -335,6 +488,110 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
private func notifyStateChange() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func uniqueReportTypes(_ types: [WildReportType]) -> [WildReportType] {
|
||||||
|
var seen: Set<Int> = []
|
||||||
|
return types.filter { seen.insert($0.value).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var evidenceCount: Int {
|
||||||
|
images.count + videos.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validateBeforeSubmit(scenicId: Int) -> Bool {
|
||||||
|
guard scenicId > 0 else {
|
||||||
|
setValidationMessage("请先选择景区后再提交举报。")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard !images.isEmpty || !videos.isEmpty else {
|
||||||
|
setValidationMessage("请至少上传一项现场证据。")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard evidenceCount <= 9 else {
|
||||||
|
setValidationMessage("现场证据最多上传9项。")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard paymentImages.count <= 3 else {
|
||||||
|
setValidationMessage("支付截图最多上传3张。")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
setValidationMessage("请填写举报说明。")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard !isUpdatingLocation else {
|
||||||
|
setValidationMessage("正在获取定位,请稍后再提交。")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else {
|
||||||
|
setValidationMessage("请先更新举报位置后再提交举报。")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadAttachments(
|
||||||
|
_ attachments: [WildReportAttachment],
|
||||||
|
uploader: any WildReportAttachmentUploading,
|
||||||
|
scenicId: Int,
|
||||||
|
progressPrefix: String,
|
||||||
|
completedOffset: Int = 0,
|
||||||
|
totalOverride: Int? = nil
|
||||||
|
) async throws -> [WildReportEvidenceRequest] {
|
||||||
|
var requests: [WildReportEvidenceRequest] = []
|
||||||
|
let total = totalOverride ?? attachments.count
|
||||||
|
for (index, attachment) in attachments.enumerated() {
|
||||||
|
let current = completedOffset + index + 1
|
||||||
|
submitProgressText = "\(progressPrefix) \(current)/\(max(total, current))"
|
||||||
|
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 = "\(progressPrefix) \(current)/\(max(total, current)) · \(progress)%"
|
||||||
|
self?.notifyStateChange()
|
||||||
|
}
|
||||||
|
requests.append(WildReportEvidenceRequest(
|
||||||
|
fileURL: url,
|
||||||
|
fileType: attachment.fileType,
|
||||||
|
fileName: attachment.fileName
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return requests
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildSubmitRequest(
|
||||||
|
scenicId: Int,
|
||||||
|
scenicName: String,
|
||||||
|
evidences: [WildReportEvidenceRequest],
|
||||||
|
paymentScreenshots: [WildReportEvidenceRequest]
|
||||||
|
) -> WildReportSubmitRequest {
|
||||||
|
let locationParts = WildReportLocationParser.split(reportLocation)
|
||||||
|
let trimmedScenicName = scenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let locationName = !trimmedScenicName.isEmpty ? trimmedScenicName : locationParts.scenic
|
||||||
|
let snapshot = locationSnapshot ?? HomeLocationSnapshot(
|
||||||
|
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
|
||||||
|
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
|
||||||
|
address: reportLocation
|
||||||
|
)
|
||||||
|
let trimmedContact = contact.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return WildReportSubmitRequest(
|
||||||
|
scenicId: scenicId,
|
||||||
|
reportType: selectedReportType.value,
|
||||||
|
desc: description.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
storeUserId: nil,
|
||||||
|
contactPhone: trimmedContact.isEmpty ? nil : trimmedContact,
|
||||||
|
locationName: locationName.isEmpty ? nil : locationName,
|
||||||
|
locationAddress: locationParts.detail.isEmpty ? reportLocation : locationParts.detail,
|
||||||
|
lat: snapshot.latitude,
|
||||||
|
lng: snapshot.longitude,
|
||||||
|
evidences: evidences,
|
||||||
|
paymentScreenshots: paymentScreenshots
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 举报成功页 ViewModel。
|
/// 举报成功页 ViewModel。
|
||||||
@ -480,8 +737,17 @@ final class WildPhotographerReportDetailViewModel {
|
|||||||
|
|
||||||
/// 我的举报列表 ViewModel。
|
/// 我的举报列表 ViewModel。
|
||||||
final class WildPhotographerReportListViewModel {
|
final class WildPhotographerReportListViewModel {
|
||||||
|
private static let pageSize = 10
|
||||||
|
|
||||||
private(set) var selectedFilter: WildReportListFilter = .all
|
private(set) var selectedFilter: WildReportListFilter = .all
|
||||||
private(set) var shareMessage: String?
|
private(set) var shareMessage: String?
|
||||||
|
private(set) var reports: [WildReportRecord] = []
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var isLoadingMore = false
|
||||||
|
private(set) var hasMore = true
|
||||||
|
private(set) var page = 1
|
||||||
|
private(set) var total = 0
|
||||||
|
private(set) var errorMessage: String?
|
||||||
|
|
||||||
let homeViewModel: WildPhotographerReportHomeViewModel
|
let homeViewModel: WildPhotographerReportHomeViewModel
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
@ -492,23 +758,63 @@ final class WildPhotographerReportListViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var filteredReports: [WildReportRecord] {
|
var filteredReports: [WildReportRecord] {
|
||||||
let reports = homeViewModel.reports
|
reports
|
||||||
switch selectedFilter {
|
|
||||||
case .all: return reports
|
|
||||||
case .pending: return reports.filter { $0.status == .pending }
|
|
||||||
case .processing: return reports.filter { $0.status == .processing }
|
|
||||||
case .processed: return reports.filter { $0.status == .processed }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var footerTip: String { "温馨提示:为保障处理效率,请如实举报并提供有效证据" }
|
var footerTip: String { "温馨提示:为保障处理效率,请如实举报并提供有效证据" }
|
||||||
|
|
||||||
/// 切换列表筛选标签。
|
var footerDisplayText: String {
|
||||||
|
if isLoadingMore { return "正在加载更多..." }
|
||||||
|
if isLoading && reports.isEmpty { return "正在加载举报记录..." }
|
||||||
|
if !hasMore && !reports.isEmpty { return footerTip }
|
||||||
|
return footerTip
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 首次加载我的举报列表。
|
||||||
|
func loadInitial(api: any WildPhotographerReportServing, scenicId: Int?) async {
|
||||||
|
await reload(api: api, scenicId: scenicId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换列表筛选标签并重新拉取第一页。
|
||||||
|
func selectFilter(_ filter: WildReportListFilter, api: any WildPhotographerReportServing, scenicId: Int?) async {
|
||||||
|
guard selectedFilter != filter else { return }
|
||||||
|
selectedFilter = filter
|
||||||
|
await reload(api: api, scenicId: scenicId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保留本地筛选切换能力,供旧单元测试和 Mock 场景使用。
|
||||||
func selectFilter(_ filter: WildReportListFilter) {
|
func selectFilter(_ filter: WildReportListFilter) {
|
||||||
selectedFilter = filter
|
selectedFilter = filter
|
||||||
|
reports = filterLocalReports(homeViewModel.reports)
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 滚动到底部时加载下一页。
|
||||||
|
func loadMoreIfNeeded(api: any WildPhotographerReportServing, scenicId: Int?) async {
|
||||||
|
guard hasMore, !isLoading, !isLoadingMore else { return }
|
||||||
|
let nextPage = page + 1
|
||||||
|
isLoadingMore = true
|
||||||
|
errorMessage = nil
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isLoadingMore = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let response = try await api.reportList(makeRequest(page: nextPage, scenicId: scenicId))
|
||||||
|
let newRecords = response.list.map { $0.toRecord() }
|
||||||
|
reports.append(contentsOf: newRecords)
|
||||||
|
page = response.page
|
||||||
|
total = response.total
|
||||||
|
hasMore = reports.count < response.total
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 生成分享提示文案。
|
/// 生成分享提示文案。
|
||||||
func shareReport(_ record: WildReportRecord) {
|
func shareReport(_ record: WildReportRecord) {
|
||||||
shareMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。"
|
shareMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。"
|
||||||
@ -516,6 +822,55 @@ final class WildPhotographerReportListViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func reload(api: any WildPhotographerReportServing, scenicId: Int?) async {
|
||||||
|
let hadReportsBeforeReload = !reports.isEmpty
|
||||||
|
isLoading = true
|
||||||
|
isLoadingMore = false
|
||||||
|
errorMessage = nil
|
||||||
|
page = 1
|
||||||
|
hasMore = true
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let response = try await api.reportList(makeRequest(page: 1, scenicId: scenicId))
|
||||||
|
reports = response.list.map { $0.toRecord() }
|
||||||
|
page = response.page
|
||||||
|
total = response.total
|
||||||
|
hasMore = reports.count < response.total
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
hasMore = false
|
||||||
|
if !hadReportsBeforeReload {
|
||||||
|
reports = []
|
||||||
|
total = 0
|
||||||
|
}
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRequest(page: Int, scenicId: Int?) -> WildReportListRequest {
|
||||||
|
WildReportListRequest(
|
||||||
|
scenicId: scenicId,
|
||||||
|
handleStatus: selectedFilter.handleStatusValue,
|
||||||
|
page: page,
|
||||||
|
pageSize: Self.pageSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filterLocalReports(_ localReports: [WildReportRecord]) -> [WildReportRecord] {
|
||||||
|
switch selectedFilter {
|
||||||
|
case .all: return localReports
|
||||||
|
case .pending: return localReports.filter { $0.status == .pending }
|
||||||
|
case .processing: return localReports.filter { $0.status == .processing }
|
||||||
|
case .processed: return localReports.filter { $0.status == .processed }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func notifyStateChange() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
|
|||||||
290
suixinkan/Features/WildPhotographerReport/举报摄影师接口.md
Normal file
290
suixinkan/Features/WildPhotographerReport/举报摄影师接口.md
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
# 举报摄影师接口文档
|
||||||
|
|
||||||
|
本文档记录 iOS `WildPhotographerReport` 模块当前使用的后端接口,以及仍处于 Mock 阶段的业务点。
|
||||||
|
|
||||||
|
## 基础约定
|
||||||
|
|
||||||
|
- 业务域:举报摄影师
|
||||||
|
- API 封装:`suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift`
|
||||||
|
- 网络入口:`NetworkServices.shared.wildPhotographerReportAPI`
|
||||||
|
- 后端成功码:`100000`
|
||||||
|
- 响应包裹:工程统一 `APIEnvelope<T>`
|
||||||
|
- 当前状态:“举报类型”、“提交举报”和“我的举报列表”已接接口;详情、补充证据、风险地图仍使用 Mock 数据闭环。
|
||||||
|
|
||||||
|
## 1. 获取举报类型
|
||||||
|
|
||||||
|
### 用途
|
||||||
|
|
||||||
|
提交举报页面进入时拉取可选举报类型。举报类型由后台动态维护,客户端不能用枚举写死,也不展示图标。
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/report/types
|
||||||
|
```
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
|
||||||
|
无。
|
||||||
|
|
||||||
|
### 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"value": 1,
|
||||||
|
"label": "私下收款"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 2,
|
||||||
|
"label": "疑似打野"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 3,
|
||||||
|
"label": "未戴工牌"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": 4,
|
||||||
|
"label": "其他"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": "2026-07-08 14:21:21"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### iOS 模型
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct WildReportType: Decodable, Hashable {
|
||||||
|
let value: Int
|
||||||
|
let label: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WildReportTypeListResponse: Decodable, Hashable {
|
||||||
|
let list: [WildReportType]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI 行为
|
||||||
|
|
||||||
|
- 页面加载时调用 `WildPhotographerReportSubmitViewModel.loadReportTypes(api:)`。
|
||||||
|
- 按接口返回顺序渲染举报类型。
|
||||||
|
- 每行最多展示 3 个类型,超过 3 个自动换行。
|
||||||
|
- 按 `label` 展示文字,不展示图标。
|
||||||
|
- 默认选中 `value == 2` 的类型;如果接口返回中不存在该类型,则选中列表第一项。
|
||||||
|
- 接口失败时回退本地兜底列表,并提示“举报类型获取失败,已使用默认类型”。
|
||||||
|
|
||||||
|
### 当前调用位置
|
||||||
|
|
||||||
|
- `WildPhotographerReportSubmitViewController.viewDidLoad()`
|
||||||
|
- `WildPhotographerReportSubmitViewModel.loadReportTypes(api:)`
|
||||||
|
- `WildPhotographerReportAPI.reportTypes()`
|
||||||
|
|
||||||
|
## 2. 提交举报
|
||||||
|
|
||||||
|
### 用途
|
||||||
|
|
||||||
|
提交页点击“提交举报”时调用。客户端会先把现场证据和支付截图上传到 OSS,全部上传成功后再提交表单数据。
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/yf-handset-app/photog/report/submit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scenic_id": 1,
|
||||||
|
"report_type": 2,
|
||||||
|
"desc": "疑似人员在观景台附近主动揽客,并引导游客线下转账。",
|
||||||
|
"contact_phone": "13800138000",
|
||||||
|
"location_name": "黄山风景区",
|
||||||
|
"location_address": "光明顶景区",
|
||||||
|
"lat": 30.12345,
|
||||||
|
"lng": 120.12345,
|
||||||
|
"evidences": [
|
||||||
|
{
|
||||||
|
"file_url": "https://example.com/photog_report/20260708/1/report_image.jpg",
|
||||||
|
"file_type": 1,
|
||||||
|
"file_name": "report_image.jpg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file_url": "https://example.com/photog_report/20260708/1/report_video.mp4",
|
||||||
|
"file_type": 2,
|
||||||
|
"file_name": "report_video.mp4"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"payment_screenshots": [
|
||||||
|
{
|
||||||
|
"file_url": "https://example.com/photog_report/20260708/1/payment.jpg",
|
||||||
|
"file_type": 1,
|
||||||
|
"file_name": "payment.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段说明
|
||||||
|
|
||||||
|
- `scenic_id`:当前景区 ID,来自 `AppStore.shared.currentScenicId`,必须大于 0。
|
||||||
|
- `report_type`:举报类型接口返回的 `value`。
|
||||||
|
- `desc`:举报说明,必填,最多 500 字。
|
||||||
|
- `store_user_id`:被举报摄影师 ID;当前页面无来源,本轮暂不传。
|
||||||
|
- `contact_phone`:当前联系方式输入值,非空才传。
|
||||||
|
- `location_name` / `location_address`:从当前定位展示文案拆分;无法拆分时使用景区名和完整地址兜底。
|
||||||
|
- `lat` / `lng`:当前定位快照坐标;定位失败时使用 Mock 地址对应的兜底坐标。
|
||||||
|
- `evidences`:现场证据,必填,至少 1 项、最多 9 项。
|
||||||
|
- `payment_screenshots`:线下微信、支付宝截图,选填,最多 3 张。
|
||||||
|
- `file_type`:举报提交接口约定为 `1 = 图片`,`2 = 视频`。
|
||||||
|
|
||||||
|
### iOS 模型
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct WildReportSubmitRequest: Encodable, Equatable {
|
||||||
|
let scenicId: Int
|
||||||
|
let reportType: Int
|
||||||
|
let desc: String
|
||||||
|
let storeUserId: Int?
|
||||||
|
let contactPhone: String?
|
||||||
|
let locationName: String?
|
||||||
|
let locationAddress: String?
|
||||||
|
let lat: Double
|
||||||
|
let lng: Double
|
||||||
|
let evidences: [WildReportEvidenceRequest]
|
||||||
|
let paymentScreenshots: [WildReportEvidenceRequest]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WildReportEvidenceRequest: Encodable, Equatable {
|
||||||
|
let fileURL: String
|
||||||
|
let fileType: Int
|
||||||
|
let fileName: String?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI 与上传行为
|
||||||
|
|
||||||
|
- 图片、视频和支付截图使用系统相册选择器。
|
||||||
|
- 图片/视频选择后先保存到本地临时目录,点击附件 tile 可预览本地文件。
|
||||||
|
- 提交时顺序上传附件到 OSS,按钮展示上传进度并禁用重复提交。
|
||||||
|
- 任一 OSS 上传失败时不调用提交接口,并保留用户已填写内容。
|
||||||
|
- 支付截图通过 `payment_screenshots` 独立提交,不替代现场证据必填校验。
|
||||||
|
|
||||||
|
### 当前调用位置
|
||||||
|
|
||||||
|
- `WildPhotographerReportSubmitViewController.submitTapped()`
|
||||||
|
- `WildPhotographerReportSubmitViewModel.submitReport(api:uploader:scenicId:scenicName:)`
|
||||||
|
- `OSSUploadService.uploadWildReportAttachment(...)`
|
||||||
|
- `WildPhotographerReportAPI.submitReport(_:)`
|
||||||
|
|
||||||
|
## 3. 我的举报列表
|
||||||
|
|
||||||
|
### 用途
|
||||||
|
|
||||||
|
“我的举报”页面进入、切换状态筛选、滚动加载更多时调用。
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/report/list
|
||||||
|
```
|
||||||
|
|
||||||
|
### 请求参数
|
||||||
|
|
||||||
|
| 参数 | 类型 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `scenic_id` | integer | 可选,当前景区 ID,大于 0 时传 |
|
||||||
|
| `handle_status` | integer | 可选,状态筛选;“全部”不传该参数,其他筛选 iOS 固定使用该参数 |
|
||||||
|
| `status` | integer | 可选,与 `handle_status` 等价;iOS 不传 |
|
||||||
|
| `page` | integer | 可选,默认 1 |
|
||||||
|
| `page_size` | integer | 可选,默认 10,最大 50 |
|
||||||
|
|
||||||
|
### 状态码
|
||||||
|
|
||||||
|
- 全部:不传 `handle_status`
|
||||||
|
- `0`:待处理
|
||||||
|
- `1`:处理中
|
||||||
|
- `2`:已处理
|
||||||
|
|
||||||
|
### 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 32,
|
||||||
|
"complaint_no": "JB20260708917",
|
||||||
|
"title": "私下收款举报",
|
||||||
|
"report_type": 1,
|
||||||
|
"report_type_text": "私下收款",
|
||||||
|
"desc": "我在",
|
||||||
|
"scenic_id": 128,
|
||||||
|
"scenic_name": "伊犁那拉提景区-5A",
|
||||||
|
"location_name": "伊犁那拉提景区-5A",
|
||||||
|
"latitude": "32.4299428",
|
||||||
|
"longitude": "119.4403263",
|
||||||
|
"handle_status": 0,
|
||||||
|
"handle_status_text": "待处理",
|
||||||
|
"created_at": "2026-07-08 16:06:59"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 10
|
||||||
|
},
|
||||||
|
"time": "2026-07-08 16:07:30"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### iOS 模型
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct WildReportListRequest: Equatable {
|
||||||
|
let scenicId: Int?
|
||||||
|
let handleStatus: Int?
|
||||||
|
let page: Int
|
||||||
|
let pageSize: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WildReportListResponse: Decodable, Equatable {
|
||||||
|
let list: [WildReportListItem]
|
||||||
|
let total: Int
|
||||||
|
let page: Int
|
||||||
|
let pageSize: Int
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI 行为
|
||||||
|
|
||||||
|
- 页面首次进入请求 `page=1&page_size=10`,不传 `handle_status`。
|
||||||
|
- 切换筛选时重置为第一页,待处理传 `handle_status=0`,处理中传 `handle_status=1`,已处理传 `handle_status=2`。
|
||||||
|
- 滚动到底部时,如果仍有更多数据,则请求下一页并追加。
|
||||||
|
- 首屏失败显示 toast 和空态;加载更多失败只 toast,并保留已有列表。
|
||||||
|
- 列表接口暂不返回媒体数量,`WildReportRecord.imageCount/videoCount` 暂按 0 处理,后续详情接口补齐。
|
||||||
|
|
||||||
|
### 当前调用位置
|
||||||
|
|
||||||
|
- `WildPhotographerReportListViewController.viewDidLoad()`
|
||||||
|
- `WildPhotographerReportListViewModel.loadInitial(api:scenicId:)`
|
||||||
|
- `WildPhotographerReportListViewModel.loadMoreIfNeeded(api:scenicId:)`
|
||||||
|
- `WildPhotographerReportAPI.reportList(_:)`
|
||||||
|
|
||||||
|
## Mock 阶段业务
|
||||||
|
|
||||||
|
以下业务当前仍由 `WildPhotographerReportMockStore` 和 ViewModel 本地逻辑完成,暂未接真实接口:
|
||||||
|
|
||||||
|
- 举报详情
|
||||||
|
- 补充证据
|
||||||
|
- 附近风险地图
|
||||||
|
- 分享、媒体预览、地图导航演示提示
|
||||||
|
|
||||||
|
后续接入真实接口时,应优先在 `WildPhotographerReportServing` 中补充语义化方法,并保持 ViewModel 不依赖 UIKit 视图类型。
|
||||||
@ -9,12 +9,17 @@ import UIKit
|
|||||||
/// 我的举报列表页,支持按状态筛选并进入举报详情。
|
/// 我的举报列表页,支持按状态筛选并进入举报详情。
|
||||||
final class WildPhotographerReportListViewController: BaseViewController {
|
final class WildPhotographerReportListViewController: BaseViewController {
|
||||||
private let viewModel: WildPhotographerReportListViewModel
|
private let viewModel: WildPhotographerReportListViewModel
|
||||||
|
private let api: any WildPhotographerReportServing
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let stack = UIStackView()
|
private let stack = UIStackView()
|
||||||
private let filterTabBar = WildReportFilterTabBarView()
|
private let filterTabBar = WildReportFilterTabBarView()
|
||||||
|
|
||||||
init(homeViewModel: WildPhotographerReportHomeViewModel) {
|
init(
|
||||||
|
homeViewModel: WildPhotographerReportHomeViewModel,
|
||||||
|
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
|
||||||
|
) {
|
||||||
self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
|
self.viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
|
||||||
|
self.api = api
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,11 +36,19 @@ final class WildPhotographerReportListViewController: BaseViewController {
|
|||||||
view.backgroundColor = AppColor.pageBackgroundSoft
|
view.backgroundColor = AppColor.pageBackgroundSoft
|
||||||
scrollView.showsVerticalScrollIndicator = false
|
scrollView.showsVerticalScrollIndicator = false
|
||||||
scrollView.alwaysBounceVertical = true
|
scrollView.alwaysBounceVertical = true
|
||||||
|
scrollView.delegate = self
|
||||||
stack.axis = .vertical
|
stack.axis = .vertical
|
||||||
stack.spacing = 14
|
stack.spacing = 14
|
||||||
|
|
||||||
filterTabBar.onSelect = { [weak self] filter in
|
filterTabBar.onSelect = { [weak self] filter in
|
||||||
self?.viewModel.selectFilter(filter)
|
guard let self else { return }
|
||||||
|
Task {
|
||||||
|
await self.viewModel.selectFilter(
|
||||||
|
filter,
|
||||||
|
api: self.api,
|
||||||
|
scenicId: self.currentScenicId
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
view.addSubview(scrollView)
|
view.addSubview(scrollView)
|
||||||
@ -56,6 +69,13 @@ final class WildPhotographerReportListViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
Task {
|
||||||
|
await viewModel.loadInitial(api: api, scenicId: currentScenicId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override func bindActions() {
|
override func bindActions() {
|
||||||
viewModel.onStateChange = { [weak self] in
|
viewModel.onStateChange = { [weak self] in
|
||||||
Task { @MainActor in self?.reloadContent(animated: true) }
|
Task { @MainActor in self?.reloadContent(animated: true) }
|
||||||
@ -93,7 +113,7 @@ final class WildPhotographerReportListViewController: BaseViewController {
|
|||||||
|
|
||||||
let reports = viewModel.filteredReports
|
let reports = viewModel.filteredReports
|
||||||
if reports.isEmpty {
|
if reports.isEmpty {
|
||||||
stack.addArrangedSubview(WildReportListEmptyView())
|
stack.addArrangedSubview(viewModel.isLoading ? WildReportListLoadingView(text: "正在加载举报记录...") : WildReportListEmptyView())
|
||||||
} else {
|
} else {
|
||||||
reports.forEach { record in
|
reports.forEach { record in
|
||||||
let card = WildReportListCardView(record: record)
|
let card = WildReportListCardView(record: record)
|
||||||
@ -103,7 +123,7 @@ final class WildPhotographerReportListViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let footer = WildReportUI.label(viewModel.footerTip, font: .app(.caption), color: UIColor(hex: 0x9CA3AF), lines: 0)
|
let footer = WildReportUI.label(viewModel.footerDisplayText, font: .app(.caption), color: UIColor(hex: 0x9CA3AF), lines: 0)
|
||||||
footer.textAlignment = .center
|
footer.textAlignment = .center
|
||||||
let footerWrap = UIView()
|
let footerWrap = UIView()
|
||||||
footerWrap.addSubview(footer)
|
footerWrap.addSubview(footer)
|
||||||
@ -120,6 +140,23 @@ final class WildPhotographerReportListViewController: BaseViewController {
|
|||||||
animated: true
|
animated: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var currentScenicId: Int? {
|
||||||
|
let scenicId = AppStore.shared.currentScenicId
|
||||||
|
return scenicId > 0 ? scenicId : nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension WildPhotographerReportListViewController: UIScrollViewDelegate {
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
let threshold: CGFloat = 80
|
||||||
|
let visibleBottom = scrollView.contentOffset.y + scrollView.bounds.height
|
||||||
|
let triggerY = scrollView.contentSize.height - threshold
|
||||||
|
guard visibleBottom >= triggerY, scrollView.contentSize.height > scrollView.bounds.height else { return }
|
||||||
|
Task {
|
||||||
|
await viewModel.loadMoreIfNeeded(api: api, scenicId: currentScenicId)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 我的举报筛选栏,使用四段等宽胶囊按钮展示状态过滤。
|
/// 我的举报筛选栏,使用四段等宽胶囊按钮展示状态过滤。
|
||||||
@ -180,7 +217,7 @@ private final class WildReportFilterTabBarView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 我的举报列表卡片,展示一条举报记录并提供详情与分享入口。
|
/// 我的举报列表卡片,展示一条举报记录并提供详情与分享入口。
|
||||||
private final class WildReportListCardView: UIControl {
|
private final class WildReportListCardView: UIControl, UIGestureRecognizerDelegate {
|
||||||
var onOpen: (() -> Void)?
|
var onOpen: (() -> Void)?
|
||||||
var onShare: (() -> Void)?
|
var onShare: (() -> Void)?
|
||||||
|
|
||||||
@ -215,7 +252,11 @@ private final class WildReportListCardView: UIControl {
|
|||||||
layer.cornerRadius = 16
|
layer.cornerRadius = 16
|
||||||
layer.borderWidth = 1
|
layer.borderWidth = 1
|
||||||
layer.borderColor = AppColor.cardOutline.cgColor
|
layer.borderColor = AppColor.cardOutline.cgColor
|
||||||
addTarget(self, action: #selector(openTapped), for: .touchUpInside)
|
|
||||||
|
let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(openTapped))
|
||||||
|
tapRecognizer.delegate = self
|
||||||
|
tapRecognizer.cancelsTouchesInView = false
|
||||||
|
addGestureRecognizer(tapRecognizer)
|
||||||
|
|
||||||
let contentStack = UIStackView()
|
let contentStack = UIStackView()
|
||||||
contentStack.axis = .vertical
|
contentStack.axis = .vertical
|
||||||
@ -365,6 +406,17 @@ private final class WildReportListCardView: UIControl {
|
|||||||
@objc private func shareTapped() {
|
@objc private func shareTapped() {
|
||||||
onShare?()
|
onShare?()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||||
|
var currentView: UIView? = touch.view
|
||||||
|
while let view = currentView {
|
||||||
|
if view is UIButton {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
currentView = view.superview
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 我的举报列表信息行,展示举报编号、举报时间等左右结构内容。
|
/// 我的举报列表信息行,展示举报编号、举报时间等左右结构内容。
|
||||||
@ -429,6 +481,35 @@ private final class WildReportListEmptyView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 我的举报列表加载态卡片。
|
||||||
|
private final class WildReportListLoadingView: UIView {
|
||||||
|
init(text: String) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
backgroundColor = .white
|
||||||
|
layer.cornerRadius = 16
|
||||||
|
|
||||||
|
let indicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
indicator.color = AppColor.primary
|
||||||
|
indicator.startAnimating()
|
||||||
|
let label = WildReportUI.label(text, font: .systemFont(ofSize: 15, weight: .semibold), color: AppColor.textSecondary)
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [indicator, label])
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.alignment = .center
|
||||||
|
stack.spacing = 12
|
||||||
|
addSubview(stack)
|
||||||
|
stack.snp.makeConstraints { make in
|
||||||
|
make.top.bottom.equalToSuperview().inset(48)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 举报详情页。
|
/// 举报详情页。
|
||||||
final class WildPhotographerReportDetailViewController: BaseViewController {
|
final class WildPhotographerReportDetailViewController: BaseViewController {
|
||||||
private let viewModel: WildPhotographerReportDetailViewModel
|
private let viewModel: WildPhotographerReportDetailViewModel
|
||||||
@ -619,7 +700,7 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
|||||||
private func makeReportInfoCard() -> UIView {
|
private func makeReportInfoCard() -> UIView {
|
||||||
let card = WildReportCardView(spacing: 0, inset: 0)
|
let card = WildReportCardView(spacing: 0, inset: 0)
|
||||||
card.layer.cornerRadius = 18
|
card.layer.cornerRadius = 18
|
||||||
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报类型", value: viewModel.record.reportType.rawValue))
|
card.stack.addArrangedSubview(WildReportDetailInfoRowView(title: "举报类型", value: viewModel.record.reportType.label))
|
||||||
card.stack.addArrangedSubview(makeInsetDivider())
|
card.stack.addArrangedSubview(makeInsetDivider())
|
||||||
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())
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -83,6 +83,48 @@ final class ProfileSpaceSettingsViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(response.schedule["2026-07-08"]?.first?.userPhone, "13800000000")
|
XCTAssertEqual(response.schedule["2026-07-08"]?.first?.userPhone, "13800000000")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testProfileInfoEnvelopeDecodeSupportsEmptyScheduleArray() throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"data": {
|
||||||
|
"accept_order_status": 1,
|
||||||
|
"attr_label": [],
|
||||||
|
"avatar": "https://vipsky.oss-cn-shanghai.aliyuncs.com/avatar/20260707/128/E7036CC996884E8A963A3962A9E72850_avatar_1783416288.jpg",
|
||||||
|
"business_end_time": "",
|
||||||
|
"business_start_time": "",
|
||||||
|
"camera_device": [],
|
||||||
|
"description": "",
|
||||||
|
"holiday_end_time": "",
|
||||||
|
"holiday_start_time": "",
|
||||||
|
"id": 732,
|
||||||
|
"nickname": "陆久银12",
|
||||||
|
"real_name": "陆久银",
|
||||||
|
"scenic_certification": [
|
||||||
|
"伊犁那拉提景区-5A"
|
||||||
|
],
|
||||||
|
"schedule": [],
|
||||||
|
"shoot_label": []
|
||||||
|
},
|
||||||
|
"msg": "success",
|
||||||
|
"time": "2026-07-08 16:16:18"
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
|
||||||
|
let envelope = try JSONDecoder().decode(APIEnvelope<PhotographerProfileInfoResponse>.self, from: json)
|
||||||
|
let response = try XCTUnwrap(envelope.data)
|
||||||
|
|
||||||
|
XCTAssertEqual(response.id, 732)
|
||||||
|
XCTAssertEqual(response.scenicId, 0)
|
||||||
|
XCTAssertEqual(response.photogUid, 0)
|
||||||
|
XCTAssertEqual(response.realName, "陆久银")
|
||||||
|
XCTAssertEqual(response.nickname, "陆久银12")
|
||||||
|
XCTAssertEqual(response.scenicCertification, ["伊犁那拉提景区-5A"])
|
||||||
|
XCTAssertEqual(response.acceptOrderStatus, 1)
|
||||||
|
XCTAssertEqual(response.schedule, [:])
|
||||||
|
XCTAssertEqual(response.virtualPhone, "")
|
||||||
|
}
|
||||||
|
|
||||||
func testLabelValidationCoversEmptyTooLongDuplicateAndLimit() {
|
func testLabelValidationCoversEmptyTooLongDuplicateAndLimit() {
|
||||||
let viewModel = makeViewModel()
|
let viewModel = makeViewModel()
|
||||||
var messages: [String] = []
|
var messages: [String] = []
|
||||||
|
|||||||
@ -6,7 +6,8 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
@testable import suixinkan
|
@testable import suixinkan
|
||||||
|
|
||||||
/// 举报摄影师模块测试,覆盖 Mock 数据、提交校验、列表筛选和补充证据。
|
@MainActor
|
||||||
|
/// 举报摄影师模块测试,覆盖 Mock 数据、接口类型、提交校验、列表筛选和补充证据。
|
||||||
final class WildPhotographerReportTests: XCTestCase {
|
final class WildPhotographerReportTests: XCTestCase {
|
||||||
func testSeedDataLoadsReportsAndRiskPoints() {
|
func testSeedDataLoadsReportsAndRiskPoints() {
|
||||||
let viewModel = WildPhotographerReportHomeViewModel()
|
let viewModel = WildPhotographerReportHomeViewModel()
|
||||||
@ -21,7 +22,7 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
let beforeCount = viewModel.reports.count
|
let beforeCount = viewModel.reports.count
|
||||||
|
|
||||||
let record = viewModel.submitReport(
|
let record = viewModel.submitReport(
|
||||||
reportType: .suspectedWild,
|
reportType: WildReportType(value: 2, label: "疑似打野"),
|
||||||
description: "测试举报说明",
|
description: "测试举报说明",
|
||||||
location: "测试景区 · 测试点位",
|
location: "测试景区 · 测试点位",
|
||||||
contact: "13800138000",
|
contact: "13800138000",
|
||||||
@ -36,11 +37,154 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertEqual(record.phoneNumber, "13800138000")
|
XCTAssertEqual(record.phoneNumber, "13800138000")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testReportTypesAPILoadsRemoteOptions() async throws {
|
||||||
|
let json = Data("""
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{ "value": 1, "label": "私下收款" },
|
||||||
|
{ "value": 2, "label": "疑似打野" },
|
||||||
|
{ "value": 3, "label": "未戴工牌" },
|
||||||
|
{ "value": 4, "label": "其他" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": "2026-07-08 14:21:21"
|
||||||
|
}
|
||||||
|
""".utf8)
|
||||||
|
let session = MockURLSession(responses: [json])
|
||||||
|
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
let response = try await api.reportTypes()
|
||||||
|
|
||||||
|
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/types")
|
||||||
|
XCTAssertEqual(response.list, [
|
||||||
|
WildReportType(value: 1, label: "私下收款"),
|
||||||
|
WildReportType(value: 2, label: "疑似打野"),
|
||||||
|
WildReportType(value: 3, label: "未戴工牌"),
|
||||||
|
WildReportType(value: 4, label: "其他")
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReportListAPILoadsRemoteReports() async throws {
|
||||||
|
let json = Data("""
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 32,
|
||||||
|
"complaint_no": "JB20260708917",
|
||||||
|
"title": "私下收款举报",
|
||||||
|
"report_type": 1,
|
||||||
|
"report_type_text": "私下收款",
|
||||||
|
"desc": "我在",
|
||||||
|
"scenic_id": 128,
|
||||||
|
"scenic_name": "伊犁那拉提景区-5A",
|
||||||
|
"location_name": "伊犁那拉提景区-5A",
|
||||||
|
"latitude": "32.4299428",
|
||||||
|
"longitude": "119.4403263",
|
||||||
|
"handle_status": 0,
|
||||||
|
"handle_status_text": "待处理",
|
||||||
|
"created_at": "2026-07-08 16:06:59"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 10
|
||||||
|
},
|
||||||
|
"time": "2026-07-08 16:07:30"
|
||||||
|
}
|
||||||
|
""".utf8)
|
||||||
|
let session = MockURLSession(responses: [json])
|
||||||
|
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
let response = try await api.reportList(WildReportListRequest(scenicId: 128, handleStatus: 0, page: 1, pageSize: 10))
|
||||||
|
|
||||||
|
let components = URLComponents(url: try XCTUnwrap(session.requests.first?.url), resolvingAgainstBaseURL: false)
|
||||||
|
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/list")
|
||||||
|
XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "scenic_id", value: "128")) == true)
|
||||||
|
XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "handle_status", value: "0")) == true)
|
||||||
|
XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "page", value: "1")) == true)
|
||||||
|
XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "page_size", value: "10")) == true)
|
||||||
|
XCTAssertEqual(response.total, 1)
|
||||||
|
XCTAssertEqual(response.page, 1)
|
||||||
|
XCTAssertEqual(response.pageSize, 10)
|
||||||
|
XCTAssertEqual(response.list.first?.complaintNo, "JB20260708917")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReportListItemMapsToRecord() {
|
||||||
|
let item = makeReportListItem(
|
||||||
|
complaintNo: "JB20260708917",
|
||||||
|
title: "私下收款举报",
|
||||||
|
reportType: 1,
|
||||||
|
reportTypeText: "私下收款",
|
||||||
|
handleStatus: 0,
|
||||||
|
handleStatusText: "待处理"
|
||||||
|
)
|
||||||
|
|
||||||
|
let record = item.toRecord()
|
||||||
|
|
||||||
|
XCTAssertEqual(record.id, "JB20260708917")
|
||||||
|
XCTAssertEqual(record.title, "私下收款举报")
|
||||||
|
XCTAssertEqual(record.reportType, WildReportType(value: 1, label: "私下收款"))
|
||||||
|
XCTAssertEqual(record.status, .pending)
|
||||||
|
XCTAssertEqual(record.location, "伊犁那拉提景区-5A")
|
||||||
|
XCTAssertEqual(record.description, "测试说明")
|
||||||
|
XCTAssertEqual(record.submitTime, "2026-07-08 16:06:59")
|
||||||
|
XCTAssertEqual(record.imageCount, 0)
|
||||||
|
XCTAssertEqual(record.videoCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testReportListAllFilterOmitsHandleStatus() {
|
||||||
|
let request = WildReportListRequest(scenicId: 128, handleStatus: nil, page: 1, pageSize: 10)
|
||||||
|
|
||||||
|
XCTAssertFalse(request.queryItems.contains { $0.name == "handle_status" })
|
||||||
|
XCTAssertTrue(request.queryItems.contains(URLQueryItem(name: "scenic_id", value: "128")))
|
||||||
|
XCTAssertTrue(request.queryItems.contains(URLQueryItem(name: "page", value: "1")))
|
||||||
|
XCTAssertTrue(request.queryItems.contains(URLQueryItem(name: "page_size", value: "10")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubmitViewModelLoadsReportTypesFromAPI() async {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
api.reportTypeResponse = WildReportTypeListResponse(list: [
|
||||||
|
WildReportType(value: 1, label: "私下收款"),
|
||||||
|
WildReportType(value: 2, label: "疑似打野"),
|
||||||
|
WildReportType(value: 4, label: "其他"),
|
||||||
|
])
|
||||||
|
|
||||||
|
await viewModel.loadReportTypes(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.reportTypes, [
|
||||||
|
WildReportType(value: 1, label: "私下收款"),
|
||||||
|
WildReportType(value: 2, label: "疑似打野"),
|
||||||
|
WildReportType(value: 4, label: "其他")
|
||||||
|
])
|
||||||
|
XCTAssertEqual(viewModel.selectedReportType, .defaultSelected)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubmitViewModelFallsBackWhenReportTypesFail() async {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
api.error = URLError(.badServerResponse)
|
||||||
|
var messages: [String] = []
|
||||||
|
viewModel.onShowMessage = { messages.append($0) }
|
||||||
|
|
||||||
|
await viewModel.loadReportTypes(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.reportTypes, WildReportType.fallbackOptions)
|
||||||
|
XCTAssertEqual(viewModel.selectedReportType, .defaultSelected)
|
||||||
|
XCTAssertEqual(messages.last, "举报类型获取失败,已使用默认类型")
|
||||||
|
}
|
||||||
|
|
||||||
func testSubmitFailsWithoutEvidence() {
|
func testSubmitFailsWithoutEvidence() {
|
||||||
let homeViewModel = WildPhotographerReportHomeViewModel()
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
viewModel.removeAttachment(viewModel.images[0])
|
|
||||||
viewModel.removeAttachment(viewModel.videos[0])
|
|
||||||
viewModel.updateDescription("有说明")
|
viewModel.updateDescription("有说明")
|
||||||
viewModel.applyResolvedLocation("测试位置")
|
viewModel.applyResolvedLocation("测试位置")
|
||||||
|
|
||||||
@ -53,6 +197,7 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
func testSubmitFailsWithoutDescription() {
|
func testSubmitFailsWithoutDescription() {
|
||||||
let homeViewModel = WildPhotographerReportHomeViewModel()
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
viewModel.addImage()
|
||||||
viewModel.updateDescription(" ")
|
viewModel.updateDescription(" ")
|
||||||
viewModel.applyResolvedLocation("测试位置")
|
viewModel.applyResolvedLocation("测试位置")
|
||||||
|
|
||||||
@ -65,6 +210,7 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
func testSubmitFailsWithoutConfirmedLocation() {
|
func testSubmitFailsWithoutConfirmedLocation() {
|
||||||
let homeViewModel = WildPhotographerReportHomeViewModel()
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
viewModel.addImage()
|
||||||
viewModel.updateDescription("测试说明")
|
viewModel.updateDescription("测试说明")
|
||||||
|
|
||||||
viewModel.submitReport()
|
viewModel.submitReport()
|
||||||
@ -73,17 +219,77 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertNil(viewModel.submitResult)
|
XCTAssertNil(viewModel.submitResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSubmitSuccessCreatesRecord() {
|
func testSubmitSuccessCreatesRecord() async throws {
|
||||||
let homeViewModel = WildPhotographerReportHomeViewModel()
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
let uploader = MockWildReportAttachmentUploader()
|
||||||
|
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
|
||||||
|
viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")])
|
||||||
viewModel.updateDescription("测试说明")
|
viewModel.updateDescription("测试说明")
|
||||||
viewModel.applyResolvedLocation("测试景区 · 测试点")
|
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: "测试景区 · 测试点"))
|
||||||
|
|
||||||
viewModel.submitReport()
|
await viewModel.submitReport(api: api, uploader: uploader, scenicId: 9, scenicName: "测试景区")
|
||||||
|
|
||||||
XCTAssertNil(viewModel.validationMessage)
|
XCTAssertNil(viewModel.validationMessage)
|
||||||
XCTAssertNotNil(viewModel.submitResult)
|
XCTAssertNotNil(viewModel.submitResult)
|
||||||
XCTAssertEqual(homeViewModel.reports.first?.id, viewModel.submitResult?.record.id)
|
XCTAssertEqual(homeViewModel.reports.first?.id, viewModel.submitResult?.record.id)
|
||||||
|
XCTAssertEqual(uploader.uploadedFileNames, ["scene.jpg", "payment.jpg"])
|
||||||
|
XCTAssertEqual(api.submitRequests.first?.scenicId, 9)
|
||||||
|
XCTAssertEqual(api.submitRequests.first?.reportType, 2)
|
||||||
|
XCTAssertEqual(api.submitRequests.first?.lat, 30.12)
|
||||||
|
XCTAssertEqual(api.submitRequests.first?.lng, 120.34)
|
||||||
|
XCTAssertEqual(api.submitRequests.first?.evidences.count, 1)
|
||||||
|
XCTAssertEqual(api.submitRequests.first?.evidences.first?.fileType, 1)
|
||||||
|
XCTAssertEqual(api.submitRequests.first?.paymentScreenshots.count, 1)
|
||||||
|
XCTAssertNil(api.submitRequests.first?.storeUserId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPaymentScreenshotsDoNotReplaceRequiredEvidence() async throws {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")])
|
||||||
|
viewModel.updateDescription("测试说明")
|
||||||
|
viewModel.applyResolvedLocation("测试景区 · 测试点")
|
||||||
|
|
||||||
|
await viewModel.submitReport(
|
||||||
|
api: MockWildPhotographerReportAPI(),
|
||||||
|
uploader: MockWildReportAttachmentUploader(),
|
||||||
|
scenicId: 9,
|
||||||
|
scenicName: "测试景区"
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.validationMessage, "请至少上传一项现场证据。")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPaymentScreenshotsLimitedToThree() throws {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
|
||||||
|
viewModel.addPaymentImages([
|
||||||
|
try makeAttachment(kind: .payment, fileName: "p1.jpg"),
|
||||||
|
try makeAttachment(kind: .payment, fileName: "p2.jpg"),
|
||||||
|
try makeAttachment(kind: .payment, fileName: "p3.jpg"),
|
||||||
|
try makeAttachment(kind: .payment, fileName: "p4.jpg")
|
||||||
|
])
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.paymentImages.count, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUploadFailureSkipsSubmitAPI() async throws {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
let uploader = MockWildReportAttachmentUploader()
|
||||||
|
uploader.error = OSSUploadError.emptyFile
|
||||||
|
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
|
||||||
|
viewModel.updateDescription("测试说明")
|
||||||
|
viewModel.applyResolvedLocation("测试景区 · 测试点")
|
||||||
|
|
||||||
|
await viewModel.submitReport(api: api, uploader: uploader, scenicId: 9, scenicName: "测试景区")
|
||||||
|
|
||||||
|
XCTAssertEqual(api.submitRequests.count, 0)
|
||||||
|
XCTAssertEqual(viewModel.validationMessage, OSSUploadError.emptyFile.localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testListFilterReturnsMatchingStatus() {
|
func testListFilterReturnsMatchingStatus() {
|
||||||
@ -103,6 +309,81 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.filteredReports.count, homeViewModel.reports.count)
|
XCTAssertEqual(viewModel.filteredReports.count, homeViewModel.reports.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testListInitialLoadUsesAllStatusAndFirstPage() async {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
api.reportListResponses = [
|
||||||
|
WildReportListResponse(
|
||||||
|
list: [makeReportListItem(complaintNo: "JB001", handleStatus: 0)],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
await viewModel.loadInitial(api: api, scenicId: 128)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.reportListRequests.first, WildReportListRequest(scenicId: 128, handleStatus: nil, page: 1, pageSize: 10))
|
||||||
|
XCTAssertEqual(viewModel.filteredReports.map(\.id), ["JB001"])
|
||||||
|
XCTAssertFalse(viewModel.hasMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListFilterReloadsWithMappedStatus() async {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
api.reportListResponses = [
|
||||||
|
WildReportListResponse(list: [makeReportListItem(complaintNo: "JB001", handleStatus: 0)], total: 1, page: 1, pageSize: 10),
|
||||||
|
WildReportListResponse(list: [makeReportListItem(complaintNo: "JB002", handleStatus: 1)], total: 1, page: 1, pageSize: 10),
|
||||||
|
WildReportListResponse(list: [makeReportListItem(complaintNo: "JB003", handleStatus: 2)], total: 1, page: 1, pageSize: 10),
|
||||||
|
]
|
||||||
|
|
||||||
|
await viewModel.selectFilter(.pending, api: api, scenicId: 128)
|
||||||
|
await viewModel.selectFilter(.processing, api: api, scenicId: 128)
|
||||||
|
await viewModel.selectFilter(.processed, api: api, scenicId: 128)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.reportListRequests.map(\.handleStatus), [0, 1, 2] as [Int?])
|
||||||
|
XCTAssertEqual(api.reportListRequests.map(\.page), [1, 1, 1])
|
||||||
|
XCTAssertEqual(viewModel.filteredReports.map(\.id), ["JB003"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListLoadMoreAppendsReportsAndUpdatesHasMore() async {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
api.reportListResponses = [
|
||||||
|
WildReportListResponse(list: [makeReportListItem(complaintNo: "JB001", handleStatus: 0)], total: 2, page: 1, pageSize: 10),
|
||||||
|
WildReportListResponse(list: [makeReportListItem(complaintNo: "JB002", handleStatus: 0)], total: 2, page: 2, pageSize: 10),
|
||||||
|
]
|
||||||
|
|
||||||
|
await viewModel.loadInitial(api: api, scenicId: 128)
|
||||||
|
XCTAssertTrue(viewModel.hasMore)
|
||||||
|
await viewModel.loadMoreIfNeeded(api: api, scenicId: 128)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.reportListRequests.map(\.page), [1, 2])
|
||||||
|
XCTAssertEqual(viewModel.filteredReports.map(\.id), ["JB001", "JB002"])
|
||||||
|
XCTAssertFalse(viewModel.hasMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListLoadFailureKeepsExistingReports() async {
|
||||||
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
api.reportListResponses = [
|
||||||
|
WildReportListResponse(list: [makeReportListItem(complaintNo: "JB001", handleStatus: 0)], total: 2, page: 1, pageSize: 10)
|
||||||
|
]
|
||||||
|
var messages: [String] = []
|
||||||
|
viewModel.onShowMessage = { messages.append($0) }
|
||||||
|
|
||||||
|
await viewModel.loadInitial(api: api, scenicId: 128)
|
||||||
|
api.reportListError = URLError(.badServerResponse)
|
||||||
|
await viewModel.loadMoreIfNeeded(api: api, scenicId: 128)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.filteredReports.map(\.id), ["JB001"])
|
||||||
|
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
func testSupplementEvidenceValidationAndSuccess() {
|
func testSupplementEvidenceValidationAndSuccess() {
|
||||||
let homeViewModel = WildPhotographerReportHomeViewModel()
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
let reportID = homeViewModel.reports[0].id
|
let reportID = homeViewModel.reports[0].id
|
||||||
@ -120,3 +401,88 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: reportID).first?.imageCount, 1)
|
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: reportID).first?.imageCount, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MockWildPhotographerReportAPI: WildPhotographerReportServing {
|
||||||
|
var reportTypeResponse = WildReportTypeListResponse(list: [])
|
||||||
|
var error: Error?
|
||||||
|
var submitRequests: [WildReportSubmitRequest] = []
|
||||||
|
var reportListResponses: [WildReportListResponse] = []
|
||||||
|
var reportListRequests: [WildReportListRequest] = []
|
||||||
|
var reportListError: Error?
|
||||||
|
|
||||||
|
func reportTypes() async throws -> WildReportTypeListResponse {
|
||||||
|
if let error { throw error }
|
||||||
|
return reportTypeResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitReport(_ request: WildReportSubmitRequest) async throws {
|
||||||
|
if let error { throw error }
|
||||||
|
submitRequests.append(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse {
|
||||||
|
if let reportListError { throw reportListError }
|
||||||
|
reportListRequests.append(request)
|
||||||
|
guard !reportListResponses.isEmpty else {
|
||||||
|
return WildReportListResponse(list: [], total: 0, page: request.page, pageSize: request.pageSize)
|
||||||
|
}
|
||||||
|
return reportListResponses.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MockWildReportAttachmentUploader: WildReportAttachmentUploading {
|
||||||
|
var uploadedFileNames: [String] = []
|
||||||
|
var error: Error?
|
||||||
|
|
||||||
|
func uploadWildReportAttachment(
|
||||||
|
data: Data,
|
||||||
|
fileName: String,
|
||||||
|
fileType: Int,
|
||||||
|
scenicId: Int,
|
||||||
|
onProgress: @escaping (Int) -> Void
|
||||||
|
) async throws -> String {
|
||||||
|
if let error { throw error }
|
||||||
|
onProgress(100)
|
||||||
|
uploadedFileNames.append(fileName)
|
||||||
|
return "https://oss.example.com/\(fileName)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeAttachment(kind: WildReportAttachmentKind, fileName: String) throws -> WildReportAttachment {
|
||||||
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + fileName)
|
||||||
|
try Data("mock".utf8).write(to: url)
|
||||||
|
return WildReportAttachment(
|
||||||
|
kind: kind,
|
||||||
|
title: fileName,
|
||||||
|
localFileURL: url,
|
||||||
|
fileName: fileName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeReportListItem(
|
||||||
|
complaintNo: String,
|
||||||
|
title: String = "疑似打野摄影师举报",
|
||||||
|
reportType: Int = 2,
|
||||||
|
reportTypeText: String = "疑似打野",
|
||||||
|
handleStatus: Int,
|
||||||
|
handleStatusText: String = "待处理"
|
||||||
|
) -> WildReportListItem {
|
||||||
|
WildReportListItem(
|
||||||
|
id: Int(complaintNo.filter(\.isNumber)) ?? 1,
|
||||||
|
complaintNo: complaintNo,
|
||||||
|
title: title,
|
||||||
|
reportType: reportType,
|
||||||
|
reportTypeText: reportTypeText,
|
||||||
|
desc: "测试说明",
|
||||||
|
scenicId: 128,
|
||||||
|
scenicName: "伊犁那拉提景区-5A",
|
||||||
|
locationName: "伊犁那拉提景区-5A",
|
||||||
|
latitude: "32.4299428",
|
||||||
|
longitude: "119.4403263",
|
||||||
|
handleStatus: handleStatus,
|
||||||
|
handleStatusText: handleStatusText,
|
||||||
|
createdAt: "2026-07-08 16:06:59"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user