feat: 更新素材管理和举报风险地图
This commit is contained in:
@ -36,7 +36,7 @@ enum HomeRouteHandler {
|
||||
case "photographer_stats":
|
||||
selectTab(.statistics, from: viewController)
|
||||
case "system_settings":
|
||||
selectTab(.profile, from: viewController)
|
||||
viewController.navigationController?.pushViewController(SettingViewController(), animated: true)
|
||||
case "space_settings":
|
||||
viewController.navigationController?.pushViewController(ProfileSpaceSettingsViewController(), animated: true)
|
||||
case "pilot_cert":
|
||||
@ -66,6 +66,8 @@ enum HomeRouteHandler {
|
||||
viewController.navigationController?.pushViewController(TravelAlbumEntryViewController(), animated: true)
|
||||
case "sample_management":
|
||||
viewController.navigationController?.pushViewController(SampleListViewController(), animated: true)
|
||||
case "asset_management":
|
||||
viewController.navigationController?.pushViewController(MaterialListViewController(), animated: true)
|
||||
case "live_stream_management":
|
||||
viewController.navigationController?.pushViewController(LiveManageViewController(), animated: true)
|
||||
case "live_album":
|
||||
|
||||
@ -24,7 +24,10 @@ final class LocationProvider: NSObject, LocationProviding {
|
||||
try AMapBootstrap.requireConfigured()
|
||||
try await ensureLocationPermission()
|
||||
|
||||
let manager = makeLocationManager()
|
||||
let manager = makeLocationManager(
|
||||
desiredAccuracy: kCLLocationAccuracyBest,
|
||||
locatingWithReGeocode: true
|
||||
)
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let started = manager.requestLocation(withReGeocode: true) { location, regeocode, error in
|
||||
if let error {
|
||||
@ -48,11 +51,14 @@ final class LocationProvider: NSObject, LocationProviding {
|
||||
}
|
||||
}
|
||||
|
||||
func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
||||
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
|
||||
try AMapBootstrap.requireConfigured()
|
||||
try await ensureLocationPermission()
|
||||
|
||||
let manager = makeLocationManager()
|
||||
let manager = makeLocationManager(
|
||||
desiredAccuracy: desiredAccuracy,
|
||||
locatingWithReGeocode: false
|
||||
)
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let started = manager.requestLocation(withReGeocode: false) { location, _, error in
|
||||
if let error {
|
||||
@ -89,12 +95,15 @@ final class LocationProvider: NSObject, LocationProviding {
|
||||
}
|
||||
}
|
||||
|
||||
private func makeLocationManager() -> AMapLocationManager {
|
||||
private func makeLocationManager(
|
||||
desiredAccuracy: CLLocationAccuracy,
|
||||
locatingWithReGeocode: Bool
|
||||
) -> AMapLocationManager {
|
||||
let manager = AMapLocationManager()
|
||||
manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
manager.desiredAccuracy = desiredAccuracy
|
||||
manager.locationTimeout = 30
|
||||
manager.reGeocodeTimeout = 10
|
||||
manager.locatingWithReGeocode = true
|
||||
manager.locatingWithReGeocode = locatingWithReGeocode
|
||||
return manager
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,12 +17,19 @@ struct HomeLocationSnapshot: Sendable, Equatable {
|
||||
protocol LocationProviding: Sendable {
|
||||
/// 请求当前坐标与地址。
|
||||
func requestSnapshot() async throws -> HomeLocationSnapshot
|
||||
/// 请求当前坐标。
|
||||
func requestCoordinate() async throws -> CLLocationCoordinate2D
|
||||
/// 按指定精度请求当前坐标。
|
||||
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D
|
||||
/// 逆地理编码。
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String
|
||||
}
|
||||
|
||||
extension LocationProviding {
|
||||
/// 请求当前坐标,默认使用最高精度以保持既有业务行为。
|
||||
func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
||||
try await requestCoordinate(desiredAccuracy: kCLLocationAccuracyBest)
|
||||
}
|
||||
}
|
||||
|
||||
/// 定位错误。
|
||||
enum LocationProviderError: LocalizedError, Equatable {
|
||||
case privacyNotAccepted
|
||||
|
||||
@ -0,0 +1,140 @@
|
||||
//
|
||||
// MaterialManagementAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 素材管理服务协议,抽象列表、详情、上下架、删除、标签、上传与编辑接口。
|
||||
@MainActor
|
||||
protocol MaterialManagementServing {
|
||||
/// 拉取素材列表。
|
||||
func list(status: Int, keyword: String?, page: Int, pageSize: Int) async throws -> MaterialListResponse
|
||||
|
||||
/// 拉取素材详情。
|
||||
func detail(id: Int) async throws -> MaterialDetail
|
||||
|
||||
/// 设置素材上下架状态。
|
||||
func setListingStatus(id: Int, listingStatus: Int) async throws
|
||||
|
||||
/// 删除素材。
|
||||
func delete(id: Int) async throws
|
||||
|
||||
/// 新增素材标签。
|
||||
func addTag(name: String) async throws -> Int
|
||||
|
||||
/// 上传素材。
|
||||
func upload(_ request: MaterialUploadRequest) async throws
|
||||
|
||||
/// 编辑素材。
|
||||
func edit(_ request: MaterialUploadRequest) async throws
|
||||
|
||||
/// 拉取景区全部打卡点。
|
||||
func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse
|
||||
}
|
||||
|
||||
/// 素材管理 API,封装 Android `NetworkApi` 中 `media-album type=1` 接口。
|
||||
@MainActor
|
||||
final class MaterialManagementAPI: MaterialManagementServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化素材管理 API。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 拉取素材列表,固定 `type=1`。
|
||||
func list(status: Int, keyword: String?, page: Int = 1, pageSize: Int = 10) async throws -> MaterialListResponse {
|
||||
var items = [
|
||||
URLQueryItem(name: "status", value: String(status)),
|
||||
URLQueryItem(name: "type", value: "1"),
|
||||
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
|
||||
]
|
||||
if let keyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines), !keyword.isEmpty {
|
||||
items.append(URLQueryItem(name: "keyword", value: keyword))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/media-album/list", queryItems: items)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取素材详情,固定 `type=1`。
|
||||
func detail(id: Int) async throws -> MaterialDetail {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/media-album/detail",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "id", value: String(id)),
|
||||
URLQueryItem(name: "type", value: "1"),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 设置素材上下架状态,固定 `type=1`。
|
||||
func setListingStatus(id: Int, listingStatus: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/media-album/operation",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "id", value: String(id)),
|
||||
URLQueryItem(name: "listing_status", value: String(listingStatus)),
|
||||
URLQueryItem(name: "type", value: "1"),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 删除素材,固定 `type=1`。
|
||||
func delete(id: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/media-album/delete",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "id", value: String(id)),
|
||||
URLQueryItem(name: "type", value: "1"),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 新增素材标签。
|
||||
func addTag(name: String) async throws -> Int {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/media-album/add-tag",
|
||||
queryItems: [URLQueryItem(name: "name", value: name)]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 上传素材。
|
||||
func upload(_ request: MaterialUploadRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/media-album/upload", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
/// 编辑素材。
|
||||
func edit(_ request: MaterialUploadRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(method: .post, path: "/api/app/media-album/edit", body: request)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取景区全部打卡点。
|
||||
func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/scenic-spot/list-all",
|
||||
queryItems: [URLQueryItem(name: "scenic_id", value: scenicId)]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,541 @@
|
||||
//
|
||||
// MaterialManagementModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 素材管理筛选状态,对齐 Android `MaterialListViewModel` 的四档筛选。
|
||||
enum MaterialFilterStatus: Int, CaseIterable, Sendable, Equatable {
|
||||
case all = 0
|
||||
case approved = 1
|
||||
case pending = 2
|
||||
case rejected = 3
|
||||
|
||||
/// 展示文案。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: "全部"
|
||||
case .approved: "已通过"
|
||||
case .pending: "待审核"
|
||||
case .rejected: "未通过"
|
||||
}
|
||||
}
|
||||
|
||||
/// 服务端 `status` 参数,Android 映射为:全部 4、已通过 1、待审核 0、未通过 2。
|
||||
var apiStatus: Int {
|
||||
switch self {
|
||||
case .all: 4
|
||||
case .approved: 1
|
||||
case .pending: 0
|
||||
case .rejected: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材媒体类型,对齐 Android `mediaType`:1 图片,2 视频。
|
||||
enum MaterialMediaType: Int, CaseIterable, Sendable, Equatable {
|
||||
case image = 1
|
||||
case video = 2
|
||||
|
||||
/// 展示文案。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .image: "图片"
|
||||
case .video: "视频"
|
||||
}
|
||||
}
|
||||
|
||||
/// 最大可选数量。
|
||||
var maxCount: Int {
|
||||
switch self {
|
||||
case .image: 9
|
||||
case .video: 1
|
||||
}
|
||||
}
|
||||
|
||||
/// OSS 文件类型,Android 上传任务中视频为 1、图片为 2。
|
||||
var uploadFileType: Int {
|
||||
switch self {
|
||||
case .image: 2
|
||||
case .video: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材列表项,对齐 Android `MaterialItemEntity`。
|
||||
struct MaterialItem: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
let coverUrl: String
|
||||
let createdAt: String
|
||||
let status: Int
|
||||
let downloadCount: Int
|
||||
let likesCount: Int
|
||||
let collectCount: Int
|
||||
let shareCount: Int
|
||||
let scenicSpotName: String
|
||||
let projectName: String
|
||||
let listingStatus: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case coverUrl = "cover_url"
|
||||
case createdAt = "created_at"
|
||||
case status
|
||||
case downloadCount = "download_count"
|
||||
case likesCount = "likes_count"
|
||||
case collectCount = "collect_count"
|
||||
case shareCount = "share_count"
|
||||
case scenicSpotName = "scenic_spot_name"
|
||||
case projectName = "project_name"
|
||||
case listingStatus = "listing_status"
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
name: String = "",
|
||||
coverUrl: String = "",
|
||||
createdAt: String = "",
|
||||
status: Int = 0,
|
||||
downloadCount: Int = 0,
|
||||
likesCount: Int = 0,
|
||||
collectCount: Int = 0,
|
||||
shareCount: Int = 0,
|
||||
scenicSpotName: String = "",
|
||||
projectName: String = "",
|
||||
listingStatus: Int = 0
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.coverUrl = coverUrl
|
||||
self.createdAt = createdAt
|
||||
self.status = status
|
||||
self.downloadCount = downloadCount
|
||||
self.likesCount = likesCount
|
||||
self.collectCount = collectCount
|
||||
self.shareCount = shareCount
|
||||
self.scenicSpotName = scenicSpotName
|
||||
self.projectName = projectName
|
||||
self.listingStatus = listingStatus
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材列表响应,对齐 Android `MaterialListResponse`。
|
||||
struct MaterialListResponse: Decodable, Sendable, Equatable {
|
||||
let total: Int
|
||||
let list: [MaterialItem]
|
||||
let order: MaterialOrderInfo?
|
||||
|
||||
init(total: Int = 0, list: [MaterialItem] = [], order: MaterialOrderInfo? = nil) {
|
||||
self.total = total
|
||||
self.list = list
|
||||
self.order = order
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材订单统计信息,对齐 Android `MaterialOrderInfo`。
|
||||
struct MaterialOrderInfo: Decodable, Sendable, Equatable, Hashable {
|
||||
let totalNum: Int
|
||||
let avgOrderAmount: String
|
||||
let refundTotal: String
|
||||
let avgChange: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case totalNum = "total_num"
|
||||
case avgOrderAmount = "avg_order_amount"
|
||||
case refundTotal = "refund_total"
|
||||
case avgChange = "avg_change"
|
||||
}
|
||||
|
||||
init(totalNum: Int = 0, avgOrderAmount: String = "0", refundTotal: String = "0", avgChange: Int = 0) {
|
||||
self.totalNum = totalNum
|
||||
self.avgOrderAmount = avgOrderAmount
|
||||
self.refundTotal = refundTotal
|
||||
self.avgChange = avgChange
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
totalNum = try container.decodeIfPresent(Int.self, forKey: .totalNum) ?? 0
|
||||
avgOrderAmount = try container.decodeIfPresent(String.self, forKey: .avgOrderAmount) ?? "0"
|
||||
refundTotal = try container.decodeIfPresent(String.self, forKey: .refundTotal) ?? "0"
|
||||
avgChange = try container.decodeIfPresent(Int.self, forKey: .avgChange) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材详情响应,对齐 Android `MaterialDetailResponse`。
|
||||
struct MaterialDetail: Decodable, Sendable, Equatable {
|
||||
let id: Int
|
||||
let name: String?
|
||||
let type: String?
|
||||
let ossUrl: String?
|
||||
let coverUrl: String?
|
||||
let coverSize: Int?
|
||||
let description: String?
|
||||
let scenicSpotId: Int64?
|
||||
let scenicAreaId: Int?
|
||||
let materialTag: [MaterialTagItem]?
|
||||
let uploaderId: Int?
|
||||
let status: Int
|
||||
let likesCount: Int
|
||||
let downloadCount: Int
|
||||
let collectCount: Int
|
||||
let isRecommended: Bool
|
||||
let createdAt: String?
|
||||
let updatedAt: String?
|
||||
let deletedAt: String?
|
||||
let listingStatus: Int
|
||||
let reviewNotes: String?
|
||||
let reviewTime: String?
|
||||
let reviewerId: Int?
|
||||
let reviewer: String?
|
||||
let provinceId: Int?
|
||||
let cityId: Int?
|
||||
let shareCount: Int
|
||||
let isWeb: Int
|
||||
let isMini: Int
|
||||
let uploaderName: String?
|
||||
let uploaderAvatar: String?
|
||||
let uploaderDescription: String?
|
||||
let platformCertification: Bool
|
||||
let scenicCertification: Bool
|
||||
let mediaList: [MaterialDetailMediaItem]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case type
|
||||
case ossUrl = "oss_url"
|
||||
case coverUrl = "cover_url"
|
||||
case coverSize = "cover_size"
|
||||
case description
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case scenicAreaId = "scenic_area_id"
|
||||
case materialTag = "material_tag"
|
||||
case uploaderId = "uploader_id"
|
||||
case status
|
||||
case likesCount = "likes_count"
|
||||
case downloadCount = "download_count"
|
||||
case collectCount = "collect_count"
|
||||
case isRecommended = "is_recommended"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case deletedAt = "deleted_at"
|
||||
case listingStatus = "listing_status"
|
||||
case reviewNotes = "review_notes"
|
||||
case reviewTime = "review_time"
|
||||
case reviewerId = "reviewer_id"
|
||||
case reviewer
|
||||
case provinceId = "province_id"
|
||||
case cityId = "city_id"
|
||||
case shareCount = "share_count"
|
||||
case isWeb = "is_web"
|
||||
case isMini = "is_mini"
|
||||
case uploaderName = "uploader_name"
|
||||
case uploaderAvatar = "uploader_avatar"
|
||||
case uploaderDescription = "uploader_description"
|
||||
case platformCertification = "platform_certification"
|
||||
case scenicCertification = "scenic_certification"
|
||||
case mediaList = "media_list"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
|
||||
name = try container.decodeIfPresent(String.self, forKey: .name)
|
||||
type = try container.decodeFlexibleString(forKey: .type)
|
||||
ossUrl = try container.decodeIfPresent(String.self, forKey: .ossUrl)
|
||||
coverUrl = try container.decodeIfPresent(String.self, forKey: .coverUrl)
|
||||
coverSize = try container.decodeIfPresent(Int.self, forKey: .coverSize)
|
||||
description = try container.decodeIfPresent(String.self, forKey: .description)
|
||||
scenicSpotId = try container.decodeFlexibleInt64(forKey: .scenicSpotId)
|
||||
scenicAreaId = try container.decodeIfPresent(Int.self, forKey: .scenicAreaId)
|
||||
materialTag = try container.decodeIfPresent([MaterialTagItem].self, forKey: .materialTag)
|
||||
uploaderId = try container.decodeIfPresent(Int.self, forKey: .uploaderId)
|
||||
status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0
|
||||
likesCount = try container.decodeIfPresent(Int.self, forKey: .likesCount) ?? 0
|
||||
downloadCount = try container.decodeIfPresent(Int.self, forKey: .downloadCount) ?? 0
|
||||
collectCount = try container.decodeIfPresent(Int.self, forKey: .collectCount) ?? 0
|
||||
isRecommended = try container.decodeIfPresent(Bool.self, forKey: .isRecommended) ?? false
|
||||
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt)
|
||||
updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt)
|
||||
deletedAt = try container.decodeIfPresent(String.self, forKey: .deletedAt)
|
||||
listingStatus = try container.decodeIfPresent(Int.self, forKey: .listingStatus) ?? 0
|
||||
reviewNotes = try container.decodeIfPresent(String.self, forKey: .reviewNotes)
|
||||
reviewTime = try container.decodeIfPresent(String.self, forKey: .reviewTime)
|
||||
reviewerId = try container.decodeIfPresent(Int.self, forKey: .reviewerId)
|
||||
reviewer = try container.decodeIfPresent(String.self, forKey: .reviewer)
|
||||
provinceId = try container.decodeIfPresent(Int.self, forKey: .provinceId)
|
||||
cityId = try container.decodeIfPresent(Int.self, forKey: .cityId)
|
||||
shareCount = try container.decodeIfPresent(Int.self, forKey: .shareCount) ?? 0
|
||||
isWeb = try container.decodeIfPresent(Int.self, forKey: .isWeb) ?? 0
|
||||
isMini = try container.decodeIfPresent(Int.self, forKey: .isMini) ?? 0
|
||||
uploaderName = try container.decodeIfPresent(String.self, forKey: .uploaderName)
|
||||
uploaderAvatar = try container.decodeIfPresent(String.self, forKey: .uploaderAvatar)
|
||||
uploaderDescription = try container.decodeIfPresent(String.self, forKey: .uploaderDescription)
|
||||
platformCertification = try container.decodeIfPresent(Bool.self, forKey: .platformCertification) ?? false
|
||||
scenicCertification = try container.decodeIfPresent(Bool.self, forKey: .scenicCertification) ?? false
|
||||
mediaList = try container.decodeIfPresent([MaterialDetailMediaItem].self, forKey: .mediaList)
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材标签项。
|
||||
struct MaterialTagItem: Codable, Sendable, Equatable, Hashable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
}
|
||||
|
||||
/// 素材详情媒体项,对齐 Android `MaterialDetailMediaItem`。
|
||||
struct MaterialDetailMediaItem: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||
let id: Int
|
||||
let originalName: String?
|
||||
let paidMaterialId: Int?
|
||||
let ossUrl: String?
|
||||
let thumbnailUrl: String?
|
||||
let type: Int
|
||||
let size: Int64?
|
||||
let createdAt: String?
|
||||
let updatedAt: String?
|
||||
let deletedAt: String?
|
||||
let likesCount: Int
|
||||
let downloadCount: Int
|
||||
let showUrl: String?
|
||||
let width: Int?
|
||||
let height: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case originalName = "original_name"
|
||||
case paidMaterialId = "paid_material_id"
|
||||
case ossUrl = "oss_url"
|
||||
case thumbnailUrl = "thumbnail_url"
|
||||
case type
|
||||
case size
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case deletedAt = "deleted_at"
|
||||
case likesCount = "likes_count"
|
||||
case downloadCount = "download_count"
|
||||
case showUrl = "show_url"
|
||||
case width
|
||||
case height
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
|
||||
originalName = try container.decodeIfPresent(String.self, forKey: .originalName)
|
||||
paidMaterialId = try container.decodeIfPresent(Int.self, forKey: .paidMaterialId)
|
||||
ossUrl = try container.decodeIfPresent(String.self, forKey: .ossUrl)
|
||||
thumbnailUrl = try container.decodeIfPresent(String.self, forKey: .thumbnailUrl)
|
||||
type = try container.decodeIfPresent(Int.self, forKey: .type) ?? 1
|
||||
size = try container.decodeIfPresent(Int64.self, forKey: .size)
|
||||
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt)
|
||||
updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt)
|
||||
deletedAt = try container.decodeIfPresent(String.self, forKey: .deletedAt)
|
||||
likesCount = try container.decodeIfPresent(Int.self, forKey: .likesCount) ?? 0
|
||||
downloadCount = try container.decodeIfPresent(Int.self, forKey: .downloadCount) ?? 0
|
||||
showUrl = try container.decodeIfPresent(String.self, forKey: .showUrl)
|
||||
width = try container.decodeIfPresent(Int.self, forKey: .width)
|
||||
height = try container.decodeIfPresent(Int.self, forKey: .height)
|
||||
}
|
||||
|
||||
/// 详情轮播优先展示地址。
|
||||
var displayURL: String {
|
||||
(ossUrl?.trimmingCharacters(in: .whitespacesAndNewlines).sxkNonEmpty)
|
||||
?? (showUrl?.trimmingCharacters(in: .whitespacesAndNewlines).sxkNonEmpty)
|
||||
?? ""
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材上传与编辑请求,对齐 Android `UploadMaterialRequest` / `EditMaterialRequest`。
|
||||
struct MaterialUploadRequest: Encodable, Sendable, Equatable {
|
||||
let id: Int?
|
||||
let name: String
|
||||
let type: String
|
||||
let mediaType: String
|
||||
let mediaList: [MaterialUploadMediaItem]
|
||||
let coverUrl: String
|
||||
let coverSize: String
|
||||
let scenicSpotId: Int64
|
||||
let description: String
|
||||
let materialTag: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
case type
|
||||
case mediaType = "media_type"
|
||||
case mediaList = "media_list"
|
||||
case coverUrl = "cover_url"
|
||||
case coverSize = "cover_size"
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case description
|
||||
case materialTag = "material_tag"
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int? = nil,
|
||||
name: String,
|
||||
mediaType: MaterialMediaType,
|
||||
mediaList: [MaterialUploadMediaItem],
|
||||
coverUrl: String,
|
||||
coverSize: String,
|
||||
scenicSpotId: Int64,
|
||||
materialTag: String
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.type = "1"
|
||||
self.mediaType = String(mediaType.rawValue)
|
||||
self.mediaList = mediaList
|
||||
self.coverUrl = coverUrl
|
||||
self.coverSize = coverSize
|
||||
self.scenicSpotId = scenicSpotId
|
||||
self.description = ""
|
||||
self.materialTag = materialTag
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encodeIfPresent(id, forKey: .id)
|
||||
try container.encode(name, forKey: .name)
|
||||
try container.encode(type, forKey: .type)
|
||||
try container.encode(mediaType, forKey: .mediaType)
|
||||
try container.encode(mediaList, forKey: .mediaList)
|
||||
try container.encode(coverUrl, forKey: .coverUrl)
|
||||
try container.encode(coverSize, forKey: .coverSize)
|
||||
try container.encode(scenicSpotId, forKey: .scenicSpotId)
|
||||
try container.encode(description, forKey: .description)
|
||||
try container.encode(materialTag, forKey: .materialTag)
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材上传媒体项,对齐 Android `MediaItem`。
|
||||
struct MaterialUploadMediaItem: Encodable, Sendable, Equatable {
|
||||
let originalName: String
|
||||
let ossUrl: String
|
||||
let size: Int64
|
||||
let fileWidthSize: MaterialUploadFileSize
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case originalName = "original_name"
|
||||
case ossUrl = "oss_url"
|
||||
case size
|
||||
case fileWidthSize = "file_width_size"
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材上传文件宽高。
|
||||
struct MaterialUploadFileSize: Encodable, Sendable, Equatable {
|
||||
let width: Int
|
||||
let height: Int
|
||||
}
|
||||
|
||||
/// 上传页本地媒体项,不依赖 UIKit,供 ViewModel 管理上传状态。
|
||||
struct MaterialUploadedMediaItem: Sendable, Equatable, Hashable, Identifiable {
|
||||
let id: String
|
||||
let data: Data
|
||||
let thumbnailData: Data?
|
||||
let previewURL: String?
|
||||
let fileName: String
|
||||
let size: Int64
|
||||
let width: Int
|
||||
let height: Int
|
||||
var ossUrl: String
|
||||
var isUploading: Bool
|
||||
var uploadProgress: Int
|
||||
|
||||
init(
|
||||
id: String = UUID().uuidString,
|
||||
data: Data,
|
||||
thumbnailData: Data? = nil,
|
||||
previewURL: String? = nil,
|
||||
fileName: String,
|
||||
size: Int64? = nil,
|
||||
width: Int = 0,
|
||||
height: Int = 0,
|
||||
ossUrl: String = "",
|
||||
isUploading: Bool = false,
|
||||
uploadProgress: Int = 0
|
||||
) {
|
||||
self.id = id
|
||||
self.data = data
|
||||
self.thumbnailData = thumbnailData
|
||||
self.previewURL = previewURL
|
||||
self.fileName = fileName
|
||||
self.size = size ?? Int64(data.count)
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.ossUrl = ossUrl
|
||||
self.isUploading = isUploading
|
||||
self.uploadProgress = uploadProgress
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材上传进度弹窗状态。
|
||||
struct MaterialUploadDialogState: Sendable, Equatable {
|
||||
let title: String
|
||||
let progress: Int
|
||||
}
|
||||
|
||||
/// 素材 UI 展示格式化工具。
|
||||
enum MaterialDisplayFormatter {
|
||||
/// 格式化计数,超过 1000 按 Android 规则显示 k。
|
||||
static func formatCount(_ count: Int) -> String {
|
||||
guard count >= 1000 else { return String(count) }
|
||||
let value = Double(count) / 1000.0
|
||||
if value.truncatingRemainder(dividingBy: 1) == 0 {
|
||||
return "\(Int(value))k"
|
||||
}
|
||||
return String(format: "%.1fk", value)
|
||||
}
|
||||
|
||||
/// 格式化整数,增加千位分隔符。
|
||||
static func formatNumber(_ number: Int) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
return formatter.string(from: NSNumber(value: number)) ?? String(number)
|
||||
}
|
||||
|
||||
/// 审核状态文案。
|
||||
static func reviewStatusText(_ status: Int) -> String {
|
||||
switch status {
|
||||
case 1: "已通过"
|
||||
case 0: "待审核"
|
||||
case 2: "未通过"
|
||||
default: "未知"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func decodeFlexibleString(forKey key: Key) throws -> String? {
|
||||
if let value = try decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeFlexibleInt64(forKey key: Key) throws -> Int64? {
|
||||
if let value = try decodeIfPresent(Int64.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try decodeIfPresent(Int.self, forKey: key) {
|
||||
return Int64(value)
|
||||
}
|
||||
if let value = try decodeIfPresent(String.self, forKey: key) {
|
||||
return Int64(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var sxkNonEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
@ -0,0 +1,612 @@
|
||||
//
|
||||
// MaterialManagementViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 素材 OSS 上传协议,便于上传和编辑 ViewModel 在测试中注入替身。
|
||||
@MainActor
|
||||
protocol MaterialOSSUploading {
|
||||
/// 上传素材文件并返回 OSS URL。
|
||||
func uploadMaterialFile(
|
||||
data: Data,
|
||||
fileName: String,
|
||||
fileType: Int,
|
||||
scenicId: Int,
|
||||
onProgress: @escaping (Int) -> Void
|
||||
) async throws -> String
|
||||
}
|
||||
|
||||
/// 素材列表页 ViewModel,负责搜索、筛选、分页和上下架操作。
|
||||
final class MaterialListViewModel {
|
||||
private(set) var items: [MaterialItem] = []
|
||||
private(set) var isLoading = false
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var canLoadMore = false
|
||||
private(set) var filterStatus: MaterialFilterStatus = .all
|
||||
private(set) var searchText = ""
|
||||
private(set) var orderInfo = MaterialOrderInfo()
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
private var currentPage = 1
|
||||
private var totalCount = 0
|
||||
private let pageSize = 10
|
||||
|
||||
/// 首次加载素材列表。
|
||||
func loadInitial(api: any MaterialManagementServing) async {
|
||||
await load(reset: true, showLoading: true, api: api)
|
||||
}
|
||||
|
||||
/// 下拉刷新素材列表。
|
||||
func refresh(api: any MaterialManagementServing) async {
|
||||
isRefreshing = true
|
||||
notifyStateChange()
|
||||
await load(reset: true, showLoading: false, api: api)
|
||||
}
|
||||
|
||||
/// 滚动到底部附近时加载更多。
|
||||
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any MaterialManagementServing) async {
|
||||
guard lastVisibleIndex >= items.count - 3 else { return }
|
||||
await load(reset: false, showLoading: false, api: api)
|
||||
}
|
||||
|
||||
/// 更新搜索文本并重新加载第一页。
|
||||
func updateSearchText(_ text: String, api: any MaterialManagementServing) async {
|
||||
searchText = text
|
||||
await load(reset: true, showLoading: false, api: api)
|
||||
}
|
||||
|
||||
/// 选择筛选状态并重新加载第一页。
|
||||
func selectFilter(_ status: MaterialFilterStatus, api: any MaterialManagementServing) async {
|
||||
guard filterStatus != status else { return }
|
||||
filterStatus = status
|
||||
notifyStateChange()
|
||||
await load(reset: true, showLoading: false, api: api)
|
||||
}
|
||||
|
||||
/// 切换素材上下架状态。
|
||||
func toggleListing(materialId: Int, checked: Bool, api: any MaterialManagementServing) async {
|
||||
guard let currentItem = items.first(where: { $0.id == materialId }) else { return }
|
||||
guard currentItem.status == 1 else {
|
||||
onShowMessage?("审核通过才可以操作上下架")
|
||||
notifyStateChange()
|
||||
return
|
||||
}
|
||||
|
||||
let targetStatus = checked ? 1 : 0
|
||||
guard currentItem.listingStatus != targetStatus else { return }
|
||||
|
||||
do {
|
||||
try await api.setListingStatus(id: materialId, listingStatus: targetStatus)
|
||||
items = items.map { item in
|
||||
guard item.id == materialId else { return item }
|
||||
return MaterialItem(
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
coverUrl: item.coverUrl,
|
||||
createdAt: item.createdAt,
|
||||
status: item.status,
|
||||
downloadCount: item.downloadCount,
|
||||
likesCount: item.likesCount,
|
||||
collectCount: item.collectCount,
|
||||
shareCount: item.shareCount,
|
||||
scenicSpotName: item.scenicSpotName,
|
||||
projectName: item.projectName,
|
||||
listingStatus: targetStatus
|
||||
)
|
||||
}
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从列表中移除已删除素材。
|
||||
func removeMaterial(id: Int) {
|
||||
items.removeAll { $0.id == id }
|
||||
totalCount = max(0, totalCount - 1)
|
||||
canLoadMore = items.count < totalCount
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func load(reset: Bool, showLoading: Bool, api: any MaterialManagementServing) async {
|
||||
if reset {
|
||||
currentPage = 1
|
||||
canLoadMore = false
|
||||
} else {
|
||||
guard canLoadMore, !isLoading else { return }
|
||||
currentPage += 1
|
||||
}
|
||||
|
||||
isLoading = showLoading
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines).sxkNonEmpty
|
||||
let response = try await api.list(
|
||||
status: filterStatus.apiStatus,
|
||||
keyword: keyword,
|
||||
page: currentPage,
|
||||
pageSize: pageSize
|
||||
)
|
||||
totalCount = response.total
|
||||
orderInfo = response.order ?? MaterialOrderInfo()
|
||||
items = reset ? response.list : items + response.list
|
||||
canLoadMore = items.count < totalCount
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
if !reset, currentPage > 1 {
|
||||
currentPage -= 1
|
||||
}
|
||||
if reset {
|
||||
items = []
|
||||
totalCount = 0
|
||||
canLoadMore = false
|
||||
}
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材详情页 ViewModel,负责详情加载、删除和编辑跳转状态。
|
||||
final class MaterialDetailViewModel {
|
||||
private(set) var detail: MaterialDetail?
|
||||
private(set) var isLoading = false
|
||||
private(set) var isDeleting = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onDeleted: ((Int) -> Void)?
|
||||
|
||||
let materialId: Int
|
||||
|
||||
/// 初始化素材详情 ViewModel。
|
||||
init(materialId: Int) {
|
||||
self.materialId = materialId
|
||||
}
|
||||
|
||||
/// 加载素材详情。
|
||||
func load(api: any MaterialManagementServing) async {
|
||||
guard materialId > 0 else {
|
||||
onShowMessage?("素材ID无效")
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
detail = try await api.detail(id: materialId)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "获取素材详情失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除当前素材。
|
||||
func delete(api: any MaterialManagementServing) async {
|
||||
guard materialId > 0 else {
|
||||
onShowMessage?("素材ID无效")
|
||||
return
|
||||
}
|
||||
isDeleting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isDeleting = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
try await api.delete(id: materialId)
|
||||
onShowMessage?("删除成功")
|
||||
onDeleted?(materialId)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "删除失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 素材上传与编辑页 ViewModel,负责表单、标签、选择数据、上传进度与提交。
|
||||
final class MaterialFormViewModel {
|
||||
enum Mode: Equatable {
|
||||
case create
|
||||
case edit(id: Int)
|
||||
}
|
||||
|
||||
private(set) var materialName = ""
|
||||
private(set) var selectedScenicSpot: ScenicSpotItem?
|
||||
private(set) var scenicSpotList: [ScenicSpotItem] = []
|
||||
private(set) var mediaType: MaterialMediaType = .image
|
||||
private(set) var uploadedMediaList: [MaterialUploadedMediaItem] = []
|
||||
private(set) var coverImage: MaterialUploadedMediaItem?
|
||||
private(set) var tagList: [MaterialTagItem] = []
|
||||
private(set) var tagInput = ""
|
||||
private(set) var uploadDialogState: MaterialUploadDialogState?
|
||||
private(set) var isSubmitting = false
|
||||
private(set) var isLoadingDetail = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onSubmitSuccess: (() -> Void)?
|
||||
|
||||
let mode: Mode
|
||||
private let currentScenicIdProvider: () -> Int
|
||||
private var pendingScenicSpotId: Int64?
|
||||
|
||||
/// 初始化素材表单 ViewModel。
|
||||
init(
|
||||
mode: Mode = .create,
|
||||
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }
|
||||
) {
|
||||
self.mode = mode
|
||||
self.currentScenicIdProvider = currentScenicIdProvider
|
||||
}
|
||||
|
||||
/// 初始加载,编辑模式会同时拉详情。
|
||||
func loadInitial(api: any MaterialManagementServing) async {
|
||||
await loadScenicSpots(api: api)
|
||||
if case .edit(let id) = mode {
|
||||
await loadDetail(id: id, api: api)
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新素材名称,最多保留 30 个字符。
|
||||
func updateMaterialName(_ name: String) {
|
||||
materialName = String(name.prefix(30))
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 选择关联打卡点。
|
||||
func selectScenicSpot(_ spot: ScenicSpotItem) {
|
||||
selectedScenicSpot = spot
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 切换素材类型,类型变化时清空已选素材。
|
||||
func selectMediaType(_ type: MaterialMediaType) {
|
||||
guard mediaType != type else { return }
|
||||
mediaType = type
|
||||
uploadedMediaList = []
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 更新标签输入。
|
||||
func updateTagInput(_ text: String) {
|
||||
tagInput = text
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 新增标签。
|
||||
func addTag(api: any MaterialManagementServing) async {
|
||||
let name = tagInput.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard name.count >= 5, name.count <= 15 else {
|
||||
onShowMessage?("标签长度必须在5-15个字符之间")
|
||||
return
|
||||
}
|
||||
guard tagList.count < 30 else {
|
||||
onShowMessage?("最多只能添加30个标签")
|
||||
return
|
||||
}
|
||||
guard !tagList.contains(where: { $0.name == name }) else {
|
||||
onShowMessage?("标签已存在")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let id = try await api.addTag(name: name)
|
||||
tagList.append(MaterialTagItem(id: id, name: name))
|
||||
tagInput = ""
|
||||
onShowMessage?("添加标签成功")
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "添加标签失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除本地标签。
|
||||
func deleteTag(_ tag: MaterialTagItem) {
|
||||
tagList.removeAll { $0.id == tag.id }
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 增加并立即顺序上传素材。
|
||||
func addLocalMedia(_ mediaItems: [MaterialUploadedMediaItem], uploader: any MaterialOSSUploading) async {
|
||||
guard !mediaItems.isEmpty else { return }
|
||||
let remaining = mediaType.maxCount - uploadedMediaList.count
|
||||
guard remaining > 0 else {
|
||||
onShowMessage?(mediaType == .video ? "视频只能上传1个" : "图片最多上传9张")
|
||||
return
|
||||
}
|
||||
|
||||
let accepted = Array(mediaItems.prefix(remaining))
|
||||
uploadedMediaList.append(contentsOf: accepted)
|
||||
notifyStateChange()
|
||||
|
||||
for offset in accepted.indices {
|
||||
let index = uploadedMediaList.count - accepted.count + offset
|
||||
await uploadMedia(at: index, title: "正在上传素材(\(offset + 1)/\(accepted.count))", uploader: uploader)
|
||||
}
|
||||
uploadDialogState = nil
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 设置封面并立即上传。
|
||||
func setCoverImage(_ item: MaterialUploadedMediaItem, uploader: any MaterialOSSUploading) async {
|
||||
coverImage = item
|
||||
notifyStateChange()
|
||||
await uploadCover(uploader: uploader)
|
||||
uploadDialogState = nil
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 删除素材。
|
||||
func deleteMaterial(at index: Int) {
|
||||
guard uploadedMediaList.indices.contains(index) else { return }
|
||||
uploadedMediaList.remove(at: index)
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 删除封面。
|
||||
func deleteCoverImage() {
|
||||
coverImage = nil
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 上传或编辑素材。
|
||||
func submit(api: any MaterialManagementServing) async {
|
||||
guard !materialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
onShowMessage?("请输入素材名称")
|
||||
return
|
||||
}
|
||||
guard materialName.count <= 30 else {
|
||||
onShowMessage?("素材名称最多30个字符")
|
||||
return
|
||||
}
|
||||
guard !uploadedMediaList.isEmpty else {
|
||||
onShowMessage?("请至少上传一个素材")
|
||||
return
|
||||
}
|
||||
guard let coverImage else {
|
||||
onShowMessage?("请上传封面图片")
|
||||
return
|
||||
}
|
||||
guard let selectedScenicSpot else {
|
||||
onShowMessage?("请选择关联打卡点")
|
||||
return
|
||||
}
|
||||
guard !uploadedMediaList.contains(where: { $0.ossUrl.isEmpty }) else {
|
||||
onShowMessage?("请等待素材上传完成")
|
||||
return
|
||||
}
|
||||
guard !coverImage.ossUrl.isEmpty else {
|
||||
onShowMessage?("请等待封面图片上传完成")
|
||||
return
|
||||
}
|
||||
|
||||
let request = MaterialUploadRequest(
|
||||
id: editID,
|
||||
name: materialName,
|
||||
mediaType: mediaType,
|
||||
mediaList: uploadedMediaList.map {
|
||||
MaterialUploadMediaItem(
|
||||
originalName: $0.fileName,
|
||||
ossUrl: $0.ossUrl,
|
||||
size: $0.size,
|
||||
fileWidthSize: MaterialUploadFileSize(width: $0.width, height: $0.height)
|
||||
)
|
||||
},
|
||||
coverUrl: coverImage.ossUrl,
|
||||
coverSize: String(coverImage.size),
|
||||
scenicSpotId: selectedScenicSpot.id,
|
||||
materialTag: tagList.filter { $0.id > 0 }.map { String($0.id) }.joined(separator: ",")
|
||||
)
|
||||
|
||||
isSubmitting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isSubmitting = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
switch mode {
|
||||
case .create:
|
||||
try await api.upload(request)
|
||||
onShowMessage?("上传成功")
|
||||
case .edit:
|
||||
try await api.edit(request)
|
||||
onShowMessage?("编辑成功")
|
||||
}
|
||||
onSubmitSuccess?()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "提交失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private var editID: Int? {
|
||||
if case .edit(let id) = mode { return id }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func loadScenicSpots(api: any MaterialManagementServing) async {
|
||||
let scenicId = currentScenicIdProvider()
|
||||
guard scenicId > 0 else {
|
||||
onShowMessage?("请先选择景区")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let response = try await api.scenicSpotListAll(scenicId: String(scenicId))
|
||||
scenicSpotList = response.list
|
||||
if let pendingScenicSpotId {
|
||||
selectedScenicSpot = scenicSpotList.first { $0.id == pendingScenicSpotId }
|
||||
}
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "获取打卡点失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadDetail(id: Int, api: any MaterialManagementServing) async {
|
||||
isLoadingDetail = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoadingDetail = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
let detail = try await api.detail(id: id)
|
||||
materialName = detail.name ?? ""
|
||||
mediaType = detail.mediaList?.first?.type == 2 ? .video : .image
|
||||
pendingScenicSpotId = detail.scenicSpotId
|
||||
selectedScenicSpot = scenicSpotList.first { $0.id == detail.scenicSpotId }
|
||||
tagList = detail.materialTag ?? []
|
||||
uploadedMediaList = detail.mediaList?.map { media in
|
||||
MaterialUploadedMediaItem(
|
||||
data: Data(),
|
||||
thumbnailData: nil,
|
||||
previewURL: media.displayURL,
|
||||
fileName: media.originalName ?? "material",
|
||||
size: media.size ?? 0,
|
||||
width: media.width ?? 0,
|
||||
height: media.height ?? 0,
|
||||
ossUrl: media.ossUrl ?? media.showUrl ?? "",
|
||||
isUploading: false,
|
||||
uploadProgress: 100
|
||||
)
|
||||
} ?? []
|
||||
if let coverUrl = detail.coverUrl, !coverUrl.isEmpty {
|
||||
coverImage = MaterialUploadedMediaItem(
|
||||
data: Data(),
|
||||
previewURL: coverUrl,
|
||||
fileName: "cover",
|
||||
size: Int64(detail.coverSize ?? 0),
|
||||
ossUrl: coverUrl,
|
||||
isUploading: false,
|
||||
uploadProgress: 100
|
||||
)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "加载素材详情失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadMedia(at index: Int, title: String, uploader: any MaterialOSSUploading) async {
|
||||
guard uploadedMediaList.indices.contains(index) else { return }
|
||||
guard currentScenicIdProvider() > 0 else {
|
||||
onShowMessage?("请先选择景区")
|
||||
return
|
||||
}
|
||||
uploadedMediaList[index].isUploading = true
|
||||
uploadedMediaList[index].uploadProgress = 0
|
||||
uploadDialogState = MaterialUploadDialogState(title: title, progress: 0)
|
||||
notifyStateChange()
|
||||
|
||||
do {
|
||||
let item = uploadedMediaList[index]
|
||||
let url = try await uploader.uploadMaterialFile(
|
||||
data: item.data,
|
||||
fileName: item.fileName,
|
||||
fileType: mediaType.uploadFileType,
|
||||
scenicId: currentScenicIdProvider(),
|
||||
onProgress: { [weak self] progress in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
guard self.uploadedMediaList.indices.contains(index) else { return }
|
||||
self.uploadedMediaList[index].uploadProgress = progress
|
||||
self.uploadedMediaList[index].isUploading = progress < 100
|
||||
self.uploadDialogState = MaterialUploadDialogState(title: title, progress: progress)
|
||||
self.notifyStateChange()
|
||||
}
|
||||
}
|
||||
)
|
||||
guard uploadedMediaList.indices.contains(index) else { return }
|
||||
uploadedMediaList[index].ossUrl = url
|
||||
uploadedMediaList[index].isUploading = false
|
||||
uploadedMediaList[index].uploadProgress = 100
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
guard uploadedMediaList.indices.contains(index) else { return }
|
||||
uploadedMediaList[index].isUploading = false
|
||||
uploadedMediaList[index].uploadProgress = 0
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription)
|
||||
}
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func uploadCover(uploader: any MaterialOSSUploading) async {
|
||||
guard let coverImage else { return }
|
||||
guard currentScenicIdProvider() > 0 else {
|
||||
onShowMessage?("请先选择景区")
|
||||
return
|
||||
}
|
||||
self.coverImage?.isUploading = true
|
||||
self.coverImage?.uploadProgress = 0
|
||||
uploadDialogState = MaterialUploadDialogState(title: "正在上传封面", progress: 0)
|
||||
notifyStateChange()
|
||||
|
||||
do {
|
||||
let url = try await uploader.uploadMaterialFile(
|
||||
data: coverImage.data,
|
||||
fileName: coverImage.fileName,
|
||||
fileType: 2,
|
||||
scenicId: currentScenicIdProvider(),
|
||||
onProgress: { [weak self] progress in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.coverImage?.uploadProgress = progress
|
||||
self.coverImage?.isUploading = progress < 100
|
||||
self.uploadDialogState = MaterialUploadDialogState(title: "正在上传封面", progress: progress)
|
||||
self.notifyStateChange()
|
||||
}
|
||||
}
|
||||
)
|
||||
self.coverImage?.ossUrl = url
|
||||
self.coverImage?.isUploading = false
|
||||
self.coverImage?.uploadProgress = 100
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
self.coverImage?.isUploading = false
|
||||
self.coverImage?.uploadProgress = 0
|
||||
onShowMessage?(error.localizedDescription.isEmpty ? "封面上传失败" : error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var sxkNonEmpty: String? { isEmpty ? nil : self }
|
||||
}
|
||||
@ -158,6 +158,14 @@ struct SampleMaterialOrderInfo: Decodable, Sendable, Equatable {
|
||||
self.refundTotal = refundTotal
|
||||
self.avgChange = avgChange
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
totalNum = try container.decodeIfPresent(Int.self, forKey: .totalNum) ?? 0
|
||||
avgOrderAmount = try container.decodeIfPresent(String.self, forKey: .avgOrderAmount) ?? "0"
|
||||
refundTotal = try container.decodeIfPresent(String.self, forKey: .refundTotal) ?? "0"
|
||||
avgChange = try container.decodeIfPresent(Int.self, forKey: .avgChange) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 样片详情响应,对齐 Android `SampleDetailResponse`。
|
||||
|
||||
23
suixinkan/Features/Setting/API/SettingAPI.swift
Normal file
23
suixinkan/Features/Setting/API/SettingAPI.swift
Normal file
@ -0,0 +1,23 @@
|
||||
//
|
||||
// SettingAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
/// 设置中心 API,封装版本检查等设置页相关接口。
|
||||
final class SettingAPI {
|
||||
private let client: APIClient
|
||||
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 拉取后端配置的最新 App 版本。
|
||||
func checkLatestVersion() async throws -> VersionResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/app/latest-version")
|
||||
)
|
||||
}
|
||||
}
|
||||
136
suixinkan/Features/Setting/Models/SettingModels.swift
Normal file
136
suixinkan/Features/Setting/Models/SettingModels.swift
Normal file
@ -0,0 +1,136 @@
|
||||
//
|
||||
// SettingModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 最新版本响应实体,对齐 Android `VersionResponse`。
|
||||
struct VersionResponse: Decodable, Equatable {
|
||||
let id: Int
|
||||
let type: Int
|
||||
let version: String
|
||||
let operatorName: String
|
||||
let description: String
|
||||
let status: Int
|
||||
let createdAt: String
|
||||
let updatedAt: String
|
||||
let deletedAt: String?
|
||||
let versionCode: Int
|
||||
let apkURL: String
|
||||
let isForceUpdate: Int
|
||||
let approveNotes: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case type
|
||||
case version
|
||||
case operatorName = "operator"
|
||||
case description
|
||||
case status
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case deletedAt = "deleted_at"
|
||||
case versionCode = "version_code"
|
||||
case apkURL = "apk_url"
|
||||
case isForceUpdate = "is_force_update"
|
||||
case approveNotes = "approve_notes"
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int = 0,
|
||||
type: Int = 0,
|
||||
version: String = "",
|
||||
operatorName: String = "",
|
||||
description: String = "",
|
||||
status: Int = 0,
|
||||
createdAt: String = "",
|
||||
updatedAt: String = "",
|
||||
deletedAt: String? = nil,
|
||||
versionCode: Int = 0,
|
||||
apkURL: String = "",
|
||||
isForceUpdate: Int = 0,
|
||||
approveNotes: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.type = type
|
||||
self.version = version
|
||||
self.operatorName = operatorName
|
||||
self.description = description
|
||||
self.status = status
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.deletedAt = deletedAt
|
||||
self.versionCode = versionCode
|
||||
self.apkURL = apkURL
|
||||
self.isForceUpdate = isForceUpdate
|
||||
self.approveNotes = approveNotes
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
type = try container.decodeLossyInt(forKey: .type) ?? 0
|
||||
version = try container.decodeLossyString(forKey: .version)
|
||||
operatorName = try container.decodeLossyString(forKey: .operatorName)
|
||||
description = try container.decodeLossyString(forKey: .description)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
updatedAt = try container.decodeLossyString(forKey: .updatedAt)
|
||||
deletedAt = try container.decodeIfPresent(String.self, forKey: .deletedAt)
|
||||
versionCode = try container.decodeLossyInt(forKey: .versionCode) ?? 0
|
||||
apkURL = try container.decodeLossyString(forKey: .apkURL)
|
||||
isForceUpdate = try container.decodeLossyInt(forKey: .isForceUpdate) ?? 0
|
||||
approveNotes = try container.decodeLossyString(forKey: .approveNotes)
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页协议入口实体,表示标题和对应 H5 地址。
|
||||
struct SettingAgreementDestination: Equatable {
|
||||
let title: String
|
||||
let url: URL
|
||||
}
|
||||
|
||||
/// 设置页协议类型。
|
||||
enum SettingAgreementKind: CaseIterable {
|
||||
case aboutUs
|
||||
case userAgreement
|
||||
case privacyPolicy
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "true" : "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
87
suixinkan/Features/Setting/ViewModels/SettingViewModel.swift
Normal file
87
suixinkan/Features/Setting/ViewModels/SettingViewModel.swift
Normal file
@ -0,0 +1,87 @@
|
||||
//
|
||||
// SettingViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 设置中心 ViewModel,管理版本展示、协议入口和更新标记。
|
||||
final class SettingViewModel {
|
||||
private let infoDictionary: [String: Any]?
|
||||
private let environment: APIEnvironment
|
||||
|
||||
private(set) var latestVersion: VersionResponse?
|
||||
private(set) var hasNewVersion = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
init(
|
||||
infoDictionary: [String: Any]? = Bundle.main.infoDictionary,
|
||||
environment: APIEnvironment = .current
|
||||
) {
|
||||
self.infoDictionary = infoDictionary
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
var appVersion: String {
|
||||
AppClientInfo.appVersion(infoDictionary: infoDictionary)
|
||||
}
|
||||
|
||||
var appDownloadURL: URL {
|
||||
environment.baseURL.appending(path: "h5/app/download")
|
||||
}
|
||||
|
||||
/// 静默检查最新版本,失败时保持当前页面状态。
|
||||
func checkLatestVersion(api: SettingAPI) async {
|
||||
do {
|
||||
let response = try await api.checkLatestVersion()
|
||||
latestVersion = response
|
||||
hasNewVersion = Self.isNewerVersionCode(response.versionCode, than: currentVersionCode(infoDictionary: infoDictionary))
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
latestVersion = nil
|
||||
hasNewVersion = false
|
||||
notifyStateChange()
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回指定协议入口的标题和 URL。
|
||||
func agreementDestination(for kind: SettingAgreementKind) -> SettingAgreementDestination {
|
||||
switch kind {
|
||||
case .aboutUs:
|
||||
SettingAgreementDestination(
|
||||
title: "关于我们",
|
||||
url: environment.baseURL.appending(path: "h5/app/about-us")
|
||||
)
|
||||
case .userAgreement:
|
||||
SettingAgreementDestination(
|
||||
title: "用户协议",
|
||||
url: environment.baseURL.appending(path: "h5/app/user-agreement")
|
||||
)
|
||||
case .privacyPolicy:
|
||||
SettingAgreementDestination(
|
||||
title: "隐私政策",
|
||||
url: environment.baseURL.appending(path: "h5/app/privacy-policy")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断后端版本号是否高于当前构建号。
|
||||
nonisolated static func isNewerVersionCode(_ latestVersionCode: Int, than currentVersionCode: Int?) -> Bool {
|
||||
guard let currentVersionCode else { return false }
|
||||
return latestVersionCode > currentVersionCode
|
||||
}
|
||||
|
||||
private func currentVersionCode(infoDictionary: [String: Any]?) -> Int? {
|
||||
let build = (infoDictionary?["CFBundleVersion"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let build, !build.isEmpty else { return nil }
|
||||
return Int(build)
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,8 @@ import Foundation
|
||||
protocol WildPhotographerReportServing {
|
||||
/// 拉取提交举报可选类型。
|
||||
func reportTypes() async throws -> WildReportTypeListResponse
|
||||
/// 拉取举报首页须知与规则文案。
|
||||
func reportCopy() async throws -> WildReportCopyResponse
|
||||
/// 提交摄影师举报。
|
||||
func submitReport(_ request: WildReportSubmitRequest) async throws
|
||||
/// 为已有举报补充证据。
|
||||
@ -41,6 +43,13 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing {
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取举报首页须知与规则文案。
|
||||
func reportCopy() async throws -> WildReportCopyResponse {
|
||||
try await client.send(
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/report/copy")
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交摄影师举报。
|
||||
func submitReport(_ request: WildReportSubmitRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
@ -67,9 +76,9 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing {
|
||||
func riskMap(_ request: WildReportRiskMapRequest) async throws -> WildReportRiskMapResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/report/risk-map",
|
||||
body: request
|
||||
queryItems: request.queryItems
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -115,12 +124,23 @@ struct WildReportSupplementRequest: Encodable, Equatable {
|
||||
let evidences: [WildReportEvidenceRequest]?
|
||||
}
|
||||
|
||||
/// 附近风险地图接口请求体。
|
||||
/// 附近风险地图接口请求参数。
|
||||
struct WildReportRiskMapRequest: Encodable, Equatable {
|
||||
let scenicId: Int
|
||||
let latitude: Double?
|
||||
let longitude: Double?
|
||||
|
||||
var queryItems: [URLQueryItem] {
|
||||
var items = [URLQueryItem(name: "scenic_id", value: String(scenicId))]
|
||||
if let latitude {
|
||||
items.append(URLQueryItem(name: "latitude", value: String(latitude)))
|
||||
}
|
||||
if let longitude {
|
||||
items.append(URLQueryItem(name: "longitude", value: String(longitude)))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case latitude
|
||||
|
||||
@ -8,7 +8,7 @@ import Foundation
|
||||
import MapKit
|
||||
import UIKit
|
||||
|
||||
/// 举报摄影师页面状态,用于演示加载、空态和异常态。
|
||||
/// 举报摄影师页面状态,用于加载、空态和异常态展示。
|
||||
enum WildReportPageState: String, CaseIterable, Hashable {
|
||||
case normal
|
||||
case loading
|
||||
@ -36,15 +36,6 @@ enum WildReportAttachmentKind: Hashable {
|
||||
struct WildReportType: Decodable, Hashable {
|
||||
let value: Int
|
||||
let label: String
|
||||
|
||||
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: "其他")
|
||||
]
|
||||
}
|
||||
|
||||
/// 举报类型接口返回的列表包裹。
|
||||
@ -56,7 +47,51 @@ struct WildReportTypeListResponse: Decodable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报附件实体,表示一条本地待上传或演示用的上传项。
|
||||
/// 举报首页须知与规则接口响应体。
|
||||
struct WildReportCopyResponse: Decodable, Equatable, Hashable {
|
||||
let notice: [String]
|
||||
let ruleGroups: [WildReportRuleGroup]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case notice
|
||||
case ruleGroups = "rule_groups"
|
||||
}
|
||||
|
||||
init(notice: [String] = [], ruleGroups: [WildReportRuleGroup] = []) {
|
||||
self.notice = notice
|
||||
self.ruleGroups = ruleGroups
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
notice = try container.decodeIfPresent([String].self, forKey: .notice) ?? []
|
||||
ruleGroups = try container.decodeIfPresent([WildReportRuleGroup].self, forKey: .ruleGroups) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报规则分组,用于首页规则入口与举报规则页展示。
|
||||
struct WildReportRuleGroup: Decodable, Equatable, Hashable {
|
||||
let title: String
|
||||
let items: [String]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case title
|
||||
case items
|
||||
}
|
||||
|
||||
init(title: String = "", items: [String] = []) {
|
||||
self.title = title
|
||||
self.items = items
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
|
||||
items = try container.decodeIfPresent([String].self, forKey: .items) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报附件实体,表示一条本地待上传的附件。
|
||||
struct WildReportAttachment: Hashable {
|
||||
let id: UUID
|
||||
let kind: WildReportAttachmentKind
|
||||
|
||||
@ -1,606 +0,0 @@
|
||||
//
|
||||
// WildPhotographerReportMockStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import MapKit
|
||||
|
||||
/// 举报摄影师 Mock 数据仓库,统一管理演示种子数据和地图标记构建。
|
||||
enum WildPhotographerReportMockStore {
|
||||
static let fallbackLocation = "黄山风景区 · 光明顶景区"
|
||||
|
||||
static let initialImages = [
|
||||
WildReportAttachment(kind: .image, title: "现场图片1")
|
||||
]
|
||||
|
||||
static let initialVideos = [
|
||||
WildReportAttachment(kind: .video, title: "现场视频1")
|
||||
]
|
||||
|
||||
static let seedRiskPoints = [
|
||||
WildReportRiskPoint(
|
||||
id: 1,
|
||||
title: "光明顶观景台附近",
|
||||
location: "黄山风景区 · 光明顶景区",
|
||||
distance: "120m",
|
||||
riskLevel: "高",
|
||||
evidenceCount: 6,
|
||||
latestTime: "10分钟前",
|
||||
description: "多名游客反馈有疑似未备案摄影师主动揽客,集中在观景台东侧步道附近。"
|
||||
),
|
||||
WildReportRiskPoint(
|
||||
id: 2,
|
||||
title: "迎客松游客出口",
|
||||
location: "黄山风景区 · 玉屏景区",
|
||||
distance: "860m",
|
||||
riskLevel: "中",
|
||||
evidenceCount: 3,
|
||||
latestTime: "32分钟前",
|
||||
description: "疑似人员以低价加急出片为话术引导游客线下付款,建议现场核查。"
|
||||
),
|
||||
WildReportRiskPoint(
|
||||
id: 3,
|
||||
title: "云谷索道排队区",
|
||||
location: "黄山风景区 · 云谷景区",
|
||||
distance: "1.4km",
|
||||
riskLevel: "低",
|
||||
evidenceCount: 2,
|
||||
latestTime: "1小时前",
|
||||
description: "排队区附近存在疑似私下拉客行为,已收到图片和位置线索。"
|
||||
)
|
||||
]
|
||||
|
||||
static let seedReports = [
|
||||
WildReportRecord(
|
||||
id: "JB20260618001",
|
||||
title: "摄影师举报",
|
||||
reportType: WildReportType(value: 2, label: "疑似打野"),
|
||||
status: .processing,
|
||||
location: "那拉提景区 · 空中草原入口附近",
|
||||
submitTime: "2026-06-18 10:24",
|
||||
description: "疑似人员在观景台附近主动向游客售卖拍摄服务,并引导线下转账。",
|
||||
wechatID: "wx_photographer_01",
|
||||
phoneNumber: "13812345678",
|
||||
imageCount: 3,
|
||||
videoCount: 1,
|
||||
handlerInfo: nil
|
||||
),
|
||||
WildReportRecord(
|
||||
id: "JB20260617008",
|
||||
title: "摄影师举报",
|
||||
reportType: WildReportType(value: 2, label: "疑似打野"),
|
||||
status: .processed,
|
||||
location: "那拉提景区 · 河谷草原游客中心",
|
||||
submitTime: "2026-06-17 16:42",
|
||||
description: "现场发现疑似未备案摄影师持续向游客推销旅拍套餐。",
|
||||
wechatID: "nalati_photo",
|
||||
phoneNumber: "",
|
||||
imageCount: 2,
|
||||
videoCount: 1,
|
||||
handlerInfo: processedHandlerInfo(
|
||||
handlerName: "赵强",
|
||||
handlerPhone: "15938267120",
|
||||
processingAt: "2026-06-17 17:10",
|
||||
processedAt: "2026-06-17 18:26",
|
||||
processOpinion: "已到场核查并完成劝离,处理结果已同步景区公安。",
|
||||
imageSeedPrefix: "handler-jb008"
|
||||
)
|
||||
),
|
||||
WildReportRecord(
|
||||
id: "JB20260616003",
|
||||
title: "摄影师举报",
|
||||
reportType: WildReportType(value: 3, label: "未戴工牌"),
|
||||
status: .pending,
|
||||
location: "那拉提景区 · 盘龙古道停车区",
|
||||
submitTime: "2026-06-16 09:15",
|
||||
description: "停车区附近有摄影师未佩戴工牌开展拍摄服务,已收到图片和位置线索。",
|
||||
wechatID: "",
|
||||
phoneNumber: "13987654321",
|
||||
imageCount: 1,
|
||||
videoCount: 0,
|
||||
handlerInfo: nil
|
||||
),
|
||||
WildReportRecord(
|
||||
id: "JB20260615002",
|
||||
title: "摄影师举报",
|
||||
reportType: WildReportType(value: 4, label: "其他"),
|
||||
status: .processed,
|
||||
location: "那拉提景区 · 雪莲谷步道",
|
||||
submitTime: "2026-06-15 14:30",
|
||||
description: "举报线索经核查未发现异常摄影师服务行为,已归档。",
|
||||
wechatID: "grassland_cam",
|
||||
phoneNumber: "13600001111",
|
||||
imageCount: 1,
|
||||
videoCount: 0,
|
||||
handlerInfo: processedHandlerInfo(
|
||||
handlerName: "李娜",
|
||||
handlerPhone: "18674598210",
|
||||
processingAt: "2026-06-15 15:05",
|
||||
processedAt: "2026-06-15 16:55",
|
||||
processOpinion: "现场核查未发现未备案摄影师,已归档处理。",
|
||||
imageSeedPrefix: "handler-jb002",
|
||||
includeVideo: false
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
static let seedSupplementaryEvidences: [String: [WildReportSupplementaryEvidence]] = [
|
||||
"JB20260618001": [
|
||||
WildReportSupplementaryEvidence(
|
||||
id: "SE001",
|
||||
submitTime: "2026-06-18 14:20",
|
||||
text: "补充现场视频,对方仍在揽客。",
|
||||
imageCount: 1,
|
||||
videoCount: 1
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
static let reportDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static let submitTimeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter
|
||||
}()
|
||||
|
||||
static let nalatiCenter = CLLocationCoordinate2D(latitude: 43.382, longitude: 84.032)
|
||||
|
||||
private static let clueID1 = "2026年6月22日-0001"
|
||||
private static let clueID2 = "2026年6月22日-0002"
|
||||
private static let clueID3 = "2026年6月22日-0003"
|
||||
private static let initialProcessingClueIDs: Set<String> = [clueID2]
|
||||
|
||||
/// 构建已处理举报的处理人信息 Mock 数据。
|
||||
static func processedHandlerInfo(
|
||||
handlerName: String,
|
||||
handlerPhone: String,
|
||||
processingAt: String,
|
||||
processedAt: String,
|
||||
processOpinion: String,
|
||||
imageSeedPrefix: String,
|
||||
includeVideo: Bool = true
|
||||
) -> WildReportHandlerInfo {
|
||||
var media: [WildReportCompletionMediaItem] = [
|
||||
WildReportCompletionMediaItem(
|
||||
id: "\(imageSeedPrefix)-img-1",
|
||||
kind: .image,
|
||||
coverURL: "https://picsum.photos/seed/\(imageSeedPrefix)-1/640/480",
|
||||
duration: nil
|
||||
),
|
||||
WildReportCompletionMediaItem(
|
||||
id: "\(imageSeedPrefix)-img-2",
|
||||
kind: .image,
|
||||
coverURL: "https://picsum.photos/seed/\(imageSeedPrefix)-2/640/480",
|
||||
duration: nil
|
||||
)
|
||||
]
|
||||
if includeVideo {
|
||||
media.append(
|
||||
WildReportCompletionMediaItem(
|
||||
id: "\(imageSeedPrefix)-vid-1",
|
||||
kind: .video,
|
||||
coverURL: "https://picsum.photos/seed/\(imageSeedPrefix)-video/640/480",
|
||||
duration: "00:18"
|
||||
)
|
||||
)
|
||||
}
|
||||
return WildReportHandlerInfo(
|
||||
handlerName: handlerName,
|
||||
handlerPhone: handlerPhone,
|
||||
processingAt: processingAt,
|
||||
processedAt: processedAt,
|
||||
processOpinion: processOpinion,
|
||||
completionMedia: media
|
||||
)
|
||||
}
|
||||
|
||||
/// 从已有举报编号中提取最大序号,供新编号递增。
|
||||
static func maxSequence(from reports: [WildReportRecord]) -> Int {
|
||||
reports.compactMap { record in
|
||||
guard record.id.count >= 3 else { return nil }
|
||||
return Int(record.id.suffix(3))
|
||||
}.max() ?? 0
|
||||
}
|
||||
|
||||
/// 解析联系方式,区分微信号和手机号。
|
||||
static func parseContact(_ contact: String?) -> (wechat: String, phone: String) {
|
||||
guard let contact else { return ("", "") }
|
||||
let trimmed = contact.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return ("", "") }
|
||||
|
||||
let digits = trimmed.filter(\.isNumber)
|
||||
if digits.count >= 7, trimmed.filter({ $0.isNumber || "+-() ".contains($0) }).count == trimmed.count {
|
||||
return ("", trimmed)
|
||||
}
|
||||
return (trimmed, "")
|
||||
}
|
||||
|
||||
/// 根据线索状态返回地图标记类型。
|
||||
static func markerKind(for clue: WildReportRadarActiveClue) -> WildReportMapMarkerKind {
|
||||
switch clue.type {
|
||||
case .blue:
|
||||
return .selfLocation
|
||||
case .green:
|
||||
return .peerPhotographer
|
||||
case .yellow:
|
||||
return .processingClue
|
||||
case .red:
|
||||
if clue.processingStarted && !clue.completed {
|
||||
return .processingClue
|
||||
}
|
||||
return .wildPhotographer
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建风险地图全部标记点。
|
||||
static func buildMarkers(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) -> [WildReportMapMarker] {
|
||||
_ = record
|
||||
_ = riskPoints
|
||||
|
||||
let blueCoordinate = CLLocationCoordinate2D(latitude: 43.3779, longitude: 84.0217)
|
||||
let redSeeds: [(id: String, name: String, coordinate: CLLocationCoordinate2D, clue: WildReportRadarActiveClue)] = [
|
||||
redSeed(
|
||||
id: clueID1,
|
||||
name: "空中草原观景道",
|
||||
coordinate: CLLocationCoordinate2D(latitude: 43.4068, longitude: 84.0582),
|
||||
updatedAt: "5分钟前",
|
||||
distanceText: "320米",
|
||||
reporterName: "张三",
|
||||
reporterPhone: "13333333333",
|
||||
contents: [
|
||||
"多人结伴揽客,疑似无资质摄影团队,携带专业器材在观景道附近活动。",
|
||||
"补充证据:对方仍在向游客推销拍照套餐,并拒绝出示相关资质。"
|
||||
],
|
||||
imageSeeds: ["nalati-radar-1", "nalati-radar-2", "nalati-radar-3"]
|
||||
),
|
||||
redSeed(
|
||||
id: clueID2,
|
||||
name: "塔吾萨尼步道",
|
||||
coordinate: CLLocationCoordinate2D(latitude: 43.3652, longitude: 84.0385),
|
||||
updatedAt: "18分钟前",
|
||||
distanceText: "780米",
|
||||
reporterName: "李四",
|
||||
reporterPhone: "18674598210",
|
||||
contents: [
|
||||
"疑似二次转移,仍在塔吾萨尼步道附近揽客拍照。",
|
||||
"补充:对方穿黑色外套,携带三脚架,在步道入口反复搭讪游客。"
|
||||
],
|
||||
imageSeeds: ["nalati-radar-4", "nalati-radar-5"]
|
||||
),
|
||||
redSeed(
|
||||
id: clueID3,
|
||||
name: "河谷草原西南侧",
|
||||
coordinate: CLLocationCoordinate2D(latitude: 43.3546, longitude: 84.0136),
|
||||
updatedAt: "昨天17:45",
|
||||
distanceText: "910米",
|
||||
reporterName: "王五",
|
||||
reporterPhone: "15938267120",
|
||||
contents: ["游客反馈有人在步道旁揽客拍照,行为较为可疑。"],
|
||||
imageSeeds: ["nalati-radar-8"]
|
||||
)
|
||||
]
|
||||
|
||||
let nearestText = nearestAbnormalText(
|
||||
blueCoordinate: blueCoordinate,
|
||||
redCoordinates: redSeeds.map(\.coordinate)
|
||||
)
|
||||
|
||||
var markers: [WildReportMapMarker] = [
|
||||
WildReportMapMarker(
|
||||
id: "ME",
|
||||
title: "河谷草原入口",
|
||||
coordinate: blueCoordinate,
|
||||
kind: .selfLocation,
|
||||
detail: .activeClue(
|
||||
WildReportRadarActiveClue(
|
||||
id: "ME",
|
||||
type: .blue,
|
||||
displayTitle: "当前位置:河谷草原入口",
|
||||
statusText: "当前位置",
|
||||
actionText: "刷新定位",
|
||||
nearestAbnormalText: nearestText,
|
||||
distance: "0 米",
|
||||
direction: "蓝色基准点",
|
||||
updatedAt: "实时定位",
|
||||
name: "河谷草原入口",
|
||||
storeName: nil,
|
||||
avatar: nil,
|
||||
summary: "蓝色标记为当前位置,可对比周边摄影师风险点位距离。",
|
||||
detail: "当前定位在河谷草原入口附近,建议优先关注 800 米内的异常点位。",
|
||||
distanceText: nil,
|
||||
reporterName: nil,
|
||||
reporterPhone: nil,
|
||||
maskedReporterPhone: nil,
|
||||
reportContents: [],
|
||||
images: [],
|
||||
processingStarted: false,
|
||||
completed: false,
|
||||
record: nil,
|
||||
processingTimeline: [],
|
||||
completionPhoto: nil
|
||||
)
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
for seed in redSeeds {
|
||||
let processingStarted = initialProcessingClueIDs.contains(seed.id)
|
||||
let clue = enrichClue(seed.clue, processingStarted: processingStarted)
|
||||
markers.append(
|
||||
WildReportMapMarker(
|
||||
id: seed.id,
|
||||
title: seed.name,
|
||||
coordinate: seed.coordinate,
|
||||
kind: markerKind(for: clue),
|
||||
detail: .activeClue(clue)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
markers.append(contentsOf: greenMarkers())
|
||||
return markers
|
||||
}
|
||||
|
||||
/// 根据全部标记计算地图可视区域。
|
||||
static func region(for markers: [WildReportMapMarker]) -> MKCoordinateRegion {
|
||||
guard let first = markers.first else {
|
||||
return MKCoordinateRegion(
|
||||
center: nalatiCenter,
|
||||
span: MKCoordinateSpan(latitudeDelta: 0.025, longitudeDelta: 0.025)
|
||||
)
|
||||
}
|
||||
|
||||
var minLat = first.coordinate.latitude
|
||||
var maxLat = first.coordinate.latitude
|
||||
var minLon = first.coordinate.longitude
|
||||
var maxLon = first.coordinate.longitude
|
||||
|
||||
for marker in markers {
|
||||
minLat = min(minLat, marker.coordinate.latitude)
|
||||
maxLat = max(maxLat, marker.coordinate.latitude)
|
||||
minLon = min(minLon, marker.coordinate.longitude)
|
||||
maxLon = max(maxLon, marker.coordinate.longitude)
|
||||
}
|
||||
|
||||
let center = CLLocationCoordinate2D(
|
||||
latitude: (minLat + maxLat) / 2,
|
||||
longitude: (minLon + maxLon) / 2
|
||||
)
|
||||
let span = MKCoordinateSpan(
|
||||
latitudeDelta: max(0.022, (maxLat - minLat) * 1.8),
|
||||
longitudeDelta: max(0.022, (maxLon - minLon) * 1.8)
|
||||
)
|
||||
return MKCoordinateRegion(center: center, span: span)
|
||||
}
|
||||
|
||||
private static func redSeed(
|
||||
id: String,
|
||||
name: String,
|
||||
coordinate: CLLocationCoordinate2D,
|
||||
updatedAt: String,
|
||||
distanceText: String,
|
||||
reporterName: String,
|
||||
reporterPhone: String,
|
||||
contents: [String],
|
||||
imageSeeds: [String]
|
||||
) -> (id: String, name: String, coordinate: CLLocationCoordinate2D, clue: WildReportRadarActiveClue) {
|
||||
let reportContents = contents.enumerated().map { index, content in
|
||||
WildReportRadarReportContent(time: index == 0 ? "14:32:18" : "14:45:06", content: content)
|
||||
}
|
||||
return (
|
||||
id,
|
||||
name,
|
||||
coordinate,
|
||||
WildReportRadarActiveClue(
|
||||
id: id,
|
||||
type: .red,
|
||||
displayTitle: "线索 ID:\(id)",
|
||||
statusText: "疑似打野",
|
||||
actionText: "导航到此",
|
||||
nearestAbnormalText: nil,
|
||||
distance: nil,
|
||||
direction: nil,
|
||||
updatedAt: updatedAt,
|
||||
name: name,
|
||||
storeName: nil,
|
||||
avatar: nil,
|
||||
summary: nil,
|
||||
detail: nil,
|
||||
distanceText: distanceText,
|
||||
reporterName: reporterName,
|
||||
reporterPhone: reporterPhone,
|
||||
maskedReporterPhone: maskPhone(reporterPhone),
|
||||
reportContents: reportContents,
|
||||
images: imageSeeds.map { "https://picsum.photos/seed/\($0)/640/480" },
|
||||
processingStarted: false,
|
||||
completed: false,
|
||||
record: nil,
|
||||
processingTimeline: [],
|
||||
completionPhoto: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func greenMarkers() -> [WildReportMapMarker] {
|
||||
let seeds: [(id: String, coordinate: CLLocationCoordinate2D, name: String, store: String, detail: String)] = [
|
||||
("PG20240618001", CLLocationCoordinate2D(latitude: 43.4042, longitude: 84.0186), "阿依达娜", "空中草原旅拍店", "当前在空中草原区域服务游客拍摄。"),
|
||||
("PG20240618002", CLLocationCoordinate2D(latitude: 43.3985, longitude: 84.0408), "波拉提", "天界台摄影服务点", "当前在天界台附近接待预约游客。"),
|
||||
("PG20240618003", CLLocationCoordinate2D(latitude: 43.3828, longitude: 84.0794), "米热丽", "塔吾萨尼影像馆", "当前在塔吾萨尼步道附近提供旅拍服务。")
|
||||
]
|
||||
|
||||
return seeds.map { seed in
|
||||
let clue = WildReportRadarActiveClue(
|
||||
id: seed.id,
|
||||
type: .green,
|
||||
displayTitle: "认证摄影师",
|
||||
statusText: "认证摄影师",
|
||||
actionText: "导航到此",
|
||||
nearestAbnormalText: nil,
|
||||
distance: "430 米",
|
||||
direction: "景区内",
|
||||
updatedAt: "在线",
|
||||
name: seed.name,
|
||||
storeName: seed.store,
|
||||
avatar: "person.crop.circle.fill",
|
||||
summary: "绿色标记为已认证摄影师,可与红色摄影师风险点位区分。",
|
||||
detail: seed.detail,
|
||||
distanceText: nil,
|
||||
reporterName: nil,
|
||||
reporterPhone: nil,
|
||||
maskedReporterPhone: nil,
|
||||
reportContents: [],
|
||||
images: [],
|
||||
processingStarted: false,
|
||||
completed: false,
|
||||
record: nil,
|
||||
processingTimeline: [],
|
||||
completionPhoto: nil
|
||||
)
|
||||
return WildReportMapMarker(
|
||||
id: seed.id,
|
||||
title: seed.name,
|
||||
coordinate: seed.coordinate,
|
||||
kind: .peerPhotographer,
|
||||
detail: .activeClue(clue)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func processRecord(for clueID: String) -> WildReportRadarProcessRecord? {
|
||||
switch clueID {
|
||||
case clueID1:
|
||||
return WildReportRadarProcessRecord(
|
||||
handlerName: "王磊",
|
||||
phone: "13812346721",
|
||||
maskedPhone: maskPhone("13812346721"),
|
||||
statusText: "待处理",
|
||||
processResult: "待现场核查,请前往空中草原观景道确认摄影师线索。",
|
||||
processedAt: nil,
|
||||
processingAt: nil
|
||||
)
|
||||
case clueID2:
|
||||
return WildReportRadarProcessRecord(
|
||||
handlerName: "李娜",
|
||||
phone: "18674598210",
|
||||
maskedPhone: maskPhone("18674598210"),
|
||||
statusText: "处理中",
|
||||
processResult: "待现场核查,请前往塔吾萨尼步道确认摄影师线索。",
|
||||
processedAt: nil,
|
||||
processingAt: "2024-05-18 10:35"
|
||||
)
|
||||
case clueID3:
|
||||
return WildReportRadarProcessRecord(
|
||||
handlerName: "赵强",
|
||||
phone: "15938267120",
|
||||
maskedPhone: maskPhone("15938267120"),
|
||||
statusText: "已处理",
|
||||
processResult: "已到场核查并完成劝离,处理结果已同步景区公安。",
|
||||
processedAt: "2024-05-17 18:26",
|
||||
processingAt: "2024-05-17 17:45"
|
||||
)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func buildProcessingTimeline(
|
||||
record: WildReportRadarProcessRecord,
|
||||
completed: Bool
|
||||
) -> [WildReportRadarProcessingTimelineItem] {
|
||||
let startedAt = record.processingAt ?? "2024-05-18 10:35"
|
||||
return [
|
||||
WildReportRadarProcessingTimelineItem(title: "处理时间", time: startedAt, status: "done"),
|
||||
WildReportRadarProcessingTimelineItem(
|
||||
title: "处理完成时间",
|
||||
time: completed ? (record.processedAt ?? "刚刚") : "待完成",
|
||||
status: completed ? "done" : "current"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private static func enrichClue(
|
||||
_ base: WildReportRadarActiveClue,
|
||||
processingStarted: Bool,
|
||||
completed: Bool = false,
|
||||
completionPhoto: String? = nil
|
||||
) -> WildReportRadarActiveClue {
|
||||
let record = processRecord(for: base.id)
|
||||
let isProcessing = processingStarted && !completed
|
||||
let timeline = (isProcessing || completed) && record != nil
|
||||
? buildProcessingTimeline(record: record!, completed: completed)
|
||||
: []
|
||||
|
||||
return WildReportRadarActiveClue(
|
||||
id: base.id,
|
||||
type: base.type,
|
||||
displayTitle: base.displayTitle,
|
||||
statusText: completed ? "已处理" : (isProcessing ? "正在处理中" : base.statusText),
|
||||
actionText: base.actionText,
|
||||
nearestAbnormalText: base.nearestAbnormalText,
|
||||
distance: base.distance,
|
||||
direction: base.direction,
|
||||
updatedAt: completed ? (record?.processedAt ?? "刚刚") : base.updatedAt,
|
||||
name: base.name,
|
||||
storeName: base.storeName,
|
||||
avatar: base.avatar,
|
||||
summary: base.summary,
|
||||
detail: base.detail,
|
||||
distanceText: base.distanceText,
|
||||
reporterName: base.reporterName,
|
||||
reporterPhone: base.reporterPhone,
|
||||
maskedReporterPhone: base.maskedReporterPhone,
|
||||
reportContents: base.reportContents,
|
||||
images: base.images,
|
||||
processingStarted: processingStarted || completed,
|
||||
completed: completed,
|
||||
record: (isProcessing || completed) ? record : nil,
|
||||
processingTimeline: timeline,
|
||||
completionPhoto: completionPhoto
|
||||
)
|
||||
}
|
||||
|
||||
private static func maskPhone(_ phone: String) -> String {
|
||||
guard phone.count == 11, phone.allSatisfy(\.isNumber) else { return phone }
|
||||
return "\(phone.prefix(3))****\(phone.suffix(4))"
|
||||
}
|
||||
|
||||
private static func nearestAbnormalText(
|
||||
blueCoordinate: CLLocationCoordinate2D,
|
||||
redCoordinates: [CLLocationCoordinate2D]
|
||||
) -> String {
|
||||
guard !redCoordinates.isEmpty else { return "距离最近的异常点位0.00KM" }
|
||||
let minKm = redCoordinates.map {
|
||||
calcDistanceKm(
|
||||
lat1: blueCoordinate.latitude,
|
||||
lon1: blueCoordinate.longitude,
|
||||
lat2: $0.latitude,
|
||||
lon2: $0.longitude
|
||||
)
|
||||
}.min() ?? 0
|
||||
return String(format: "距离最近的异常点位%.2fKM", minKm)
|
||||
}
|
||||
|
||||
private static func calcDistanceKm(
|
||||
lat1: Double,
|
||||
lon1: Double,
|
||||
lat2: Double,
|
||||
lon2: Double
|
||||
) -> Double {
|
||||
let earthRadiusKm = 6371.0
|
||||
let toRad = { (deg: Double) in deg * .pi / 180 }
|
||||
let dLat = toRad(lat2 - lat1)
|
||||
let dLon = toRad(lon2 - lon1)
|
||||
let a = sin(dLat / 2) * sin(dLat / 2)
|
||||
+ cos(toRad(lat1)) * cos(toRad(lat2)) * sin(dLon / 2) * sin(dLon / 2)
|
||||
return earthRadiusKm * 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
}
|
||||
}
|
||||
@ -22,126 +22,83 @@ protocol WildReportAttachmentUploading {
|
||||
|
||||
extension OSSUploadService: WildReportAttachmentUploading {}
|
||||
|
||||
/// 举报摄影师首页 ViewModel,持有 Mock 举报数据并供子页面共享。
|
||||
/// 举报摄影师模块通用日期格式化工具。
|
||||
enum WildReportDateFormatters {
|
||||
static let displayDateTime: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.timeZone = .current
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
/// 举报摄影师模块通用联系方式解析工具。
|
||||
enum WildReportContactParser {
|
||||
/// 将用户输入的联系方式拆分为手机号和微信号。
|
||||
static func parse(_ contact: String?) -> (phone: String?, wechat: String?) {
|
||||
let trimmed = contact?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty else { return (nil, nil) }
|
||||
let digits = trimmed.filter(\.isNumber)
|
||||
if digits.count >= 7 {
|
||||
return (trimmed, nil)
|
||||
}
|
||||
return (nil, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报摄影师首页 ViewModel,维护首页远端文案和跨页面共享状态。
|
||||
final class WildPhotographerReportHomeViewModel {
|
||||
private(set) var pageState: WildReportPageState = .normal
|
||||
private(set) var reports: [WildReportRecord] = []
|
||||
private(set) var riskPoints: [WildReportRiskPoint] = []
|
||||
private(set) var supplementaryEvidenceMap: [String: [WildReportSupplementaryEvidence]] = [:]
|
||||
private(set) var noticeItems: [String] = []
|
||||
private(set) var ruleGroups: [WildReportRuleGroup] = []
|
||||
private(set) var isLoadingCopy = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
private var nextSequence = 0
|
||||
private var nextSupplementSequence = 0
|
||||
|
||||
init() {
|
||||
resetDemoData(notify: false)
|
||||
}
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
var emptyTitle: String { "暂无举报服务" }
|
||||
var emptyMessage: String { "当前景区暂未开放举报摄影师演示入口。" }
|
||||
var emptyMessage: String { "当前景区暂未开放举报摄影师入口。" }
|
||||
var shouldShowNoticeModule: Bool { !noticeItems.isEmpty || !ruleGroups.isEmpty }
|
||||
var shouldShowRuleButton: Bool { !ruleGroups.isEmpty }
|
||||
|
||||
/// 恢复演示数据并回到正常状态。
|
||||
func restoreDemoData() {
|
||||
resetDemoData(notify: false)
|
||||
pageState = .normal
|
||||
/// 加载首页举报须知与规则文案。
|
||||
func loadReportCopy(api: any WildPhotographerReportServing) async {
|
||||
guard !isLoadingCopy else { return }
|
||||
isLoadingCopy = true
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 模拟错误后重新加载。
|
||||
func retryLoadAfterError() {
|
||||
pageState = .loading
|
||||
notifyStateChange()
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 450_000_000)
|
||||
pageState = .normal
|
||||
defer {
|
||||
isLoadingCopy = false
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
let response = try await api.reportCopy()
|
||||
noticeItems = response.notice
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
ruleGroups = response.ruleGroups.compactMap { group in
|
||||
let title = group.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let items = group.items
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard !title.isEmpty || !items.isEmpty else { return nil }
|
||||
return WildReportRuleGroup(title: title, items: items)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
noticeItems = []
|
||||
ruleGroups = []
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换页面演示状态。
|
||||
/// 切换页面状态。
|
||||
func setPageState(_ state: WildReportPageState) {
|
||||
pageState = state
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 提交举报并写入 Mock 列表,返回新记录。
|
||||
@discardableResult
|
||||
func submitReport(
|
||||
reportType: WildReportType,
|
||||
description: String,
|
||||
location: String,
|
||||
contact: String?,
|
||||
imageCount: Int,
|
||||
videoCount: Int
|
||||
) -> WildReportRecord {
|
||||
let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let parsedContact = WildPhotographerReportMockStore.parseContact(contact)
|
||||
let record = WildReportRecord(
|
||||
id: nextReportID(),
|
||||
title: "摄影师举报",
|
||||
reportType: reportType,
|
||||
status: .pending,
|
||||
location: location,
|
||||
submitTime: WildPhotographerReportMockStore.submitTimeFormatter.string(from: Date()),
|
||||
description: trimmedDescription,
|
||||
wechatID: parsedContact.wechat,
|
||||
phoneNumber: parsedContact.phone,
|
||||
imageCount: imageCount,
|
||||
videoCount: videoCount,
|
||||
handlerInfo: nil
|
||||
)
|
||||
reports.insert(record, at: 0)
|
||||
notifyStateChange()
|
||||
return record
|
||||
}
|
||||
|
||||
/// 读取指定举报的补充证据列表。
|
||||
func supplementaryEvidences(for reportID: String) -> [WildReportSupplementaryEvidence] {
|
||||
supplementaryEvidenceMap[reportID] ?? []
|
||||
}
|
||||
|
||||
/// 追加一条补充证据记录。
|
||||
func appendSupplementaryEvidence(
|
||||
reportID: String,
|
||||
text: String,
|
||||
imageCount: Int,
|
||||
videoCount: Int
|
||||
) {
|
||||
let item = WildReportSupplementaryEvidence(
|
||||
id: nextSupplementID(),
|
||||
submitTime: WildPhotographerReportMockStore.submitTimeFormatter.string(from: Date()),
|
||||
text: text.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
imageCount: imageCount,
|
||||
videoCount: videoCount
|
||||
)
|
||||
var items = supplementaryEvidenceMap[reportID] ?? []
|
||||
items.insert(item, at: 0)
|
||||
supplementaryEvidenceMap[reportID] = items
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 重置为种子 Mock 数据。
|
||||
func resetDemoData(notify: Bool = true) {
|
||||
reports = WildPhotographerReportMockStore.seedReports
|
||||
riskPoints = WildPhotographerReportMockStore.seedRiskPoints
|
||||
supplementaryEvidenceMap = WildPhotographerReportMockStore.seedSupplementaryEvidences
|
||||
nextSequence = WildPhotographerReportMockStore.maxSequence(from: WildPhotographerReportMockStore.seedReports) + 1
|
||||
nextSupplementSequence = 100
|
||||
if notify { notifyStateChange() }
|
||||
}
|
||||
|
||||
private func nextSupplementID() -> String {
|
||||
defer { nextSupplementSequence += 1 }
|
||||
return String(format: "SE%03d", nextSupplementSequence)
|
||||
}
|
||||
|
||||
private func nextReportID() -> String {
|
||||
let datePart = WildPhotographerReportMockStore.reportDateFormatter.string(from: Date())
|
||||
defer { nextSequence += 1 }
|
||||
return String(format: "JB\(datePart)%03d", nextSequence)
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
@ -153,8 +110,8 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
private(set) var images: [WildReportAttachment]
|
||||
private(set) var videos: [WildReportAttachment]
|
||||
private(set) var paymentImages: [WildReportAttachment] = []
|
||||
private(set) var reportTypes: [WildReportType] = WildReportType.fallbackOptions
|
||||
private(set) var selectedReportType: WildReportType = .defaultSelected
|
||||
private(set) var reportTypes: [WildReportType] = []
|
||||
private(set) var selectedReportType: WildReportType?
|
||||
private(set) var description = ""
|
||||
private(set) var contact = ""
|
||||
private(set) var reportLocation = ""
|
||||
@ -209,7 +166,7 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
refreshCurrentLocation()
|
||||
}
|
||||
|
||||
/// 从服务端加载举报类型,失败时回退到本地兜底选项。
|
||||
/// 从服务端加载举报类型。
|
||||
func loadReportTypes(api: any WildPhotographerReportServing) async {
|
||||
guard !isLoadingReportTypes else { return }
|
||||
isLoadingReportTypes = true
|
||||
@ -222,22 +179,19 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
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 }) {
|
||||
reportTypes = loadedTypes
|
||||
if let currentSelectedType = selectedReportType,
|
||||
let updatedType = reportTypes.first(where: { $0.value == currentSelectedType.value }) {
|
||||
selectedReportType = updatedType
|
||||
} else {
|
||||
selectedReportType = reportTypes.first ?? .defaultSelected
|
||||
selectedReportType = reportTypes.first
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
reportTypes = WildReportType.fallbackOptions
|
||||
if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) {
|
||||
selectedReportType = updatedType
|
||||
} else {
|
||||
selectedReportType = .defaultSelected
|
||||
}
|
||||
onShowMessage?("举报类型获取失败,已使用默认类型")
|
||||
reportTypes = []
|
||||
selectedReportType = nil
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@ -270,11 +224,6 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 添加演示用现场图片。
|
||||
func addImage() {
|
||||
addImages([WildReportAttachment(kind: .image, title: "现场图片\(images.count + 1)")])
|
||||
}
|
||||
|
||||
/// 添加本地现场视频。
|
||||
func addVideos(_ attachments: [WildReportAttachment]) {
|
||||
let videoRemaining = max(0, 3 - videos.count)
|
||||
@ -288,11 +237,6 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 添加演示用现场视频。
|
||||
func addVideo() {
|
||||
addVideos([WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)")])
|
||||
}
|
||||
|
||||
/// 添加本地支付截图。
|
||||
func addPaymentImages(_ attachments: [WildReportAttachment]) {
|
||||
guard paymentImages.count < 3 else {
|
||||
@ -303,11 +247,6 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 添加演示用支付截图。
|
||||
func addPaymentImage() {
|
||||
addPaymentImages([WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)")])
|
||||
}
|
||||
|
||||
/// 删除指定附件。
|
||||
func removeAttachment(_ attachment: WildReportAttachment) {
|
||||
images.removeAll { $0.id == attachment.id }
|
||||
@ -329,11 +268,7 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
let snapshot = try await locationProvider.requestSnapshot()
|
||||
applyResolvedLocation(snapshot)
|
||||
} catch {
|
||||
applyResolvedLocation(HomeLocationSnapshot(
|
||||
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
|
||||
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
|
||||
address: WildPhotographerReportMockStore.fallbackLocation
|
||||
))
|
||||
applyLocationFailure(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -389,14 +324,7 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
paymentScreenshots: paymentRequests
|
||||
))
|
||||
|
||||
let record = homeViewModel.submitReport(
|
||||
reportType: selectedReportType,
|
||||
description: description,
|
||||
location: reportLocation,
|
||||
contact: contact.isEmpty ? nil : contact,
|
||||
imageCount: images.count,
|
||||
videoCount: videos.count
|
||||
)
|
||||
let record = buildSubmittedRecord()
|
||||
let result = WildReportSubmitResult(
|
||||
record: record,
|
||||
imageCount: images.count,
|
||||
@ -412,36 +340,13 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验表单并写入本地 Mock 记录。
|
||||
func submitReport() {
|
||||
validationMessage = nil
|
||||
guard validateBeforeSubmit(scenicId: 1) else { return }
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// 重置表单到初始状态。
|
||||
func resetForm() {
|
||||
images = []
|
||||
videos = []
|
||||
paymentImages = []
|
||||
reportTypes = WildReportType.fallbackOptions
|
||||
selectedReportType = .defaultSelected
|
||||
reportTypes = []
|
||||
selectedReportType = nil
|
||||
description = ""
|
||||
contact = ""
|
||||
reportLocation = ""
|
||||
@ -456,14 +361,6 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
func applyResolvedLocation(_ location: String) {
|
||||
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)
|
||||
@ -479,6 +376,15 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func applyLocationFailure(_ message: String) {
|
||||
reportLocation = ""
|
||||
locationSnapshot = nil
|
||||
locationConfirmed = false
|
||||
isUpdatingLocation = false
|
||||
notifyStateChange()
|
||||
onShowMessage?(message)
|
||||
}
|
||||
|
||||
private func setValidationMessage(_ message: String) {
|
||||
validationMessage = message
|
||||
notifyStateChange()
|
||||
@ -503,6 +409,10 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
setValidationMessage("请先选择景区后再提交举报。")
|
||||
return false
|
||||
}
|
||||
guard selectedReportType != nil else {
|
||||
setValidationMessage("请先选择举报类型。")
|
||||
return false
|
||||
}
|
||||
guard !images.isEmpty || !videos.isEmpty else {
|
||||
setValidationMessage("请至少上传一项现场证据。")
|
||||
return false
|
||||
@ -527,6 +437,10 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
setValidationMessage("请先更新举报位置后再提交举报。")
|
||||
return false
|
||||
}
|
||||
guard locationSnapshot != nil else {
|
||||
setValidationMessage("缺少定位坐标,请重新更新举报位置。")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@ -572,15 +486,11 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
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 snapshot = locationSnapshot ?? HomeLocationSnapshot(latitude: 0, longitude: 0, address: reportLocation)
|
||||
let trimmedContact = contact.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return WildReportSubmitRequest(
|
||||
scenicId: scenicId,
|
||||
reportType: selectedReportType.value,
|
||||
reportType: selectedReportType?.value ?? 0,
|
||||
desc: description.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
storeUserId: nil,
|
||||
contactPhone: trimmedContact.isEmpty ? nil : trimmedContact,
|
||||
@ -592,6 +502,25 @@ final class WildPhotographerReportSubmitViewModel {
|
||||
paymentScreenshots: paymentScreenshots
|
||||
)
|
||||
}
|
||||
|
||||
private func buildSubmittedRecord() -> WildReportRecord {
|
||||
let parsedContact = WildReportContactParser.parse(contact)
|
||||
return WildReportRecord(
|
||||
serverID: nil,
|
||||
id: "",
|
||||
title: "摄影师举报",
|
||||
reportType: selectedReportType ?? WildReportType(value: 0, label: ""),
|
||||
status: .pending,
|
||||
location: reportLocation,
|
||||
submitTime: WildReportDateFormatters.displayDateTime.string(from: Date()),
|
||||
description: description.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
wechatID: parsedContact.wechat ?? "",
|
||||
phoneNumber: parsedContact.phone ?? "",
|
||||
imageCount: images.count,
|
||||
videoCount: videos.count,
|
||||
handlerInfo: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报成功页 ViewModel。
|
||||
@ -642,6 +571,17 @@ final class WildPhotographerReportDetailViewModel {
|
||||
let detail = WildReportLocationParser.split(record.location).detail
|
||||
return detail.contains("附近") ? detail : "\(detail)附近"
|
||||
}
|
||||
var mapCoordinate: CLLocationCoordinate2D? {
|
||||
guard let latitudeText = detail?.latitude,
|
||||
let longitudeText = detail?.longitude,
|
||||
let latitude = Double(latitudeText),
|
||||
let longitude = Double(longitudeText) else {
|
||||
return nil
|
||||
}
|
||||
let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||
guard CLLocationCoordinate2DIsValid(coordinate), latitude != 0 || longitude != 0 else { return nil }
|
||||
return coordinate
|
||||
}
|
||||
|
||||
var displayStatusText: String { detail?.displayStatusText ?? record.status.rawValue }
|
||||
var descriptionText: String { record.description }
|
||||
@ -651,7 +591,7 @@ final class WildPhotographerReportDetailViewModel {
|
||||
if let detail {
|
||||
return detail.supplementRecords
|
||||
}
|
||||
return homeViewModel?.supplementaryEvidences(for: record.id) ?? []
|
||||
return []
|
||||
}
|
||||
var reporterMediaItems: [WildReportReporterMediaItem] {
|
||||
if let detail {
|
||||
@ -718,7 +658,7 @@ final class WildPhotographerReportDetailViewModel {
|
||||
|
||||
/// 展示媒体预览提示。
|
||||
func showMediaPreview(kind: String) {
|
||||
showActionMessage("演示环境暂不支持预览\(kind),可在提交页查看上传内容。")
|
||||
showActionMessage("暂不支持预览\(kind)")
|
||||
}
|
||||
|
||||
/// 展示位置预览提示。
|
||||
@ -738,30 +678,14 @@ final class WildPhotographerReportDetailViewModel {
|
||||
}
|
||||
|
||||
private static func buildProgressSteps(for record: WildReportRecord) -> [WildReportProgressStep] {
|
||||
let baseDate = submitDate(from: record.submitTime) ?? Date()
|
||||
let expectedFeedback = formatDate(baseDate.addingTimeInterval(8 * 3_600))
|
||||
|
||||
switch record.status {
|
||||
case .pending, .processing:
|
||||
return [
|
||||
makeStep(id: "1", title: "已提交", date: baseDate, state: .completed),
|
||||
makeStep(id: "2", title: "已派工作人员前往", date: baseDate.addingTimeInterval(22 * 60), state: .completed),
|
||||
makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .current),
|
||||
makeStep(id: "4", title: "待结果反馈", date: nil, state: .pending, pendingText: "预计 \(expectedFeedback) 前")
|
||||
]
|
||||
case .processed, .rejected:
|
||||
return [
|
||||
makeStep(id: "1", title: "已提交", date: baseDate, state: .completed),
|
||||
makeStep(id: "2", title: "已派工作人员前往", date: baseDate.addingTimeInterval(22 * 60), state: .completed),
|
||||
makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .completed),
|
||||
makeStep(
|
||||
id: "4",
|
||||
title: record.status == .rejected ? "已驳回举报" : "已反馈核查结果",
|
||||
date: baseDate.addingTimeInterval(3 * 3_600),
|
||||
state: .completed
|
||||
)
|
||||
]
|
||||
}
|
||||
[
|
||||
WildReportProgressStep(
|
||||
id: "current-status",
|
||||
title: record.status.rawValue,
|
||||
timeText: record.submitTime,
|
||||
state: record.status == .pending || record.status == .processing ? .current : .completed
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private static func buildProgressSteps(
|
||||
@ -780,29 +704,6 @@ final class WildPhotographerReportDetailViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeStep(
|
||||
id: String,
|
||||
title: String,
|
||||
date: Date?,
|
||||
state: WildReportProgressStepState,
|
||||
pendingText: String? = nil
|
||||
) -> WildReportProgressStep {
|
||||
WildReportProgressStep(
|
||||
id: id,
|
||||
title: title,
|
||||
timeText: pendingText ?? (date.map(formatDate) ?? ""),
|
||||
state: state
|
||||
)
|
||||
}
|
||||
|
||||
private static func submitDate(from text: String) -> Date? {
|
||||
WildPhotographerReportMockStore.submitTimeFormatter.date(from: text)
|
||||
}
|
||||
|
||||
private static func formatDate(_ date: Date) -> String {
|
||||
WildPhotographerReportMockStore.submitTimeFormatter.string(from: date)
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
@ -855,13 +756,6 @@ final class WildPhotographerReportListViewModel {
|
||||
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 }
|
||||
@ -935,15 +829,6 @@ final class WildPhotographerReportListViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
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?()
|
||||
}
|
||||
@ -990,11 +875,6 @@ final class WildReportSupplementEvidenceViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 添加演示用补充图片。
|
||||
func addImage() {
|
||||
addImages([WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)")])
|
||||
}
|
||||
|
||||
/// 添加本地补充视频。
|
||||
func addVideos(_ attachments: [WildReportAttachment]) {
|
||||
let videoRemaining = max(0, 3 - videos.count)
|
||||
@ -1008,11 +888,6 @@ final class WildReportSupplementEvidenceViewModel {
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
/// 添加演示用补充视频。
|
||||
func addVideo() {
|
||||
addVideos([WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)")])
|
||||
}
|
||||
|
||||
/// 删除指定附件。
|
||||
func removeAttachment(_ attachment: WildReportAttachment) {
|
||||
images.removeAll { $0.id == attachment.id }
|
||||
@ -1055,12 +930,6 @@ final class WildReportSupplementEvidenceViewModel {
|
||||
evidences: evidenceRequests.isEmpty ? nil : evidenceRequests
|
||||
))
|
||||
|
||||
homeViewModel.appendSupplementaryEvidence(
|
||||
reportID: reportID,
|
||||
text: trimmedText,
|
||||
imageCount: images.count,
|
||||
videoCount: videos.count
|
||||
)
|
||||
didSubmit = true
|
||||
notifyStateChange()
|
||||
onSubmitSuccess?()
|
||||
@ -1141,7 +1010,8 @@ final class WildReportSupplementEvidenceViewModel {
|
||||
|
||||
/// 附近风险地图 ViewModel。
|
||||
final class WildReportRiskMapViewModel {
|
||||
private let fallbackMarkers: [WildReportMapMarker]
|
||||
private static let riskMapLocationAccuracy = kCLLocationAccuracyHundredMeters
|
||||
|
||||
private var loadedDetailMarkerIDs: Set<String> = []
|
||||
|
||||
private(set) var region: MKCoordinateRegion
|
||||
@ -1159,14 +1029,12 @@ final class WildReportRiskMapViewModel {
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) {
|
||||
let markers = WildPhotographerReportMockStore.buildMarkers(record: record, riskPoints: riskPoints)
|
||||
self.fallbackMarkers = markers
|
||||
self.markers = markers
|
||||
self.region = WildPhotographerReportMockStore.region(for: markers)
|
||||
init() {
|
||||
self.markers = []
|
||||
self.region = Self.region(for: [])
|
||||
let scenicName = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.scenicAreaName = scenicName.isEmpty ? "那拉提景区" : scenicName
|
||||
self.selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id
|
||||
self.scenicAreaName = scenicName.isEmpty ? "当前景区" : scenicName
|
||||
self.selectedMarkerID = nil
|
||||
}
|
||||
|
||||
var selectedMarker: WildReportMapMarker? {
|
||||
@ -1272,7 +1140,7 @@ final class WildReportRiskMapViewModel {
|
||||
/// 生成图片预览提示。
|
||||
func showImagePreview(_ url: String) {
|
||||
_ = url
|
||||
showToast("演示环境暂不支持预览图片")
|
||||
showToast("暂不支持预览图片")
|
||||
}
|
||||
|
||||
private func showToast(_ message: String) {
|
||||
@ -1295,16 +1163,37 @@ final class WildReportRiskMapViewModel {
|
||||
return
|
||||
}
|
||||
|
||||
let coordinate = await requestRiskMapCoordinate(locationProvider: locationProvider)
|
||||
currentCoordinate = coordinate
|
||||
await requestRiskMap(
|
||||
api: api,
|
||||
scenicId: resolvedScenicId,
|
||||
scenicName: scenicName,
|
||||
coordinate: coordinate,
|
||||
selectCurrentLocation: selectCurrentLocation,
|
||||
showsLocationRefreshToast: coordinate != nil
|
||||
)
|
||||
}
|
||||
|
||||
private func requestRiskMapCoordinate(locationProvider: any LocationProviding) async -> CLLocationCoordinate2D? {
|
||||
try? await locationProvider.requestCoordinate(desiredAccuracy: Self.riskMapLocationAccuracy)
|
||||
}
|
||||
|
||||
private func requestRiskMap(
|
||||
api: any WildPhotographerReportServing,
|
||||
scenicId: Int,
|
||||
scenicName: String?,
|
||||
coordinate: CLLocationCoordinate2D?,
|
||||
selectCurrentLocation: Bool,
|
||||
showsLocationRefreshToast: Bool
|
||||
) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
notifyStateChange()
|
||||
|
||||
let coordinate = try? await locationProvider.requestCoordinate()
|
||||
currentCoordinate = coordinate
|
||||
|
||||
do {
|
||||
let response = try await api.riskMap(WildReportRiskMapRequest(
|
||||
scenicId: resolvedScenicId,
|
||||
scenicId: scenicId,
|
||||
latitude: coordinate?.latitude,
|
||||
longitude: coordinate?.longitude
|
||||
))
|
||||
@ -1315,25 +1204,17 @@ final class WildReportRiskMapViewModel {
|
||||
greenMarkerTip = response.greenMarkerTip
|
||||
legendItems = response.legend
|
||||
markers = buildMarkers(response: response, currentCoordinate: coordinate)
|
||||
if markers.isEmpty {
|
||||
markers = fallbackMarkers
|
||||
}
|
||||
region = WildPhotographerReportMockStore.region(for: markers)
|
||||
region = Self.region(for: markers)
|
||||
if selectCurrentLocation, let current = markers.first(where: { $0.kind == .selfLocation }) {
|
||||
selectedMarkerID = current.id
|
||||
} else if selectedMarker == nil {
|
||||
selectedMarkerID = markers.first?.id
|
||||
}
|
||||
if coordinate != nil {
|
||||
if showsLocationRefreshToast {
|
||||
showToast("已更新附近风险点位")
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
if markers.isEmpty {
|
||||
markers = fallbackMarkers
|
||||
region = WildPhotographerReportMockStore.region(for: markers)
|
||||
selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id ?? markers.first?.id
|
||||
}
|
||||
showToast(error.localizedDescription)
|
||||
}
|
||||
|
||||
@ -1538,7 +1419,7 @@ final class WildReportRiskMapViewModel {
|
||||
case .yellow:
|
||||
return "处理中线索"
|
||||
case .red, .unknown:
|
||||
return dto.reportTypeText.nonEmpty ?? "疑似打野"
|
||||
return dto.reportTypeText.nonEmpty ?? "异常线索"
|
||||
case .blue:
|
||||
return "当前位置"
|
||||
}
|
||||
@ -1563,6 +1444,32 @@ final class WildReportRiskMapViewModel {
|
||||
return String(format: "%.1f公里", meters / 1000)
|
||||
}
|
||||
|
||||
private static func region(for markers: [WildReportMapMarker]) -> MKCoordinateRegion {
|
||||
guard !markers.isEmpty else {
|
||||
return MKCoordinateRegion(
|
||||
center: CLLocationCoordinate2D(latitude: 0, longitude: 0),
|
||||
span: MKCoordinateSpan(latitudeDelta: 0.08, longitudeDelta: 0.08)
|
||||
)
|
||||
}
|
||||
let latitudes = markers.map(\.coordinate.latitude)
|
||||
let longitudes = markers.map(\.coordinate.longitude)
|
||||
let minLat = latitudes.min() ?? markers[0].coordinate.latitude
|
||||
let maxLat = latitudes.max() ?? markers[0].coordinate.latitude
|
||||
let minLon = longitudes.min() ?? markers[0].coordinate.longitude
|
||||
let maxLon = longitudes.max() ?? markers[0].coordinate.longitude
|
||||
let center = CLLocationCoordinate2D(
|
||||
latitude: (minLat + maxLat) / 2,
|
||||
longitude: (minLon + maxLon) / 2
|
||||
)
|
||||
return MKCoordinateRegion(
|
||||
center: center,
|
||||
span: MKCoordinateSpan(
|
||||
latitudeDelta: max(0.018, (maxLat - minLat) * 1.6),
|
||||
longitudeDelta: max(0.018, (maxLon - minLon) * 1.6)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func maskPhone(_ phone: String) -> String {
|
||||
guard phone.count == 11, phone.allSatisfy(\.isNumber) else { return phone }
|
||||
return "\(phone.prefix(3))****\(phone.suffix(4))"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# 举报摄影师接口文档
|
||||
|
||||
本文档记录 iOS `WildPhotographerReport` 模块当前使用的后端接口,以及仍处于 Mock 阶段的业务点。
|
||||
本文档记录 iOS `WildPhotographerReport` 模块当前使用的后端接口。
|
||||
|
||||
## 基础约定
|
||||
|
||||
@ -9,7 +9,59 @@
|
||||
- 网络入口:`NetworkServices.shared.wildPhotographerReportAPI`
|
||||
- 后端成功码:`100000`
|
||||
- 响应包裹:工程统一 `APIEnvelope<T>`
|
||||
- 当前状态:“举报类型”、“提交举报”、“我的举报列表”、“举报详情”和“补充证据提交”已接接口;风险地图仍使用 Mock 数据闭环。
|
||||
- 当前状态:“首页文案”、“举报类型”、“提交举报”、“我的举报列表”、“举报详情”、“补充证据提交”和“附近风险地图”已接接口;模块内生产 Mock 数据已移除。
|
||||
|
||||
## 首页文案
|
||||
|
||||
### 用途
|
||||
|
||||
“举报摄影师”首页进入时拉取举报须知和举报规则文案。
|
||||
|
||||
### 请求
|
||||
|
||||
```http
|
||||
GET /api/yf-handset-app/photog/report/copy
|
||||
```
|
||||
|
||||
### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"notice": [
|
||||
"需实名登录",
|
||||
"请尽量上传清晰证据",
|
||||
"恶意举报将被追责"
|
||||
],
|
||||
"rule_groups": [
|
||||
{
|
||||
"title": "可举报行为",
|
||||
"items": [
|
||||
"疑似打野摄影师主动揽客",
|
||||
"未佩戴工牌开展摄影服务",
|
||||
"引导游客线下付款或私下交易"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": "2026-07-09 10:15:17"
|
||||
}
|
||||
```
|
||||
|
||||
### UI 行为
|
||||
|
||||
- `notice` 用于首页“举报须知”条目展示。
|
||||
- `rule_groups` 非空时显示“举报规则”按钮,点击后在举报规则页按分组展示 `title` 和 `items`。
|
||||
- `notice` 与 `rule_groups` 都为空时,隐藏整个“举报须知”模块。
|
||||
- 接口失败时不展示本地写死兜底文案,并 toast 错误。
|
||||
|
||||
### 当前调用位置
|
||||
|
||||
- `WildPhotographerReportHomeViewController.viewDidLoad()`
|
||||
- `WildPhotographerReportHomeViewModel.loadReportCopy(api:)`
|
||||
- `WildPhotographerReportAPI.reportCopy()`
|
||||
|
||||
## 1. 获取举报类型
|
||||
|
||||
@ -77,7 +129,7 @@ struct WildReportTypeListResponse: Decodable, Hashable {
|
||||
- 每行最多展示 3 个类型,超过 3 个自动换行。
|
||||
- 按 `label` 展示文字,不展示图标。
|
||||
- 默认选中 `value == 2` 的类型;如果接口返回中不存在该类型,则选中列表第一项。
|
||||
- 接口失败时回退本地兜底列表,并提示“举报类型获取失败,已使用默认类型”。
|
||||
- 接口失败时不展示本地默认类型,并 toast 错误。
|
||||
|
||||
### 当前调用位置
|
||||
|
||||
@ -139,7 +191,7 @@ POST /api/yf-handset-app/photog/report/submit
|
||||
- `store_user_id`:被举报摄影师 ID;当前页面无来源,本轮暂不传。
|
||||
- `contact_phone`:当前联系方式输入值,非空才传。
|
||||
- `location_name` / `location_address`:从当前定位展示文案拆分;无法拆分时使用景区名和完整地址兜底。
|
||||
- `lat` / `lng`:当前定位快照坐标;定位失败时使用 Mock 地址对应的兜底坐标。
|
||||
- `lat` / `lng`:当前定位快照坐标;定位失败时不提交举报,提示用户重新获取位置。
|
||||
- `evidences`:现场证据,必填,至少 1 项、最多 9 项。
|
||||
- `payment_screenshots`:线下微信、支付宝截图,选填,最多 3 张。
|
||||
- `file_type`:举报提交接口约定为 `1 = 图片`,`2 = 视频`。
|
||||
@ -307,7 +359,7 @@ GET /api/yf-handset-app/photog/report/detail?id=32
|
||||
- `location_name` / `location_address`:详情页位置展示。
|
||||
- `handle_status` / `handle_status_text`:处理状态;iOS 支持 0 待处理、1 处理中、2 已处理、3 驳回。
|
||||
- `handle_remark`:处理备注,有值时在详情信息卡展示。
|
||||
- `timeline`:处理进度时间线;为空时使用本地状态推导兜底。
|
||||
- `timeline`:处理进度时间线;为空时只展示列表记录中的当前处理状态,不生成本地模拟流程。
|
||||
|
||||
### iOS 模型
|
||||
|
||||
@ -368,11 +420,8 @@ POST /api/yf-handset-app/photog/report/supplement
|
||||
- `WildPhotographerReportAPI.supplementReport(_:)`
|
||||
- `WildReportSupplementEvidenceViewController.submitTapped()`
|
||||
|
||||
## Mock 阶段业务
|
||||
## 生产 Mock 清理
|
||||
|
||||
以下业务当前仍由 `WildPhotographerReportMockStore` 和 ViewModel 本地逻辑完成,暂未接真实接口:
|
||||
|
||||
- 附近风险地图
|
||||
- 分享、媒体预览、地图导航演示提示
|
||||
|
||||
后续接入真实接口时,应优先在 `WildPhotographerReportServing` 中补充语义化方法,并保持 ViewModel 不依赖 UIKit 视图类型。
|
||||
- 已删除本地种子举报、种子风险点、种子补充证据及对应生产代码文件。
|
||||
- 举报类型、首页文案、列表、详情、补充证据和风险地图均不再使用本地写死数据兜底。
|
||||
- 提交接口当前无业务 `data` 返回;提交成功页只展示本次用户提交快照,不伪造服务端举报编号,也不写入“我的举报”本地列表。
|
||||
|
||||
Reference in New Issue
Block a user