接入举报摄影师正式接口

This commit is contained in:
2026-07-08 16:38:15 +08:00
parent 4a78a0c21a
commit 290a01e699
12 changed files with 2586 additions and 218 deletions

View File

@ -167,7 +167,7 @@ struct PhotographerProfileInfoResponse: Decodable, Equatable {
holidayEndTime = try container.decodeLossyString(forKey: .holidayEndTime)
businessRange = try container.decodeLossyIntArray(forKey: .businessRange)
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)
}
}
@ -612,4 +612,15 @@ private extension KeyedDecodingContainer {
}
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 [:]
}
}

View File

@ -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"
}
}

View File

@ -32,31 +32,81 @@ enum WildReportAttachmentKind: Hashable {
case payment
}
/// Mock
enum WildReportType: String, CaseIterable, Hashable {
case suspectedWild = "疑似打野"
case noWorkBadge = "未戴工牌"
case other = "其他"
///
struct WildReportType: Decodable, Hashable {
let value: Int
let label: String
var iconName: String {
switch self {
case .suspectedWild: return "person.crop.circle.badge.exclamationmark"
case .noWorkBadge: return "lanyardcard"
case .other: return "ellipsis.circle"
}
static let defaultSelected = WildReportType(value: 2, label: "疑似打野")
static let fallbackOptions = [
WildReportType(value: 1, label: "私下收款"),
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 {
let id: UUID
let kind: WildReportAttachmentKind
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.kind = kind
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 {
case all = "全部"
@ -91,6 +164,187 @@ enum WildReportListFilter: String, CaseIterable, Hashable {
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 {
let id: String

View File

@ -56,7 +56,7 @@ enum WildPhotographerReportMockStore {
WildReportRecord(
id: "JB20260618001",
title: "摄影师举报",
reportType: .suspectedWild,
reportType: WildReportType(value: 2, label: "疑似打野"),
status: .processing,
location: "那拉提景区 · 空中草原入口附近",
submitTime: "2026-06-18 10:24",
@ -70,7 +70,7 @@ enum WildPhotographerReportMockStore {
WildReportRecord(
id: "JB20260617008",
title: "摄影师举报",
reportType: .suspectedWild,
reportType: WildReportType(value: 2, label: "疑似打野"),
status: .processed,
location: "那拉提景区 · 河谷草原游客中心",
submitTime: "2026-06-17 16:42",
@ -91,7 +91,7 @@ enum WildPhotographerReportMockStore {
WildReportRecord(
id: "JB20260616003",
title: "摄影师举报",
reportType: .noWorkBadge,
reportType: WildReportType(value: 3, label: "未戴工牌"),
status: .pending,
location: "那拉提景区 · 盘龙古道停车区",
submitTime: "2026-06-16 09:15",
@ -105,7 +105,7 @@ enum WildPhotographerReportMockStore {
WildReportRecord(
id: "JB20260615002",
title: "摄影师举报",
reportType: .other,
reportType: WildReportType(value: 4, label: "其他"),
status: .processed,
location: "那拉提景区 · 雪莲谷步道",
submitTime: "2026-06-15 14:30",

View File

@ -7,6 +7,21 @@ import Foundation
import MapKit
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
final class WildPhotographerReportHomeViewModel {
private(set) var pageState: WildReportPageState = .normal
@ -138,11 +153,15 @@ final class WildPhotographerReportSubmitViewModel {
private(set) var images: [WildReportAttachment]
private(set) var videos: [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 contact = ""
private(set) var reportLocation = ""
private(set) var isLoadingReportTypes = false
private(set) var isUpdatingLocation = false
private(set) var isSubmitting = false
private(set) var submitProgressText = ""
private(set) var locationConfirmed = false
private(set) var submitResult: WildReportSubmitResult?
private(set) var validationMessage: String?
@ -154,6 +173,7 @@ final class WildPhotographerReportSubmitViewModel {
private let homeViewModel: WildPhotographerReportHomeViewModel
private let locationProvider: any LocationProviding
private var hasRequestedInitialLocation = false
private var locationSnapshot: HomeLocationSnapshot?
var locationTitleText: String {
if reportLocation.isEmpty || isUpdatingLocation {
@ -178,8 +198,8 @@ final class WildPhotographerReportSubmitViewModel {
) {
self.homeViewModel = homeViewModel
self.locationProvider = locationProvider
self.images = WildPhotographerReportMockStore.initialImages
self.videos = WildPhotographerReportMockStore.initialVideos
self.images = []
self.videos = []
}
///
@ -189,6 +209,38 @@ final class WildPhotographerReportSubmitViewModel {
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) {
selectedReportType = type
@ -207,26 +259,53 @@ final class WildPhotographerReportSubmitViewModel {
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() {
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()
}
///
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()
}
///
func addPaymentImage() {
guard paymentImages.count < 3 else {
onShowMessage?("支付截图最多上传3张")
return
}
paymentImages.append(WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)"))
notifyStateChange()
addPaymentImages([WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)")])
}
///
@ -248,12 +327,13 @@ final class WildPhotographerReportSubmitViewModel {
Task {
do {
let snapshot = try await locationProvider.requestSnapshot()
let address = snapshot.address.isEmpty
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
: snapshot.address
applyResolvedLocation(address)
applyResolvedLocation(snapshot)
} 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()
}
///
///
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() {
validationMessage = nil
guard !images.isEmpty || !videos.isEmpty else {
setValidationMessage("请至少上传一项现场证据。")
return
}
guard !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
setValidationMessage("请填写举报说明。")
return
}
guard !isUpdatingLocation else {
setValidationMessage("正在获取定位,请稍后再提交。")
return
}
guard locationConfirmed, !reportLocation.isEmpty, reportLocation != "正在获取当前位置..." else {
setValidationMessage("请先更新举报位置后再提交举报。")
return
}
guard validateBeforeSubmit(scenicId: 1) else { return }
let record = homeViewModel.submitReport(
reportType: selectedReportType,
@ -304,15 +437,19 @@ final class WildPhotographerReportSubmitViewModel {
///
func resetForm() {
images = WildPhotographerReportMockStore.initialImages
videos = WildPhotographerReportMockStore.initialVideos
images = []
videos = []
paymentImages = []
selectedReportType = .suspectedWild
reportTypes = WildReportType.fallbackOptions
selectedReportType = .defaultSelected
description = ""
contact = ""
reportLocation = ""
locationConfirmed = false
isUpdatingLocation = false
isSubmitting = false
submitProgressText = ""
locationSnapshot = nil
hasRequestedInitialLocation = false
submitResult = nil
validationMessage = nil
@ -320,7 +457,23 @@ final class WildPhotographerReportSubmitViewModel {
}
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
isUpdatingLocation = false
notifyStateChange()
@ -335,6 +488,110 @@ final class WildPhotographerReportSubmitViewModel {
private func notifyStateChange() {
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
@ -480,8 +737,17 @@ final class WildPhotographerReportDetailViewModel {
/// ViewModel
final class WildPhotographerReportListViewModel {
private static let pageSize = 10
private(set) var selectedFilter: WildReportListFilter = .all
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
var onStateChange: (() -> Void)?
@ -492,23 +758,63 @@ final class WildPhotographerReportListViewModel {
}
var filteredReports: [WildReportRecord] {
let reports = homeViewModel.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 }
}
reports
}
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) {
selectedFilter = filter
reports = filterLocalReports(homeViewModel.reports)
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) {
shareMessage = "已生成举报 \(record.id) 的分享信息,可发送给景区处理人员查看进度。"
@ -516,6 +822,55 @@ final class WildPhotographerReportListViewModel {
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() {
onStateChange?()
}

View 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 视图类型。