Compare commits

...

2 Commits

Author SHA1 Message Date
2970f1514b 完善风险地图定位与标记详情 2026-07-09 12:47:47 +08:00
42aca73588 feat: 更新素材管理和举报风险地图 2026-07-09 12:20:02 +08:00
42 changed files with 6855 additions and 1476 deletions

View File

@ -22,7 +22,9 @@ final class NetworkServices {
let walletAPI: WalletAPI
let scenicQueueAPI: ScenicQueueAPI
let travelAlbumAPI: TravelAlbumAPI
let settingAPI: SettingAPI
let sampleManagementAPI: SampleManagementAPI
let materialManagementAPI: MaterialManagementAPI
let liveAPI: LiveAPI
let wildPhotographerReportAPI: WildPhotographerReportAPI
let uploadAPI: UploadAPI
@ -42,7 +44,9 @@ final class NetworkServices {
walletAPI = WalletAPI(client: client)
scenicQueueAPI = ScenicQueueAPI(client: client)
travelAlbumAPI = TravelAlbumAPI(client: client)
settingAPI = SettingAPI(client: client)
sampleManagementAPI = SampleManagementAPI(client: client)
materialManagementAPI = MaterialManagementAPI(client: client)
liveAPI = LiveAPI(client: client)
wildPhotographerReportAPI = WildPhotographerReportAPI(client: client)
uploadAPI = UploadAPI(client: client)
@ -67,7 +71,9 @@ final class NetworkServices {
walletAPI = WalletAPI(client: apiClient)
scenicQueueAPI = ScenicQueueAPI(client: apiClient)
travelAlbumAPI = TravelAlbumAPI(client: apiClient)
settingAPI = SettingAPI(client: apiClient)
sampleManagementAPI = SampleManagementAPI(client: apiClient)
materialManagementAPI = MaterialManagementAPI(client: apiClient)
liveAPI = LiveAPI(client: apiClient)
wildPhotographerReportAPI = WildPhotographerReportAPI(client: apiClient)
uploadAPI = UploadAPI(client: apiClient)

View File

@ -137,10 +137,17 @@ class BaseViewController: UIViewController {
topMostPresentedViewController().present(viewController, animated: animated, completion: completion)
}
///
func presentMediaPreview(items: [MediaPreviewItem], startIndex: Int) {
guard !items.isEmpty else { return }
presentOnTop(MediaPreviewViewController(items: items, startIndex: startIndex))
}
///
func presentImagePreview(imageURLs: [String], startIndex: Int) {
guard !imageURLs.isEmpty else { return }
presentOnTop(ImagePreviewViewController(imageURLs: imageURLs, startIndex: startIndex))
let items = imageURLs.compactMap { MediaPreviewItem.remoteImage($0) }
presentMediaPreview(items: items, startIndex: startIndex)
}
// MARK: - Notifications

View File

@ -7,6 +7,11 @@ import Foundation
/// App Android `AgreementRoutes`
enum AppAgreementURLs {
/// H5
nonisolated static var aboutUs: URL {
APIEnvironment.current.baseURL.appending(path: "h5/app/about-us")
}
/// H5
nonisolated static var privacyPolicy: URL {
APIEnvironment.current.baseURL.appending(path: "h5/app/privacy-policy")
@ -17,6 +22,11 @@ enum AppAgreementURLs {
APIEnvironment.current.baseURL.appending(path: "h5/app/user-agreement")
}
/// App H5
nonisolated static var appDownload: URL {
APIEnvironment.current.baseURL.appending(path: "h5/app/download")
}
///
nonisolated static let amapPrivacyPolicy = URL(string: "https://lbs.amap.com/pages/privacy/")!
}

View File

@ -109,6 +109,24 @@ final class OSSUploadService {
)
}
/// Android `moduleType = material`
func uploadMaterialFile(
data: Data,
fileName: String,
fileType: Int,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String {
try await uploadFile(
data: data,
fileName: fileName,
fileType: fileType,
scenicId: scenicId,
moduleType: "material",
onProgress: onProgress
)
}
/// Android `moduleType = live_covers`
func uploadLiveCover(
data: Data,
@ -217,6 +235,8 @@ extension OSSUploadService: OSSUploadServing {}
extension OSSUploadService: SampleOSSUploading {}
extension OSSUploadService: MaterialOSSUploading {}
enum OSSUploadPolicy {
static let maxFileSize = 2_048 * 1_024 * 1_024
private static let allowedExtensions: Set<String> = ["mp4", "mov", "m4v", "avi", "png", "jpg", "jpeg", "heic", "heif"]
@ -256,6 +276,8 @@ enum OSSUploadPolicy {
return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "sample":
return "sample/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "material":
return "material/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "scenic_apply":
return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "live_covers":

View File

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

View File

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

View File

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

View File

@ -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)]
)
)
}
}

View File

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

View File

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

View File

@ -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`

View 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")
)
}
}

View 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
}
}

View 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?()
}
}

View File

@ -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
)
)
}
@ -78,9 +87,9 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing {
func markerDetail(_ request: WildReportMarkerDetailRequest) async throws -> WildReportMarkerDetailResponse {
try await client.send(
APIRequest(
method: .post,
method: .get,
path: "/api/yf-handset-app/photog/report/marker-detail",
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
@ -128,7 +148,7 @@ struct WildReportRiskMapRequest: Encodable, Equatable {
}
}
///
///
struct WildReportMarkerDetailRequest: Encodable, Equatable {
let scenicId: Int
let markerType: String
@ -136,6 +156,21 @@ struct WildReportMarkerDetailRequest: Encodable, Equatable {
let latitude: Double?
let longitude: Double?
var queryItems: [URLQueryItem] {
var items = [
URLQueryItem(name: "scenic_id", value: String(scenicId)),
URLQueryItem(name: "marker_type", value: markerType),
URLQueryItem(name: "id", value: String(id))
]
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 markerType = "marker_type"

View File

@ -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
@ -1150,7 +1185,9 @@ struct WildReportRiskMapMarkerDTO: Decodable, Equatable, Hashable {
struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
let markerType: String
let id: Int
let clueId: String
let title: String
let statusTag: String
let tag: String
let name: String
let avatar: String
@ -1175,7 +1212,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
enum CodingKeys: String, CodingKey {
case markerType = "marker_type"
case id
case clueId = "clue_id"
case title
case statusTag = "status_tag"
case tag
case name
case avatar
@ -1202,7 +1241,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
init(
markerType: String,
id: Int,
clueId: String = "",
title: String = "",
statusTag: String = "",
tag: String = "",
name: String = "",
avatar: String = "",
@ -1226,7 +1267,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
) {
self.markerType = markerType
self.id = id
self.clueId = clueId
self.title = title
self.statusTag = statusTag
self.tag = tag
self.name = name
self.avatar = avatar
@ -1253,7 +1296,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
let container = try decoder.container(keyedBy: CodingKeys.self)
markerType = try container.decodeIfPresent(String.self, forKey: .markerType) ?? ""
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
clueId = try container.decodeIfPresent(String.self, forKey: .clueId) ?? ""
title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
statusTag = try container.decodeIfPresent(String.self, forKey: .statusTag) ?? ""
tag = try container.decodeIfPresent(String.self, forKey: .tag) ?? ""
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
avatar = try container.decodeIfPresent(String.self, forKey: .avatar) ?? ""

View File

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

View File

@ -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,14 +1010,14 @@ 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
private(set) var markers: [WildReportMapMarker]
private(set) var scenicAreaName: String
private(set) var selectedMarkerID: String?
private(set) var toastMessage: String?
private(set) var isLoading = false
private(set) var isLoadingDetail = false
private(set) var errorMessage: String?
@ -1157,16 +1026,13 @@ final class WildReportRiskMapViewModel {
private(set) var currentCoordinate: CLLocationCoordinate2D?
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? {
@ -1222,7 +1088,7 @@ final class WildReportRiskMapViewModel {
mergeDetail(detail, intoMarkerID: marker.id)
loadedDetailMarkerIDs.insert(marker.id)
} catch {
showToast(error.localizedDescription)
errorMessage = error.localizedDescription
}
isLoadingDetail = false
notifyStateChange()
@ -1246,6 +1112,19 @@ final class WildReportRiskMapViewModel {
return marker
}
///
@discardableResult
func focusCurrentLocation() -> Bool {
guard let marker = markers.first(where: { $0.kind == .selfLocation }) else { return false }
selectedMarkerID = marker.id
region = MKCoordinateRegion(
center: marker.coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
)
notifyStateChange()
return true
}
///
func refreshLocation(
api: any WildPhotographerReportServing,
@ -1264,21 +1143,12 @@ final class WildReportRiskMapViewModel {
/// 线
func shareSelectedClue() {
guard let clue = selectedClue else { return }
let title = clue.type == .red ? "线索 ID\(clue.id)" : clue.displayTitle
showToast("已生成\(title)的分享信息,可发送给他人查看。")
_ = selectedClue
}
///
func showImagePreview(_ url: String) {
_ = url
showToast("演示环境暂不支持预览图片")
}
private func showToast(_ message: String) {
toastMessage = message
onShowMessage?(message)
notifyStateChange()
}
private func loadRiskMap(
@ -1291,20 +1161,39 @@ final class WildReportRiskMapViewModel {
let resolvedScenicId = scenicId ?? AppStore.shared.currentScenicId
guard resolvedScenicId >= 1 else {
errorMessage = "请先选择景区后再查看风险地图"
showToast(errorMessage ?? "")
notifyStateChange()
return
}
let coordinate = await requestRiskMapCoordinate(locationProvider: locationProvider)
currentCoordinate = coordinate
await requestRiskMap(
api: api,
scenicId: resolvedScenicId,
scenicName: scenicName,
coordinate: coordinate,
selectCurrentLocation: selectCurrentLocation
)
}
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
) 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,26 +1204,14 @@ 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 {
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)
}
isLoading = false
@ -1473,7 +1350,8 @@ final class WildReportRiskMapViewModel {
else { return }
let markerType = WildReportRiskMarkerType(rawValue: detail.markerType.nonEmpty ?? oldClue.type.rawValue)
let statusText = detail.handleStatusText.nonEmpty
let statusText = detail.statusTag.nonEmpty
?? detail.handleStatusText.nonEmpty
?? detail.handleStatus?.displayText
?? oldClue.statusText
let reportContents = !detail.reportContents.isEmpty
@ -1481,7 +1359,7 @@ final class WildReportRiskMapViewModel {
: (detail.desc.nonEmpty.map { [WildReportRadarReportContent(time: detail.createdAt, content: $0)] } ?? oldClue.reportContents)
let imageURLs = detail.evidences.filter(\.isImage).map(\.fileURL)
let clue = WildReportRadarActiveClue(
id: String(detail.id > 0 ? detail.id : Int(oldClue.id) ?? 0),
id: detail.clueId.nonEmpty ?? String(detail.id > 0 ? detail.id : Int(oldClue.id) ?? 0),
type: markerType.radarType,
displayTitle: detail.title.nonEmpty ?? oldClue.displayTitle,
statusText: statusText,
@ -1538,7 +1416,7 @@ final class WildReportRiskMapViewModel {
case .yellow:
return "处理中线索"
case .red, .unknown:
return dto.reportTypeText.nonEmpty ?? "疑似打野"
return dto.reportTypeText.nonEmpty ?? "异常线索"
case .blue:
return "当前位置"
}
@ -1563,6 +1441,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))"

View File

@ -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` 返回;提交成功页只展示本次用户提交快照,不伪造服务端举报编号,也不写入“我的举报”本地列表。

View File

@ -0,0 +1,109 @@
//
// MediaPreviewItem.swift
// suixinkan
//
import UIKit
///
public struct MediaPreviewItem: Hashable {
///
public enum MediaType: Equatable {
case image
case video
}
///
public enum Source {
case remoteImage(URL)
case localImageFile(URL)
case image(UIImage)
case remoteVideo(URL)
case localVideoFile(URL)
}
/// Diffable Data Source
public let id: UUID
///
public let source: Source
///
public init(id: UUID = UUID(), source: Source) {
self.id = id
self.source = source
}
///
public var mediaType: MediaType {
switch source {
case .remoteImage, .localImageFile, .image:
return .image
case .remoteVideo, .localVideoFile:
return .video
}
}
///
public var isImage: Bool {
mediaType == .image
}
///
public var isVideo: Bool {
mediaType == .video
}
/// nil
public static func remoteImage(_ urlString: String) -> MediaPreviewItem? {
makeRemoteURL(from: urlString).map { MediaPreviewItem(source: .remoteImage($0)) }
}
/// nil
public static func remoteVideo(_ urlString: String) -> MediaPreviewItem? {
makeRemoteURL(from: urlString).map { MediaPreviewItem(source: .remoteVideo($0)) }
}
///
public static func localImage(url: URL) -> MediaPreviewItem {
MediaPreviewItem(source: .localImageFile(url))
}
///
public static func image(_ image: UIImage) -> MediaPreviewItem {
MediaPreviewItem(source: .image(image))
}
///
public static func localVideo(url: URL) -> MediaPreviewItem {
MediaPreviewItem(source: .localVideoFile(url))
}
///
public static func clampedStartIndex(_ startIndex: Int, itemCount: Int) -> Int {
guard itemCount > 0 else { return 0 }
return min(max(0, startIndex), itemCount - 1)
}
///
public static func currentItem(in items: [MediaPreviewItem], startIndex: Int) -> MediaPreviewItem? {
guard !items.isEmpty else { return nil }
return items[clampedStartIndex(startIndex, itemCount: items.count)]
}
public static func == (lhs: MediaPreviewItem, rhs: MediaPreviewItem) -> Bool {
lhs.id == rhs.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
private static func makeRemoteURL(from text: String) -> URL? {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
guard let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme) else { return nil }
return url
}
}

View File

@ -0,0 +1,574 @@
//
// MediaPreviewViewController.swift
// suixinkan
//
import AVFoundation
import Kingfisher
import SnapKit
import UIKit
///
class MediaPreviewViewController: UIViewController {
private enum Section {
case main
}
private let items: [MediaPreviewItem]
private let startIndex: Int
private var currentIndex: Int
private var dataSource: UICollectionViewDiffableDataSource<Section, MediaPreviewItem>!
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeCollectionLayout())
private let closeButton = UIButton(type: .system)
private let counterLabel = UILabel()
///
init(items: [MediaPreviewItem], startIndex: Int = 0) {
self.items = items
self.startIndex = MediaPreviewItem.clampedStartIndex(startIndex, itemCount: items.count)
self.currentIndex = self.startIndex
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
static func present(from viewController: UIViewController, items: [MediaPreviewItem], startIndex: Int = 0) {
guard !items.isEmpty else { return }
let preview = MediaPreviewViewController(items: items, startIndex: startIndex)
viewController.present(preview, animated: true)
}
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
setupDataSource()
applySnapshot()
registerNotifications()
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard collectionView.bounds.width > 0 else { return }
collectionView.collectionViewLayout.invalidateLayout()
collectionView.setContentOffset(
CGPoint(x: CGFloat(currentIndex) * collectionView.bounds.width, y: 0),
animated: false
)
updateVisibleCellsPlayback()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateVisibleCellsPlayback()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
pauseVisibleVideos()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
private func setupUI() {
view.backgroundColor = .black
collectionView.backgroundColor = .black
collectionView.isPagingEnabled = true
collectionView.showsHorizontalScrollIndicator = false
collectionView.alwaysBounceHorizontal = items.count > 1
collectionView.delegate = self
collectionView.register(MediaPreviewImageCell.self, forCellWithReuseIdentifier: MediaPreviewImageCell.reuseIdentifier)
collectionView.register(MediaPreviewVideoCell.self, forCellWithReuseIdentifier: MediaPreviewVideoCell.reuseIdentifier)
closeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
closeButton.tintColor = .white
closeButton.backgroundColor = UIColor.black.withAlphaComponent(0.25)
closeButton.layer.cornerRadius = 18
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
counterLabel.textColor = .white
counterLabel.font = .systemFont(ofSize: 14, weight: .medium)
counterLabel.textAlignment = .center
counterLabel.backgroundColor = UIColor.black.withAlphaComponent(0.35)
counterLabel.layer.cornerRadius = 14
counterLabel.clipsToBounds = true
updateCounter()
view.addSubview(collectionView)
view.addSubview(closeButton)
view.addSubview(counterLabel)
collectionView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
closeButton.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
make.trailing.equalToSuperview().inset(16)
make.size.equalTo(36)
}
counterLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalTo(closeButton)
make.height.equalTo(28)
make.width.greaterThanOrEqualTo(64)
}
}
private func setupDataSource() {
dataSource = UICollectionViewDiffableDataSource<Section, MediaPreviewItem>(
collectionView: collectionView
) { [weak self] collectionView, indexPath, item in
switch item.source {
case .remoteImage, .localImageFile, .image:
guard let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: MediaPreviewImageCell.reuseIdentifier,
for: indexPath
) as? MediaPreviewImageCell else {
return UICollectionViewCell()
}
cell.configure(source: item.source)
return cell
case .remoteVideo, .localVideoFile:
guard let cell = collectionView.dequeueReusableCell(
withReuseIdentifier: MediaPreviewVideoCell.reuseIdentifier,
for: indexPath
) as? MediaPreviewVideoCell else {
return UICollectionViewCell()
}
cell.configure(source: item.source)
cell.setActive(indexPath.item == self?.currentIndex)
return cell
}
}
}
private func applySnapshot() {
var snapshot = NSDiffableDataSourceSnapshot<Section, MediaPreviewItem>()
snapshot.appendSections([.main])
snapshot.appendItems(items)
dataSource.apply(snapshot, animatingDifferences: false)
}
private func makeCollectionLayout() -> UICollectionViewLayout {
let layout = UICollectionViewFlowLayout()
layout.scrollDirection = .horizontal
layout.minimumLineSpacing = 0
layout.minimumInteritemSpacing = 0
return layout
}
private func registerNotifications() {
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
}
private func updateCurrentIndex() {
guard collectionView.bounds.width > 0 else { return }
let page = Int(round(collectionView.contentOffset.x / collectionView.bounds.width))
currentIndex = min(max(0, page), max(0, items.count - 1))
updateCounter()
updateVisibleCellsPlayback()
}
private func updateCounter() {
counterLabel.text = items.isEmpty ? "0/0" : "\(currentIndex + 1)/\(items.count)"
}
private func updateVisibleCellsPlayback() {
for cell in collectionView.visibleCells {
guard let indexPath = collectionView.indexPath(for: cell),
let videoCell = cell as? MediaPreviewVideoCell
else { continue }
videoCell.setActive(indexPath.item == currentIndex && view.window != nil)
}
}
private func pauseVisibleVideos() {
collectionView.visibleCells
.compactMap { $0 as? MediaPreviewVideoCell }
.forEach { $0.setActive(false) }
}
@objc private func closeTapped() {
pauseVisibleVideos()
dismiss(animated: true)
}
@objc private func appDidEnterBackground() {
pauseVisibleVideos()
}
}
extension MediaPreviewViewController: UICollectionViewDelegateFlowLayout {
func collectionView(
_ collectionView: UICollectionView,
layout collectionViewLayout: UICollectionViewLayout,
sizeForItemAt indexPath: IndexPath
) -> CGSize {
collectionView.bounds.size
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
updateCurrentIndex()
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
updateCurrentIndex()
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.isDragging || scrollView.isDecelerating else { return }
pauseVisibleVideos()
}
}
/// Cell
private final class MediaPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
static let reuseIdentifier = "MediaPreviewImageCell"
private let scrollView = UIScrollView()
private let imageView = UIImageView()
private let messageLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
imageView.kf.cancelDownloadTask()
imageView.image = nil
messageLabel.isHidden = true
scrollView.zoomScale = 1
}
override func layoutSubviews() {
super.layoutSubviews()
imageView.frame = scrollView.bounds
}
///
func configure(source: MediaPreviewItem.Source) {
messageLabel.isHidden = true
scrollView.zoomScale = 1
switch source {
case .remoteImage(let url):
imageView.kf.setImage(with: url) { [weak self] result in
if case .failure = result {
self?.showMessage("图片加载失败")
}
}
case .localImageFile(let url):
if let image = UIImage(contentsOfFile: url.path) {
imageView.image = image
} else {
showMessage("图片加载失败")
}
case .image(let image):
imageView.image = image
case .remoteVideo, .localVideoFile:
showMessage("不支持的图片资源")
}
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
imageView
}
private func setupUI() {
backgroundColor = .black
scrollView.delegate = self
scrollView.minimumZoomScale = 1
scrollView.maximumZoomScale = 3
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
scrollView.contentInsetAdjustmentBehavior = .never
imageView.contentMode = .scaleAspectFit
imageView.backgroundColor = .black
messageLabel.textColor = UIColor.white.withAlphaComponent(0.8)
messageLabel.font = .systemFont(ofSize: 15)
messageLabel.textAlignment = .center
messageLabel.isHidden = true
contentView.addSubview(scrollView)
scrollView.addSubview(imageView)
contentView.addSubview(messageLabel)
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
messageLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
}
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
doubleTap.numberOfTapsRequired = 2
scrollView.addGestureRecognizer(doubleTap)
}
private func showMessage(_ text: String) {
imageView.image = nil
messageLabel.text = text
messageLabel.isHidden = false
}
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
if scrollView.zoomScale > 1 {
scrollView.setZoomScale(1, animated: true)
return
}
let point = gesture.location(in: imageView)
let targetScale = scrollView.maximumZoomScale
let width = scrollView.bounds.width / targetScale
let height = scrollView.bounds.height / targetScale
let rect = CGRect(x: point.x - width / 2, y: point.y - height / 2, width: width, height: height)
scrollView.zoom(to: rect, animated: true)
}
}
/// Cell AVPlayer
private final class MediaPreviewVideoCell: UICollectionViewCell, UIGestureRecognizerDelegate {
static let reuseIdentifier = "MediaPreviewVideoCell"
private let playerView = MediaPreviewPlayerView()
private let playButton = MediaPreviewPlaybackButton()
private let messageLabel = UILabel()
private var player: AVPlayer?
private var didReachEnd = false
private var isActive = false
private var isUserPaused = false
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
NotificationCenter.default.removeObserver(self)
}
override func prepareForReuse() {
super.prepareForReuse()
NotificationCenter.default.removeObserver(self)
player?.pause()
player = nil
playerView.playerLayer.player = nil
didReachEnd = false
isActive = false
isUserPaused = false
messageLabel.isHidden = true
playButton.isHidden = false
}
///
func configure(source: MediaPreviewItem.Source) {
messageLabel.isHidden = true
didReachEnd = false
isUserPaused = false
let url: URL?
switch source {
case .remoteVideo(let remoteURL), .localVideoFile(let remoteURL):
url = remoteURL
case .remoteImage, .localImageFile, .image:
url = nil
}
guard let url else {
showMessage("视频加载失败")
return
}
let player = AVPlayer(url: url)
player.actionAtItemEnd = .pause
self.player = player
playerView.playerLayer.player = player
if let item = player.currentItem {
NotificationCenter.default.addObserver(
self,
selector: #selector(videoDidEnd),
name: .AVPlayerItemDidPlayToEndTime,
object: item
)
}
}
///
func setActive(_ active: Bool) {
isActive = active
guard let player else { return }
guard active else {
player.pause()
playButton.isHidden = !didReachEnd
return
}
if didReachEnd {
showPlaybackButton(systemName: "pause.fill")
} else if isUserPaused {
showPlaybackButton(systemName: "play.fill")
player.pause()
} else {
playButton.isHidden = true
player.play()
}
}
private func setupUI() {
backgroundColor = .black
playerView.playerLayer.videoGravity = .resizeAspect
playButton.setSymbol(named: "pause.fill")
playButton.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
messageLabel.textColor = UIColor.white.withAlphaComponent(0.8)
messageLabel.font = .systemFont(ofSize: 15)
messageLabel.textAlignment = .center
messageLabel.isHidden = true
contentView.addSubview(playerView)
contentView.addSubview(playButton)
contentView.addSubview(messageLabel)
playerView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
playButton.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(96)
}
messageLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(32)
}
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(videoTapped))
tapGesture.delegate = self
contentView.addGestureRecognizer(tapGesture)
}
private func showMessage(_ text: String) {
messageLabel.text = text
messageLabel.isHidden = false
playButton.isHidden = true
}
private func showPlaybackButton(systemName: String) {
playButton.setSymbol(named: systemName)
playButton.isHidden = false
}
private func startPlaybackFromCurrentPosition() {
guard let player else { return }
if didReachEnd {
player.seek(to: .zero)
didReachEnd = false
}
isUserPaused = false
playButton.isHidden = true
player.play()
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
guard touch.view?.isDescendant(of: playButton) != true else { return false }
return true
}
@objc private func playTapped() {
startPlaybackFromCurrentPosition()
}
@objc private func videoTapped() {
guard isActive, let player else { return }
if didReachEnd || isUserPaused {
startPlaybackFromCurrentPosition()
return
}
isUserPaused = true
player.pause()
showPlaybackButton(systemName: "play.fill")
}
@objc private func videoDidEnd() {
didReachEnd = true
isUserPaused = false
showPlaybackButton(systemName: "pause.fill")
}
}
/// AVPlayerLayer
private final class MediaPreviewPlayerView: UIView {
override class var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
}
///
private final class MediaPreviewPlaybackButton: UIButton {
private let gradientLayer = CAGradientLayer()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
gradientLayer.frame = bounds
gradientLayer.cornerRadius = bounds.width / 2
layer.cornerRadius = bounds.width / 2
}
/// SF Symbol
func setSymbol(named systemName: String) {
let configuration = UIImage.SymbolConfiguration(pointSize: 42, weight: .semibold)
setImage(UIImage(systemName: systemName, withConfiguration: configuration), for: .normal)
}
private func setupUI() {
tintColor = .white
backgroundColor = UIColor.white.withAlphaComponent(0.08)
adjustsImageWhenHighlighted = false
layer.masksToBounds = false
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.28
layer.shadowRadius = 18
layer.shadowOffset = CGSize(width: 0, height: 10)
gradientLayer.colors = [
UIColor.white.withAlphaComponent(0.32).cgColor,
UIColor.black.withAlphaComponent(0.38).cgColor
]
gradientLayer.startPoint = CGPoint(x: 0.18, y: 0.12)
gradientLayer.endPoint = CGPoint(x: 0.88, y: 0.92)
layer.insertSublayer(gradientLayer, at: 0)
setSymbol(named: "pause.fill")
}
}

View File

@ -3,100 +3,19 @@
// suixinkan
//
import SnapKit
import UIKit
/// Android `MediaPreviewDialog`
final class ImagePreviewViewController: UIViewController {
private let imageURLs: [String]
private let startIndex: Int
private let scrollView = UIScrollView()
private let pageControl = UIPageControl()
///
final class ImagePreviewViewController: MediaPreviewViewController {
///
init(imageURLs: [String], startIndex: Int) {
self.imageURLs = imageURLs
self.startIndex = min(max(0, startIndex), max(0, imageURLs.count - 1))
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
let items = imageURLs.compactMap { MediaPreviewItem.remoteImage($0) }
super.init(items: items, startIndex: startIndex)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.delegate = self
pageControl.numberOfPages = imageURLs.count
pageControl.currentPage = startIndex
pageControl.isHidden = imageURLs.count <= 1
let closeButton = UIButton(type: .system)
closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
closeButton.tintColor = .white
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
view.addSubview(scrollView)
view.addSubview(pageControl)
view.addSubview(closeButton)
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
pageControl.snp.makeConstraints { make in
make.bottom.equalTo(view.safeAreaLayoutGuide).inset(AppSpacing.md)
make.centerX.equalToSuperview()
}
closeButton.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm)
make.trailing.equalToSuperview().inset(AppSpacing.md)
make.size.equalTo(AppSpacing.minTouchTarget)
}
imageURLs.enumerated().forEach { index, url in
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFit
imageView.loadRemoteImage(urlString: url, placeholderColor: .darkGray)
scrollView.addSubview(imageView)
imageView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview()
make.width.equalTo(view.snp.width)
make.height.equalTo(view.snp.height)
make.leading.equalToSuperview().offset(CGFloat(index) * view.bounds.width)
}
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
scrollView.contentSize = CGSize(width: view.bounds.width * CGFloat(imageURLs.count), height: view.bounds.height)
scrollView.setContentOffset(CGPoint(x: view.bounds.width * CGFloat(startIndex), y: 0), animated: false)
scrollView.subviews.enumerated().forEach { index, subview in
subview.frame = CGRect(
x: view.bounds.width * CGFloat(index),
y: 0,
width: view.bounds.width,
height: view.bounds.height
)
}
}
@objc private func closeTapped() {
dismiss(animated: true)
}
}
extension ImagePreviewViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard view.bounds.width > 0 else { return }
let page = Int(round(scrollView.contentOffset.x / view.bounds.width))
pageControl.currentPage = page
}
}

View File

@ -0,0 +1,710 @@
//
// MaterialDetailViewController.swift
// suixinkan
//
import AVFoundation
import Kingfisher
import SnapKit
import UIKit
/// Android `MaterialDetailNewScreen`
///
final class MaterialDetailViewController: BaseViewController {
private let viewModel: MaterialDetailViewModel
private let api: any MaterialManagementServing
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let carouselView = MaterialMediaCarouselView()
private let infoSection = MaterialDetailInfoSection()
private let uploaderSection = MaterialDetailUploaderSection()
private let reviewSection = MaterialDetailReviewSection()
private let bottomBar = UIView()
private let deleteButton = UIButton(type: .system)
private let editButton = UIButton(type: .system)
var onDeleted: ((Int) -> Void)?
var onEdited: (() -> Void)?
///
init(
materialId: Int,
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI
) {
viewModel = MaterialDetailViewModel(materialId: materialId)
self.api = api
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "素材详情"
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF4F4F4)
contentStack.axis = .vertical
contentStack.spacing = 12
scrollView.backgroundColor = UIColor(hex: 0xF4F4F4)
bottomBar.backgroundColor = .white
configureBottomButton(deleteButton, title: "删除", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary)
configureBottomButton(editButton, title: "编辑", backgroundColor: AppColor.primary, titleColor: .white)
view.addSubview(scrollView)
scrollView.addSubview(contentStack)
contentStack.addArrangedSubview(carouselView)
contentStack.addArrangedSubview(infoSection)
contentStack.addArrangedSubview(uploaderSection)
contentStack.addArrangedSubview(reviewSection)
view.addSubview(bottomBar)
bottomBar.addSubview(deleteButton)
bottomBar.addSubview(editButton)
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
deleteButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalToSuperview().offset(16)
make.width.equalTo(100)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
}
editButton.snp.makeConstraints { make in
make.top.height.equalTo(deleteButton)
make.leading.equalTo(deleteButton.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(16)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top)
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.width.equalTo(scrollView)
}
carouselView.snp.makeConstraints { make in
make.height.equalTo(carouselView.snp.width).multipliedBy(9.0 / 16.0)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onDeleted = { [weak self] id in
Task { @MainActor in
self?.onDeleted?(id)
self?.navigationController?.popViewController(animated: true)
}
}
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
editButton.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.load(api: api) }
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
carouselView.pause()
}
private func configureBottomButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) {
button.setTitle(title, for: .normal)
button.setTitleColor(titleColor, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.backgroundColor = backgroundColor
button.layer.cornerRadius = 8
}
@MainActor
private func applyViewModel() {
if (viewModel.isLoading || viewModel.isDeleting) && viewModel.detail == nil {
showLoading()
} else {
hideLoading()
}
guard let detail = viewModel.detail else { return }
carouselView.apply(media: detail.mediaList ?? [])
infoSection.apply(detail: detail)
uploaderSection.apply(detail: detail)
reviewSection.apply(detail: detail)
}
@objc private func deleteTapped() {
let alert = UIAlertController(title: "确认删除", message: "确定删除该素材吗?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in
guard let self else { return }
Task {
self.showLoading()
await self.viewModel.delete(api: self.api)
self.hideLoading()
}
})
present(alert, animated: true)
}
@objc private func editTapped() {
let controller = MaterialFormViewController(mode: .edit(id: viewModel.materialId))
controller.onSubmitSuccess = { [weak self] in
guard let self else { return }
self.onEdited?()
Task { await self.viewModel.load(api: self.api) }
}
navigationController?.pushViewController(controller, animated: true)
}
}
///
///
private final class MaterialMediaCarouselView: UIView, UIScrollViewDelegate {
private let scrollView = UIScrollView()
private let pageControl = UIPageControl()
private let placeholderLabel = UILabel()
private var media: [MaterialDetailMediaItem] = []
private var players: [Int: AVPlayer] = [:]
private var timer: Timer?
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(hex: 0xF5F5F5)
scrollView.isPagingEnabled = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.delegate = self
scrollView.bounces = false
pageControl.currentPageIndicatorTintColor = .white
pageControl.pageIndicatorTintColor = UIColor.white.withAlphaComponent(0.5)
placeholderLabel.text = "暂无媒体"
placeholderLabel.font = .systemFont(ofSize: 14)
placeholderLabel.textColor = AppColor.textSecondary
placeholderLabel.textAlignment = .center
addSubview(scrollView)
addSubview(pageControl)
addSubview(placeholderLabel)
scrollView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
pageControl.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.bottom.equalToSuperview().inset(8)
}
placeholderLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews() {
super.layoutSubviews()
layoutPages()
}
///
func apply(media: [MaterialDetailMediaItem]) {
stopTimer()
NotificationCenter.default.removeObserver(self)
players.values.forEach { $0.pause() }
players.removeAll()
self.media = media
scrollView.subviews.forEach { $0.removeFromSuperview() }
pageControl.numberOfPages = media.count
pageControl.currentPage = 0
pageControl.isHidden = media.count <= 1
placeholderLabel.isHidden = !media.isEmpty
scrollView.isHidden = media.isEmpty
guard !media.isEmpty else { return }
for (index, item) in media.enumerated() {
let page = UIView()
page.backgroundColor = UIColor(hex: 0xF5F5F5)
scrollView.addSubview(page)
if item.type == 2 {
addVideo(urlText: item.displayURL, to: page, index: index)
} else {
addImage(urlText: item.displayURL, to: page)
}
}
setNeedsLayout()
layoutIfNeeded()
playCurrentPageIfNeeded()
}
///
func pause() {
stopTimer()
players.values.forEach { $0.pause() }
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
updateCurrentPage()
}
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
updateCurrentPage()
}
private func addImage(urlText: String, to page: UIView) {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
page.addSubview(imageView)
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
if let url = URL(string: urlText), !urlText.isEmpty {
imageView.kf.setImage(with: url)
}
}
private func addVideo(urlText: String, to page: UIView, index: Int) {
guard let url = URL(string: urlText), !urlText.isEmpty else {
addImage(urlText: "", to: page)
return
}
let player = AVPlayer(url: url)
players[index] = player
let playerView = MaterialPlayerLayerView()
playerView.playerLayer.player = player
page.addSubview(playerView)
playerView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
NotificationCenter.default.addObserver(
self,
selector: #selector(videoDidEnd(_:)),
name: .AVPlayerItemDidPlayToEndTime,
object: player.currentItem
)
}
private func layoutPages() {
let size = bounds.size
guard size.width > 0, size.height > 0 else { return }
for (index, page) in scrollView.subviews.enumerated() {
page.frame = CGRect(x: CGFloat(index) * size.width, y: 0, width: size.width, height: size.height)
}
scrollView.contentSize = CGSize(width: size.width * CGFloat(media.count), height: size.height)
scrollView.contentOffset = CGPoint(x: CGFloat(pageControl.currentPage) * size.width, y: 0)
}
private func updateCurrentPage() {
guard bounds.width > 0 else { return }
let page = Int(round(scrollView.contentOffset.x / bounds.width))
pageControl.currentPage = max(0, min(page, media.count - 1))
playCurrentPageIfNeeded()
}
private func playCurrentPageIfNeeded() {
stopTimer()
players.forEach { index, player in
if index == pageControl.currentPage {
player.seek(to: .zero)
player.play()
} else {
player.pause()
}
}
guard !media.isEmpty else { return }
if media[pageControl.currentPage].type == 1 {
startImageTimer()
}
}
private func startImageTimer() {
guard media.count > 1 else { return }
timer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in
self?.advancePage()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
@objc private func videoDidEnd(_ notification: Notification) {
advancePage()
}
private func advancePage() {
guard media.count > 1, bounds.width > 0 else { return }
let nextPage = (pageControl.currentPage + 1) % media.count
pageControl.currentPage = nextPage
scrollView.setContentOffset(CGPoint(x: CGFloat(nextPage) * bounds.width, y: 0), animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.playCurrentPageIfNeeded()
}
}
}
/// AVPlayerLayer
/// AVPlayerLayer
private final class MaterialPlayerLayerView: UIView {
override class var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
}
///
///
private final class MaterialDetailInfoSection: UIView {
private let titleLabel = UILabel()
private let listingBadge = UILabel()
private let tagStack = UIStackView()
private let statsStack = UIStackView()
private let downloadStat = MaterialDetailStatView(systemIconName: "arrow.down.circle")
private let likeStat = MaterialDetailStatView(iconName: "sample_ic_like_count")
private let collectStat = MaterialDetailStatView(iconName: "sample_ic_collect_count")
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
titleLabel.font = .systemFont(ofSize: 20, weight: .bold)
titleLabel.textColor = UIColor(hex: 0x2E3746)
titleLabel.numberOfLines = 0
listingBadge.font = .systemFont(ofSize: 12)
listingBadge.layer.cornerRadius = 4
listingBadge.clipsToBounds = true
listingBadge.textAlignment = .center
tagStack.axis = .horizontal
tagStack.spacing = 8
tagStack.alignment = .leading
statsStack.axis = .horizontal
statsStack.spacing = 16
[downloadStat, likeStat, collectStat].forEach(statsStack.addArrangedSubview)
addSubview(titleLabel)
addSubview(listingBadge)
addSubview(tagStack)
addSubview(statsStack)
titleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(16)
make.trailing.lessThanOrEqualTo(listingBadge.snp.leading).offset(-12)
}
listingBadge.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(16)
make.centerY.equalTo(titleLabel)
make.height.equalTo(24)
make.width.greaterThanOrEqualTo(54)
}
tagStack.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(16)
}
statsStack.snp.makeConstraints { make in
make.top.equalTo(tagStack.snp.bottom).offset(12)
make.leading.equalToSuperview().offset(16)
make.trailing.lessThanOrEqualToSuperview().inset(16)
make.bottom.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(detail: MaterialDetail) {
titleLabel.text = detail.name ?? ""
let listed = detail.listingStatus == 1
listingBadge.text = listed ? "已上架" : "未上架"
let color = listed ? UIColor(hex: 0x22C55E) : AppColor.textSecondary
listingBadge.textColor = color
listingBadge.backgroundColor = color.withAlphaComponent(0.1)
tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
(detail.materialTag ?? []).forEach { tagStack.addArrangedSubview(makeTag($0.name)) }
downloadStat.setCount(detail.downloadCount)
likeStat.setCount(detail.likesCount)
collectStat.setCount(detail.collectCount)
}
private func makeTag(_ title: String) -> UILabel {
let label = UILabel()
label.text = title
label.font = .systemFont(ofSize: 12)
label.textColor = AppColor.primary
label.textAlignment = .center
label.backgroundColor = UIColor(hex: 0xEFF6FF)
label.layer.cornerRadius = 4
label.clipsToBounds = true
label.snp.makeConstraints { make in
make.height.equalTo(22)
make.width.greaterThanOrEqualTo(40)
}
return label
}
}
///
///
private final class MaterialDetailUploaderSection: UIView {
private let titleLabel = UILabel()
private let avatarView = UIImageView()
private let nameLabel = UILabel()
private let tagStack = UIStackView()
private let descriptionLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
titleLabel.text = "上传人"
titleLabel.font = .systemFont(ofSize: 16, weight: .bold)
titleLabel.textColor = AppColor.textPrimary
avatarView.contentMode = .scaleAspectFill
avatarView.clipsToBounds = true
avatarView.layer.cornerRadius = 24
avatarView.backgroundColor = UIColor(hex: 0xF5F5F5)
nameLabel.font = .systemFont(ofSize: 14, weight: .medium)
nameLabel.textColor = UIColor(hex: 0x002255)
descriptionLabel.font = .systemFont(ofSize: 14)
descriptionLabel.textColor = UIColor(hex: 0x7B8EAA)
descriptionLabel.numberOfLines = 0
tagStack.axis = .horizontal
tagStack.spacing = 8
addSubview(titleLabel)
addSubview(avatarView)
addSubview(nameLabel)
addSubview(tagStack)
addSubview(descriptionLabel)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(16)
}
avatarView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.leading.equalToSuperview().offset(16)
make.size.equalTo(48)
make.bottom.lessThanOrEqualToSuperview().inset(16)
}
nameLabel.snp.makeConstraints { make in
make.top.equalTo(avatarView)
make.leading.equalTo(avatarView.snp.trailing).offset(12)
}
tagStack.snp.makeConstraints { make in
make.leading.equalTo(nameLabel.snp.trailing).offset(8)
make.centerY.equalTo(nameLabel)
make.trailing.lessThanOrEqualToSuperview().inset(16)
}
descriptionLabel.snp.makeConstraints { make in
make.top.equalTo(nameLabel.snp.bottom).offset(4)
make.leading.equalTo(nameLabel)
make.trailing.equalToSuperview().inset(16)
make.bottom.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(detail: MaterialDetail) {
nameLabel.text = detail.uploaderName ?? ""
descriptionLabel.text = detail.uploaderDescription ?? ""
tagStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
if detail.platformCertification {
tagStack.addArrangedSubview(makeTag("平台认证"))
}
if detail.scenicCertification {
tagStack.addArrangedSubview(makeTag("景区认证"))
}
if let urlText = detail.uploaderAvatar, let url = URL(string: urlText), !urlText.isEmpty {
avatarView.kf.setImage(with: url, placeholder: UIImage(systemName: "person.crop.circle.fill"))
} else {
avatarView.image = UIImage(systemName: "person.crop.circle.fill")
avatarView.tintColor = AppColor.textTertiary
}
}
private func makeTag(_ title: String) -> UILabel {
let label = UILabel()
label.text = title
label.textColor = .white
label.font = .systemFont(ofSize: 8)
label.textAlignment = .center
label.backgroundColor = AppColor.primary
label.layer.cornerRadius = 4
label.clipsToBounds = true
label.snp.makeConstraints { make in
make.height.equalTo(16)
make.width.greaterThanOrEqualTo(44)
}
return label
}
}
///
///
private final class MaterialDetailReviewSection: UIView {
private let titleLabel = UILabel()
private let stack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
titleLabel.text = "审核信息"
titleLabel.font = .systemFont(ofSize: 14, weight: .bold)
titleLabel.textColor = .black
stack.axis = .vertical
stack.spacing = 0
addSubview(titleLabel)
addSubview(stack)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(16)
}
stack.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(detail: MaterialDetail) {
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
stack.addArrangedSubview(MaterialInfoRow(label: "审核人", value: detail.reviewer ?? "--"))
stack.addArrangedSubview(separator())
stack.addArrangedSubview(MaterialInfoRow(label: "审核时间", value: detail.reviewTime ?? "--"))
stack.addArrangedSubview(separator())
stack.addArrangedSubview(MaterialStatusRow(status: detail.status))
stack.addArrangedSubview(separator())
stack.addArrangedSubview(MaterialInfoRow(label: "审核备注", value: detail.reviewNotes ?? "--", valueColor: UIColor(hex: 0x4B5563), valueFont: .systemFont(ofSize: 12)))
}
private func separator() -> UIView {
let view = UIView()
view.backgroundColor = UIColor(hex: 0xEEEEEE)
view.snp.makeConstraints { make in
make.height.equalTo(1)
}
return view
}
}
///
///
private final class MaterialDetailStatView: UIView {
private let iconView = UIImageView()
private let countLabel = UILabel()
init(iconName: String? = nil, systemIconName: String? = nil) {
super.init(frame: .zero)
if let iconName {
iconView.image = UIImage(named: iconName)
} else if let systemIconName {
iconView.image = UIImage(systemName: systemIconName)
}
iconView.tintColor = UIColor(hex: 0x6B7280)
iconView.contentMode = .scaleAspectFit
countLabel.font = .systemFont(ofSize: 14)
countLabel.textColor = UIColor(hex: 0x6B7280)
addSubview(iconView)
addSubview(countLabel)
iconView.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.size.equalTo(16)
}
countLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(4)
make.trailing.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setCount(_ count: Int) {
countLabel.text = MaterialDisplayFormatter.formatCount(count)
}
}
///
///
private final class MaterialInfoRow: UIView {
private let label = UILabel()
private let valueLabel = UILabel()
init(label: String, value: String, valueColor: UIColor = .black, valueFont: UIFont = .systemFont(ofSize: 14, weight: .medium)) {
super.init(frame: .zero)
self.label.text = label
self.label.font = .systemFont(ofSize: 14)
self.label.textColor = UIColor(hex: 0x2E3746)
valueLabel.text = value
valueLabel.font = valueFont
valueLabel.textColor = valueColor
valueLabel.textAlignment = .right
valueLabel.numberOfLines = 1
addSubview(self.label)
addSubview(valueLabel)
snp.makeConstraints { make in
make.height.equalTo(44)
}
self.label.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
}
valueLabel.snp.makeConstraints { make in
make.leading.greaterThanOrEqualTo(self.label.snp.trailing).offset(12)
make.trailing.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
///
///
private final class MaterialStatusRow: UIView {
private let label = UILabel()
private let badge = MaterialStatusBadgeView()
init(status: Int) {
super.init(frame: .zero)
label.text = "审核状态"
label.font = .systemFont(ofSize: 14)
label.textColor = UIColor(hex: 0x2E3746)
badge.apply(status: status)
addSubview(label)
addSubview(badge)
snp.makeConstraints { make in
make.height.equalTo(44)
}
label.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
}
badge.snp.makeConstraints { make in
make.trailing.centerY.equalToSuperview()
make.height.equalTo(24)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,929 @@
//
// MaterialFormViewController.swift
// suixinkan
//
import AVFoundation
import Kingfisher
import PhotosUI
import SnapKit
import UniformTypeIdentifiers
import UIKit
/// / Android `UploadMaterialScreen` `EditMaterialScreen`
///
final class MaterialFormViewController: BaseViewController {
private enum PickTarget {
case material
case cover
}
private let viewModel: MaterialFormViewModel
private let api: any MaterialManagementServing
private let uploader: any MaterialOSSUploading
private let scrollView = UIScrollView()
private let formCard = UIView()
private let formStack = UIStackView()
private let nameField = UITextField()
private let nameSection = MaterialFormFieldView(title: "素材名称")
private let spotButton = MaterialSelectButton()
private let spotSection = MaterialFormFieldView(title: "关联打卡点")
private let mediaTypeButton = MaterialSelectButton()
private let mediaTypeSection = MaterialFormFieldView(title: "素材类型")
private let mediaGridView = MaterialUploadGridView()
private let materialSection = MaterialFormFieldView(title: "上传素材")
private let coverView = MaterialCoverUploadView()
private let coverSection = MaterialFormFieldView(title: "上传封面")
private let tagView = MaterialTagEditorView()
private let tagSection = MaterialFormFieldView(title: "标签")
private let bottomBar = UIView()
private let cancelButton = UIButton(type: .system)
private let confirmButton = UIButton(type: .system)
private let progressOverlay = MaterialUploadProgressOverlay()
private var pickTarget: PickTarget = .material
var onSubmitSuccess: (() -> Void)?
///
init(
mode: MaterialFormViewModel.Mode,
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI,
uploader: any MaterialOSSUploading = NetworkServices.shared.ossUploadService
) {
viewModel = MaterialFormViewModel(mode: mode)
self.api = api
self.uploader = uploader
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
switch viewModel.mode {
case .create: title = "上传素材"
case .edit: title = "编辑素材"
}
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF4F4F4)
formCard.backgroundColor = .white
formCard.layer.cornerRadius = 12
formCard.clipsToBounds = true
formStack.axis = .vertical
formStack.spacing = 16
nameField.placeholder = "请输入素材名称"
nameField.font = .systemFont(ofSize: 14)
nameField.textColor = .black
nameField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
nameField.layer.borderWidth = 1
nameField.layer.cornerRadius = 8
nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44))
nameField.leftViewMode = .always
nameField.clearButtonMode = .whileEditing
nameSection.setContent(nameField)
spotSection.setContent(spotButton)
mediaTypeSection.setContent(mediaTypeButton)
materialSection.setContent(mediaGridView)
coverSection.setContent(coverView)
tagSection.setContent(tagView)
[nameSection, spotSection, mediaTypeSection, materialSection, coverSection, tagSection].forEach(formStack.addArrangedSubview)
bottomBar.backgroundColor = .white
configureBottomButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary)
configureBottomButton(confirmButton, title: "确认", backgroundColor: AppColor.primary, titleColor: .white)
view.addSubview(scrollView)
scrollView.addSubview(formCard)
formCard.addSubview(formStack)
view.addSubview(bottomBar)
bottomBar.addSubview(cancelButton)
bottomBar.addSubview(confirmButton)
view.addSubview(progressOverlay)
progressOverlay.isHidden = true
}
override func setupConstraints() {
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
cancelButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalToSuperview().offset(15)
make.width.equalTo(100)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
}
confirmButton.snp.makeConstraints { make in
make.top.height.equalTo(cancelButton)
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(15)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(bottomBar.snp.top)
}
formCard.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView).offset(-32)
}
formStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
nameField.snp.makeConstraints { make in
make.height.equalTo(44)
}
[spotButton, mediaTypeButton].forEach { button in
button.snp.makeConstraints { make in
make.height.equalTo(44)
}
}
progressOverlay.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
viewModel.onSubmitSuccess = { [weak self] in
Task { @MainActor in
self?.onSubmitSuccess?()
self?.navigationController?.popViewController(animated: true)
}
}
nameField.addTarget(self, action: #selector(nameChanged), for: .editingChanged)
spotButton.addTarget(self, action: #selector(spotTapped), for: .touchUpInside)
mediaTypeButton.addTarget(self, action: #selector(mediaTypeTapped), for: .touchUpInside)
mediaGridView.onAdd = { [weak self] in self?.presentPickerSource(target: .material) }
mediaGridView.onDelete = { [weak self] index in self?.viewModel.deleteMaterial(at: index) }
coverView.onAdd = { [weak self] in self?.presentPickerSource(target: .cover) }
coverView.onDelete = { [weak self] in self?.viewModel.deleteCoverImage() }
tagView.onTextChanged = { [weak self] text in self?.viewModel.updateTagInput(text) }
tagView.onAdd = { [weak self] in
guard let self else { return }
Task { await self.viewModel.addTag(api: self.api) }
}
tagView.onDelete = { [weak self] tag in self?.viewModel.deleteTag(tag) }
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
applyViewModel()
}
override func viewDidLoad() {
super.viewDidLoad()
Task {
showLoading()
await viewModel.loadInitial(api: api)
hideLoading()
}
}
@MainActor
private func applyViewModel() {
if nameField.text != viewModel.materialName {
nameField.text = viewModel.materialName
}
spotButton.setTitle(viewModel.selectedScenicSpot?.name, placeholder: "请选择打卡点")
mediaTypeButton.setTitle(viewModel.mediaType.title, placeholder: "")
mediaGridView.apply(items: viewModel.uploadedMediaList, mediaType: viewModel.mediaType)
coverView.apply(item: viewModel.coverImage)
tagView.apply(tags: viewModel.tagList, input: viewModel.tagInput)
confirmButton.alpha = viewModel.isSubmitting ? 0.6 : 1
confirmButton.isEnabled = !viewModel.isSubmitting
if let dialog = viewModel.uploadDialogState {
progressOverlay.apply(state: dialog)
progressOverlay.isHidden = false
} else {
progressOverlay.isHidden = true
}
}
private func configureBottomButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) {
button.setTitle(title, for: .normal)
button.setTitleColor(titleColor, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
button.backgroundColor = backgroundColor
button.layer.cornerRadius = 8
}
@objc private func nameChanged() {
viewModel.updateMaterialName(nameField.text ?? "")
}
@objc private func spotTapped() {
let sheet = UIAlertController(title: "关联打卡点", message: nil, preferredStyle: .actionSheet)
viewModel.scenicSpotList.forEach { spot in
sheet.addAction(UIAlertAction(title: spot.name, style: .default) { [weak self] _ in
self?.viewModel.selectScenicSpot(spot)
})
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func mediaTypeTapped() {
let sheet = UIAlertController(title: "素材类型", message: nil, preferredStyle: .actionSheet)
MaterialMediaType.allCases.forEach { type in
sheet.addAction(UIAlertAction(title: type.title, style: .default) { [weak self] _ in
self?.viewModel.selectMediaType(type)
})
}
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
@objc private func cancelTapped() {
navigationController?.popViewController(animated: true)
}
@objc private func confirmTapped() {
Task {
showLoading()
await viewModel.submit(api: api)
hideLoading()
}
}
private func presentPickerSource(target: PickTarget) {
pickTarget = target
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
if UIImagePickerController.isSourceTypeAvailable(.camera) {
let cameraTitle = viewModel.mediaType == .video && target == .material ? "拍摄视频" : "拍照"
sheet.addAction(UIAlertAction(title: cameraTitle, style: .default) { [weak self] _ in
self?.presentCamera()
})
}
sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in
self?.presentPhotoPicker()
})
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
present(sheet, animated: true)
}
private func presentPhotoPicker() {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
if pickTarget == .cover {
configuration.filter = .images
configuration.selectionLimit = 1
} else {
configuration.filter = viewModel.mediaType == .image ? .images : .videos
configuration.selectionLimit = viewModel.mediaType == .video
? 1
: max(1, viewModel.mediaType.maxCount - viewModel.uploadedMediaList.count)
}
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
present(picker, animated: true)
}
private func presentCamera() {
let picker = UIImagePickerController()
picker.sourceType = .camera
picker.delegate = self
if pickTarget == .material, viewModel.mediaType == .video {
picker.mediaTypes = [UTType.movie.identifier]
picker.cameraCaptureMode = .video
} else {
picker.mediaTypes = [UTType.image.identifier]
picker.cameraCaptureMode = .photo
}
present(picker, animated: true)
}
private func uploadPickedItems(_ items: [MaterialUploadedMediaItem]) {
guard !items.isEmpty else { return }
Task {
switch pickTarget {
case .material:
await viewModel.addLocalMedia(items, uploader: uploader)
case .cover:
await viewModel.setCoverImage(items[0], uploader: uploader)
}
}
}
private func makeImageItem(image: UIImage, fileName: String) -> MaterialUploadedMediaItem? {
guard let data = image.jpegData(compressionQuality: 0.9) else { return nil }
return MaterialUploadedMediaItem(
data: data,
fileName: fileName,
width: Int(image.size.width * image.scale),
height: Int(image.size.height * image.scale)
)
}
private func makeVideoItem(data: Data, fileName: String, sourceURL: URL) -> MaterialUploadedMediaItem {
let size = videoDisplaySize(url: sourceURL)
return MaterialUploadedMediaItem(
data: data,
thumbnailData: videoThumbnailData(url: sourceURL),
fileName: fileName,
width: Int(size.width),
height: Int(size.height)
)
}
private func videoDisplaySize(url: URL) -> CGSize {
let asset = AVAsset(url: url)
guard let track = asset.tracks(withMediaType: .video).first else { return .zero }
let transformed = track.naturalSize.applying(track.preferredTransform)
return CGSize(width: abs(transformed.width), height: abs(transformed.height))
}
private func videoThumbnailData(url: URL) -> Data? {
let generator = AVAssetImageGenerator(asset: AVAsset(url: url))
generator.appliesPreferredTrackTransform = true
guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return nil }
return UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75)
}
}
extension MaterialFormViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard !results.isEmpty else { return }
var loadedItems = [MaterialUploadedMediaItem?](repeating: nil, count: results.count)
let group = DispatchGroup()
for (index, result) in results.enumerated() {
let provider = result.itemProvider
if pickTarget == .material, viewModel.mediaType == .video {
group.enter()
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, _ in
defer { group.leave() }
guard let self, let url else { return }
let data = (try? Data(contentsOf: url)) ?? Data()
guard !data.isEmpty else { return }
let fileName = provider.suggestedName.map { "\($0).mp4" } ?? "material_video_\(Int(Date().timeIntervalSince1970)).mp4"
loadedItems[index] = self.makeVideoItem(data: data, fileName: fileName, sourceURL: url)
}
} else {
group.enter()
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
defer { group.leave() }
guard let self, let image = object as? UIImage else { return }
let fileName = "material_image_\(Int(Date().timeIntervalSince1970))_\(index).jpg"
loadedItems[index] = self.makeImageItem(image: image, fileName: fileName)
}
}
}
group.notify(queue: .main) { [weak self] in
self?.uploadPickedItems(loadedItems.compactMap { $0 })
}
}
}
extension MaterialFormViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
picker.dismiss(animated: true)
if pickTarget == .material, viewModel.mediaType == .video,
let url = info[.mediaURL] as? URL,
let data = try? Data(contentsOf: url) {
uploadPickedItems([makeVideoItem(data: data, fileName: url.lastPathComponent, sourceURL: url)])
return
}
if let image = info[.originalImage] as? UIImage,
let item = makeImageItem(image: image, fileName: "material_image_\(Int(Date().timeIntervalSince1970)).jpg") {
uploadPickedItems([item])
}
}
}
///
///
private final class MaterialFormFieldView: UIView {
private let titleLabel = UILabel()
private let contentHolder = UIView()
init(title: String) {
super.init(frame: .zero)
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = .black
addSubview(titleLabel)
addSubview(contentHolder)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
contentHolder.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setContent(_ view: UIView) {
contentHolder.subviews.forEach { $0.removeFromSuperview() }
contentHolder.addSubview(view)
view.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
}
///
///
private final class MaterialSelectButton: UIControl {
private let titleLabel = UILabel()
private let chevronView = UIImageView(image: UIImage(systemName: "chevron.down"))
override init(frame: CGRect) {
super.init(frame: frame)
layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
layer.borderWidth = 1
layer.cornerRadius = 8
titleLabel.font = .systemFont(ofSize: 14)
chevronView.tintColor = .black
addSubview(titleLabel)
addSubview(chevronView)
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8)
}
chevronView.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(12)
make.centerY.equalToSuperview()
make.size.equalTo(20)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setTitle(_ title: String?, placeholder: String) {
let text = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
titleLabel.text = text.isEmpty ? placeholder : text
titleLabel.textColor = text.isEmpty ? UIColor(hex: 0x4B5563) : .black
}
}
///
///
private final class MaterialUploadGridView: UIView {
private let stack = UIStackView()
var onAdd: (() -> Void)?
var onDelete: ((Int) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
stack.axis = .vertical
stack.spacing = 8
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(items: [MaterialUploadedMediaItem], mediaType: MaterialMediaType) {
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
let showAdd = items.count < mediaType.maxCount
let cells = items.map(Optional.some) + (showAdd ? [nil] : [])
let rows = Int(ceil(Double(cells.count) / 3.0))
guard rows > 0 else { return }
for row in 0 ..< rows {
let rowStack = UIStackView()
rowStack.axis = .horizontal
rowStack.spacing = 8
rowStack.distribution = .fillEqually
stack.addArrangedSubview(rowStack)
for column in 0 ..< 3 {
let index = row * 3 + column
if index < cells.count {
rowStack.addArrangedSubview(makeCell(item: cells[index], index: index, mediaType: mediaType))
} else {
rowStack.addArrangedSubview(UIView())
}
}
}
}
private func makeCell(item: MaterialUploadedMediaItem?, index: Int, mediaType: MaterialMediaType) -> UIView {
let cell = MaterialUploadThumbView(mediaType: mediaType)
cell.snp.makeConstraints { make in
make.height.equalTo(cell.snp.width).dividedBy(1.3)
}
if let item {
cell.apply(item: item)
cell.onDelete = { [weak self] in self?.onDelete?(index) }
} else {
cell.applyAdd()
cell.onAdd = { [weak self] in self?.onAdd?() }
}
return cell
}
}
///
///
private final class MaterialUploadThumbView: UIControl {
private let imageView = UIImageView()
private let addCircle = UIView()
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
private let textLabel = UILabel()
private let deleteButton = UIButton(type: .system)
private let playIcon = UIImageView(image: UIImage(systemName: "play.fill"))
private let mediaType: MaterialMediaType
var onAdd: (() -> Void)?
var onDelete: (() -> Void)?
init(mediaType: MaterialMediaType) {
self.mediaType = mediaType
super.init(frame: .zero)
layer.cornerRadius = 8
clipsToBounds = true
backgroundColor = .white
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
addCircle.backgroundColor = AppColor.primary
addCircle.layer.cornerRadius = 14
addIcon.tintColor = .white
textLabel.font = .systemFont(ofSize: 12)
textLabel.textColor = UIColor(hex: 0x4B5563)
textLabel.textAlignment = .center
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
deleteButton.tintColor = .white
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
deleteButton.layer.cornerRadius = 10
playIcon.tintColor = .white
playIcon.isHidden = true
addSubview(imageView)
addSubview(addCircle)
addCircle.addSubview(addIcon)
addSubview(textLabel)
addSubview(deleteButton)
addSubview(playIcon)
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
addCircle.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-10)
make.size.equalTo(28)
}
addIcon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(20)
}
textLabel.snp.makeConstraints { make in
make.top.equalTo(addCircle.snp.bottom).offset(4)
make.leading.trailing.equalToSuperview().inset(4)
}
deleteButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(4)
make.size.equalTo(20)
}
playIcon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(32)
}
addTarget(self, action: #selector(addTapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func applyAdd() {
imageView.image = nil
imageView.kf.cancelDownloadTask()
backgroundColor = .white
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
layer.borderWidth = 1
addCircle.isHidden = false
textLabel.isHidden = false
textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频"
textLabel.numberOfLines = 1
deleteButton.isHidden = true
playIcon.isHidden = true
}
func apply(item: MaterialUploadedMediaItem) {
layer.borderWidth = 0
backgroundColor = UIColor(hex: 0xF5F5F5)
addCircle.isHidden = true
deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading
playIcon.isHidden = mediaType != .video || item.ossUrl.isEmpty || item.isUploading
if item.isUploading {
imageView.image = nil
textLabel.isHidden = false
textLabel.text = "上传中...\n\(item.uploadProgress)%"
textLabel.numberOfLines = 2
} else {
textLabel.isHidden = true
if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
imageView.kf.setImage(with: url)
} else {
imageView.image = UIImage(data: item.thumbnailData ?? item.data)
}
}
}
@objc private func addTapped() {
guard !addCircle.isHidden else { return }
onAdd?()
}
@objc private func deleteTapped() {
onDelete?()
}
}
///
///
private final class MaterialCoverUploadView: UIControl {
private let imageView = UIImageView()
private let addCircle = UIView()
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
private let titleLabel = UILabel()
private let hintLabel = UILabel()
private let deleteButton = UIButton(type: .system)
var onAdd: (() -> Void)?
var onDelete: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 8
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
layer.borderWidth = 1
clipsToBounds = true
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
addCircle.backgroundColor = AppColor.primary
addCircle.layer.cornerRadius = 16
addIcon.tintColor = .white
titleLabel.text = "点击上传图片"
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x4B5563)
titleLabel.textAlignment = .center
hintLabel.text = "建议尺寸: 1920x1080px, 大小不超过5MB"
hintLabel.font = .systemFont(ofSize: 12)
hintLabel.textColor = UIColor(hex: 0xB6BECA)
hintLabel.textAlignment = .center
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
deleteButton.tintColor = .white
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
deleteButton.layer.cornerRadius = 12
addSubview(imageView)
addSubview(addCircle)
addCircle.addSubview(addIcon)
addSubview(titleLabel)
addSubview(hintLabel)
addSubview(deleteButton)
snp.makeConstraints { make in
make.height.equalTo(snp.width).dividedBy(2.59)
}
imageView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
addCircle.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalToSuperview().offset(-22)
make.size.equalTo(32)
}
addIcon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(24)
}
titleLabel.snp.makeConstraints { make in
make.top.equalTo(addCircle.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(12)
}
hintLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.equalToSuperview().inset(12)
}
deleteButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(8)
make.size.equalTo(24)
}
addTarget(self, action: #selector(addTapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(item: MaterialUploadedMediaItem?) {
guard let item else {
imageView.image = nil
imageView.kf.cancelDownloadTask()
addCircle.isHidden = false
titleLabel.isHidden = false
titleLabel.text = "点击上传图片"
titleLabel.numberOfLines = 1
hintLabel.isHidden = false
deleteButton.isHidden = true
return
}
if item.isUploading {
imageView.image = nil
addCircle.isHidden = true
titleLabel.isHidden = false
titleLabel.text = "上传中...\n\(item.uploadProgress)%"
titleLabel.numberOfLines = 2
hintLabel.isHidden = true
deleteButton.isHidden = true
} else {
if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
imageView.kf.setImage(with: url)
} else {
imageView.image = UIImage(data: item.data)
}
addCircle.isHidden = true
titleLabel.isHidden = true
hintLabel.isHidden = true
deleteButton.isHidden = item.ossUrl.isEmpty
}
}
@objc private func addTapped() {
guard addCircle.isHidden == false else { return }
onAdd?()
}
@objc private func deleteTapped() {
onDelete?()
}
}
///
///
private final class MaterialTagEditorView: UIView {
private let stack = UIStackView()
private let tagsStack = UIStackView()
private let inputRow = UIStackView()
private let textField = UITextField()
private let addButton = UIButton(type: .system)
private var tags: [MaterialTagItem] = []
var onTextChanged: ((String) -> Void)?
var onAdd: (() -> Void)?
var onDelete: ((MaterialTagItem) -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
stack.axis = .vertical
stack.spacing = 8
tagsStack.axis = .horizontal
tagsStack.spacing = 8
tagsStack.alignment = .leading
inputRow.axis = .horizontal
inputRow.spacing = 8
textField.placeholder = "请输入标签"
textField.font = .systemFont(ofSize: 14)
textField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
textField.layer.borderWidth = 1
textField.layer.cornerRadius = 8
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44))
textField.leftViewMode = .always
addButton.setTitle("添加", for: .normal)
addButton.setTitleColor(.white, for: .normal)
addButton.backgroundColor = AppColor.primary
addButton.layer.cornerRadius = 8
addSubview(stack)
stack.addArrangedSubview(tagsStack)
stack.addArrangedSubview(inputRow)
inputRow.addArrangedSubview(textField)
inputRow.addArrangedSubview(addButton)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
textField.snp.makeConstraints { make in
make.height.equalTo(44)
}
addButton.snp.makeConstraints { make in
make.width.equalTo(72)
make.height.equalTo(44)
}
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(tags: [MaterialTagItem], input: String) {
self.tags = tags
if textField.text != input {
textField.text = input
}
tagsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
tags.forEach { tag in
let button = UIButton(type: .system)
button.setTitle("\(tag.name) ×", for: .normal)
button.setTitleColor(AppColor.primary, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 12)
button.backgroundColor = UIColor(hex: 0xEFF6FF)
button.layer.cornerRadius = 4
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
button.snp.makeConstraints { make in
make.height.equalTo(28)
}
button.addAction(UIAction { [weak self] _ in
self?.onDelete?(tag)
}, for: .touchUpInside)
tagsStack.addArrangedSubview(button)
}
tagsStack.isHidden = tags.isEmpty
}
@objc private func textChanged() {
onTextChanged?(textField.text ?? "")
}
@objc private func addTapped() {
onAdd?()
}
}
///
///
private final class MaterialUploadProgressOverlay: UIView {
private let card = UIView()
private let titleLabel = UILabel()
private let progressView = UIProgressView(progressViewStyle: .default)
private let percentLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor.black.withAlphaComponent(0.2)
card.backgroundColor = .white
card.layer.cornerRadius = 12
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.textColor = AppColor.textPrimary
progressView.progressTintColor = AppColor.primary
progressView.trackTintColor = UIColor(hex: 0xF3F4F6)
percentLabel.font = .systemFont(ofSize: 14)
percentLabel.textColor = AppColor.textSecondary
percentLabel.textAlignment = .center
addSubview(card)
card.addSubview(titleLabel)
card.addSubview(progressView)
card.addSubview(percentLabel)
card.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(44)
make.height.equalTo(card.snp.width).dividedBy(1.7)
}
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(24)
}
progressView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(32)
make.leading.trailing.equalToSuperview().inset(24)
make.height.equalTo(6)
}
percentLabel.snp.makeConstraints { make in
make.top.equalTo(progressView.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview().inset(24)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(state: MaterialUploadDialogState) {
titleLabel.text = state.title
let progress = Float(max(0, min(100, state.progress))) / 100
progressView.setProgress(progress, animated: true)
percentLabel.text = "\(max(0, min(100, state.progress)))%"
}
}

View File

@ -0,0 +1,654 @@
//
// MaterialListViewController.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
/// Android `MaterialListScreen`
///
final class MaterialListViewController: BaseViewController {
private let viewModel: MaterialListViewModel
private let api: any MaterialManagementServing
private let searchContainer = UIView()
private let searchBox = UIView()
private let searchIconView = UIImageView(image: UIImage(systemName: "magnifyingglass"))
private let searchTextField = UITextField()
private let filterContainer = UIView()
private let filterButton = UIButton(type: .system)
private let filterTitleLabel = UILabel()
private let filterChevronView = UIImageView(image: UIImage(systemName: "chevron.down"))
private let filterDropdownView = UIView()
private let filterDropdownStack = UIStackView()
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyLabel = UILabel()
private let bottomBar = UIView()
private let addButton = UIButton(type: .system)
private var dataSource: UITableViewDiffableDataSource<Int, MaterialListRow>!
private var filterOptionButtons: [(MaterialFilterStatus, UIButton)] = []
private var needsRefreshOnAppear = false
///
init(
viewModel: MaterialListViewModel = MaterialListViewModel(),
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI
) {
self.viewModel = viewModel
self.api = api
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "素材管理"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
searchContainer.backgroundColor = .white
searchBox.backgroundColor = UIColor(hex: 0xF4F4F4)
searchBox.layer.cornerRadius = 4
searchIconView.tintColor = AppColor.textSecondary
searchIconView.contentMode = .scaleAspectFit
searchTextField.placeholder = "搜索素材名称"
searchTextField.font = .systemFont(ofSize: 13)
searchTextField.textColor = AppColor.textPrimary
searchTextField.clearButtonMode = .whileEditing
searchTextField.returnKeyType = .search
filterContainer.backgroundColor = .white
filterButton.backgroundColor = UIColor(hex: 0xF4F4F4)
filterButton.layer.cornerRadius = 4
filterTitleLabel.text = "筛选"
filterTitleLabel.font = .systemFont(ofSize: 14)
filterTitleLabel.textColor = UIColor(hex: 0x4B5563)
filterChevronView.tintColor = UIColor(hex: 0x4B5563)
filterChevronView.contentMode = .scaleAspectFit
filterDropdownView.backgroundColor = .white
filterDropdownView.layer.cornerRadius = 4
filterDropdownView.layer.shadowColor = UIColor.black.cgColor
filterDropdownView.layer.shadowOpacity = 0.12
filterDropdownView.layer.shadowRadius = 10
filterDropdownView.layer.shadowOffset = CGSize(width: 0, height: 4)
filterDropdownView.isHidden = true
filterDropdownStack.axis = .vertical
tableView.backgroundColor = AppColor.pageBackground
tableView.separatorStyle = .none
tableView.estimatedRowHeight = 148
tableView.rowHeight = UITableView.automaticDimension
tableView.delegate = self
tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 16, right: 0)
tableView.refreshControl = UIRefreshControl()
tableView.register(MaterialStatsCell.self, forCellReuseIdentifier: MaterialStatsCell.reuseIdentifier)
tableView.register(MaterialListCell.self, forCellReuseIdentifier: MaterialListCell.reuseIdentifier)
dataSource = UITableViewDiffableDataSource<Int, MaterialListRow>(tableView: tableView) {
[weak self] tableView, indexPath, row in
switch row {
case .stats(let info):
let cell = tableView.dequeueReusableCell(
withIdentifier: MaterialStatsCell.reuseIdentifier,
for: indexPath
) as! MaterialStatsCell
cell.apply(info: info)
return cell
case .item(let item):
let cell = tableView.dequeueReusableCell(
withIdentifier: MaterialListCell.reuseIdentifier,
for: indexPath
) as! MaterialListCell
cell.apply(item: item)
cell.onToggle = { [weak self] checked in
guard let self else { return }
Task { await self.viewModel.toggleListing(materialId: item.id, checked: checked, api: self.api) }
}
return cell
}
}
emptyLabel.text = "暂无素材"
emptyLabel.font = .systemFont(ofSize: 16)
emptyLabel.textColor = AppColor.textSecondary
emptyLabel.textAlignment = .center
emptyLabel.isHidden = true
bottomBar.backgroundColor = .white
addButton.setTitle("添加", for: .normal)
addButton.setTitleColor(.white, for: .normal)
addButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
addButton.backgroundColor = AppColor.primary
addButton.layer.cornerRadius = 8
view.addSubview(searchContainer)
searchContainer.addSubview(searchBox)
searchBox.addSubview(searchIconView)
searchBox.addSubview(searchTextField)
view.addSubview(filterContainer)
filterContainer.addSubview(filterButton)
filterButton.addSubview(filterTitleLabel)
filterButton.addSubview(filterChevronView)
view.addSubview(tableView)
view.addSubview(emptyLabel)
view.addSubview(bottomBar)
bottomBar.addSubview(addButton)
view.addSubview(filterDropdownView)
filterDropdownView.addSubview(filterDropdownStack)
configureFilterDropdown()
}
override func setupConstraints() {
searchContainer.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
make.height.equalTo(68)
}
searchBox.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.centerY.equalToSuperview()
make.height.equalTo(36)
}
searchIconView.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.size.equalTo(15)
}
searchTextField.snp.makeConstraints { make in
make.leading.equalTo(searchIconView.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(12)
make.top.bottom.equalToSuperview()
}
filterContainer.snp.makeConstraints { make in
make.top.equalTo(searchContainer.snp.bottom).offset(1)
make.leading.trailing.equalToSuperview()
make.height.equalTo(68)
}
filterButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.centerY.equalToSuperview()
make.height.equalTo(36)
}
filterTitleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
}
filterChevronView.snp.makeConstraints { make in
make.trailing.equalToSuperview().inset(14)
make.centerY.equalToSuperview()
make.size.equalTo(18)
}
bottomBar.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
}
addButton.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.trailing.equalToSuperview().inset(15)
make.height.equalTo(48)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(filterContainer.snp.bottom).offset(12)
make.leading.trailing.equalToSuperview()
make.bottom.equalTo(bottomBar.snp.top)
}
filterDropdownView.snp.makeConstraints { make in
make.top.equalTo(filterButton.snp.bottom)
make.leading.trailing.equalTo(filterButton)
}
filterDropdownStack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
emptyLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.centerY.equalTo(tableView)
make.leading.trailing.equalToSuperview().inset(24)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
searchTextField.addTarget(self, action: #selector(searchTextChanged), for: .editingChanged)
searchTextField.delegate = self
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
}
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.loadInitial(api: api) }
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if needsRefreshOnAppear {
needsRefreshOnAppear = false
Task { await viewModel.refresh(api: api) }
}
}
@MainActor
private func applyViewModel() {
filterTitleLabel.text = viewModel.filterStatus == .all ? "筛选" : viewModel.filterStatus.title
filterOptionButtons.forEach { status, button in
let selected = status == viewModel.filterStatus
button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .medium : .regular)
}
if searchTextField.text != viewModel.searchText {
searchTextField.text = viewModel.searchText
}
var snapshot = NSDiffableDataSourceSnapshot<Int, MaterialListRow>()
snapshot.appendSections([0])
snapshot.appendItems([.stats(viewModel.orderInfo)] + viewModel.items.map(MaterialListRow.item))
dataSource.apply(snapshot, animatingDifferences: true)
emptyLabel.isHidden = !viewModel.items.isEmpty || viewModel.isLoading || viewModel.isRefreshing
viewModel.isLoading && viewModel.items.isEmpty ? showLoading() : hideLoading()
if !viewModel.isRefreshing {
tableView.refreshControl?.endRefreshing()
}
}
@objc private func searchTextChanged() {
let text = searchTextField.text ?? ""
Task { await viewModel.updateSearchText(text, api: api) }
}
@objc private func filterTapped() {
filterDropdownView.isHidden.toggle()
view.bringSubviewToFront(filterDropdownView)
UIView.animate(withDuration: 0.2) {
self.filterChevronView.transform = self.filterDropdownView.isHidden
? .identity
: CGAffineTransform(rotationAngle: .pi)
}
}
@objc private func addTapped() {
let controller = MaterialFormViewController(mode: .create)
controller.onSubmitSuccess = { [weak self] in
self?.needsRefreshOnAppear = true
}
navigationController?.pushViewController(controller, animated: true)
}
@objc private func refreshPulled() {
hideFilterDropdown()
Task { await viewModel.refresh(api: api) }
}
private func configureFilterDropdown() {
MaterialFilterStatus.allCases.forEach { status in
let button = UIButton(type: .system)
button.contentHorizontalAlignment = .leading
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
button.setTitle(status.title, for: .normal)
button.backgroundColor = .white
button.snp.makeConstraints { make in
make.height.equalTo(48)
}
button.addAction(UIAction { [weak self] _ in
guard let self else { return }
self.hideFilterDropdown()
Task { await self.viewModel.selectFilter(status, api: self.api) }
}, for: .touchUpInside)
filterDropdownStack.addArrangedSubview(button)
filterOptionButtons.append((status, button))
}
}
private func hideFilterDropdown() {
guard !filterDropdownView.isHidden else { return }
filterDropdownView.isHidden = true
UIView.animate(withDuration: 0.2) {
self.filterChevronView.transform = .identity
}
}
}
extension MaterialListViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
hideFilterDropdown()
tableView.deselectRow(at: indexPath, animated: true)
guard case .item(let item) = dataSource.itemIdentifier(for: indexPath) else { return }
let controller = MaterialDetailViewController(materialId: item.id)
controller.onDeleted = { [weak self] deletedId in
self?.viewModel.removeMaterial(id: deletedId)
}
controller.onEdited = { [weak self] in
self?.needsRefreshOnAppear = true
}
navigationController?.pushViewController(controller, animated: true)
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
guard case .item = dataSource.itemIdentifier(for: indexPath) else { return }
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: indexPath.row - 1, api: api) }
}
}
extension MaterialListViewController: UITextFieldDelegate {
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
}
/// Diffable
private enum MaterialListRow: Hashable {
case stats(MaterialOrderInfo)
case item(MaterialItem)
}
///
///
private final class MaterialStatsCell: UITableViewCell {
static let reuseIdentifier = "MaterialStatsCell"
private let cardView = UIView()
private let stack = UIStackView()
private let totalView = MaterialStatisticView(title: "订单总数量", color: UIColor(hex: 0x7F00FF), background: UIColor(hex: 0xEBD8FF))
private let priceView = MaterialStatisticView(title: "客单价", color: UIColor(hex: 0x22C55E), background: UIColor(hex: 0xF0FDF4))
private let refundView = MaterialStatisticView(title: "退款金额", color: UIColor(hex: 0xFF7B00), background: UIColor(hex: 0xFFF0E2))
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = AppColor.pageBackground
contentView.backgroundColor = AppColor.pageBackground
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
stack.axis = .horizontal
stack.spacing = 12
stack.distribution = .fillEqually
contentView.addSubview(cardView)
cardView.addSubview(stack)
[totalView, priceView, refundView].forEach(stack.addArrangedSubview)
cardView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(info: MaterialOrderInfo) {
totalView.setValue(MaterialDisplayFormatter.formatNumber(info.totalNum))
priceView.setValue("+\(info.avgOrderAmount)")
refundView.setValue(info.refundTotal)
}
}
///
///
private final class MaterialStatisticView: UIView {
private let titleLabel = UILabel()
private let valueLabel = UILabel()
init(title: String, color: UIColor, background: UIColor) {
super.init(frame: .zero)
self.backgroundColor = background
layer.cornerRadius = 12
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = .black
titleLabel.textAlignment = .center
valueLabel.font = .systemFont(ofSize: 18, weight: .bold)
valueLabel.textColor = color
valueLabel.textAlignment = .center
valueLabel.adjustsFontSizeToFitWidth = true
valueLabel.minimumScaleFactor = 0.7
addSubview(titleLabel)
addSubview(valueLabel)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(8)
}
valueLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(10)
make.leading.trailing.bottom.equalToSuperview().inset(8)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setValue(_ value: String) {
valueLabel.text = value
}
}
/// cell Android `MaterialItemView`
///
private final class MaterialListCell: UITableViewCell {
static let reuseIdentifier = "MaterialListCell"
private let cardView = UIView()
private let coverImageView = UIImageView()
private let placeholderLabel = UILabel()
private let titleLabel = UILabel()
private let timeLabel = UILabel()
private let statusBadge = MaterialStatusBadgeView()
private let listingSwitch = UISwitch()
private let statsStack = UIStackView()
private let downloadStatView = MaterialStatView(systemIconName: "arrow.down.circle")
private let likeStatView = MaterialStatView(iconName: "sample_ic_like_count")
private let collectStatView = MaterialStatView(iconName: "sample_ic_collect_count")
private var isApplying = false
var onToggle: ((Bool) -> Void)?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(item: MaterialItem) {
isApplying = true
titleLabel.text = item.name
timeLabel.text = item.createdAt
statusBadge.apply(status: item.status)
listingSwitch.setOn(item.listingStatus == 1, animated: false)
downloadStatView.setCount(item.downloadCount)
likeStatView.setCount(item.likesCount)
collectStatView.setCount(item.collectCount)
if let url = URL(string: item.coverUrl), !item.coverUrl.isEmpty {
placeholderLabel.isHidden = true
coverImageView.kf.setImage(with: url)
} else {
coverImageView.image = nil
placeholderLabel.isHidden = false
}
isApplying = false
}
private func setupUI() {
selectionStyle = .none
backgroundColor = AppColor.pageBackground
contentView.backgroundColor = AppColor.pageBackground
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
cardView.clipsToBounds = true
coverImageView.backgroundColor = UIColor(hex: 0xF5F5F5)
coverImageView.contentMode = .scaleAspectFill
coverImageView.clipsToBounds = true
coverImageView.layer.cornerRadius = 8
placeholderLabel.text = "暂无图片"
placeholderLabel.font = .systemFont(ofSize: 12)
placeholderLabel.textColor = AppColor.textSecondary
placeholderLabel.textAlignment = .center
titleLabel.font = .systemFont(ofSize: 16, weight: .bold)
titleLabel.textColor = .black
titleLabel.numberOfLines = 1
timeLabel.font = .systemFont(ofSize: 12)
timeLabel.textColor = UIColor(hex: 0x4B5563)
timeLabel.numberOfLines = 1
listingSwitch.onTintColor = AppColor.primary
listingSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged)
statsStack.axis = .horizontal
statsStack.spacing = 16
statsStack.alignment = .center
contentView.addSubview(cardView)
cardView.addSubview(coverImageView)
coverImageView.addSubview(placeholderLabel)
cardView.addSubview(titleLabel)
cardView.addSubview(timeLabel)
cardView.addSubview(statusBadge)
cardView.addSubview(listingSwitch)
cardView.addSubview(statsStack)
[downloadStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview)
cardView.snp.makeConstraints { make in
make.top.bottom.equalToSuperview().inset(8)
make.leading.trailing.equalToSuperview().inset(16)
}
coverImageView.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(12)
make.width.equalTo(109)
make.height.equalTo(100)
}
placeholderLabel.snp.makeConstraints { make in
make.center.equalToSuperview()
make.leading.trailing.equalToSuperview().inset(8)
}
titleLabel.snp.makeConstraints { make in
make.top.equalToSuperview().offset(16)
make.leading.equalTo(coverImageView.snp.trailing).offset(12)
make.trailing.equalToSuperview().inset(12)
}
timeLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.leading.trailing.equalTo(titleLabel)
}
statusBadge.snp.makeConstraints { make in
make.top.equalTo(timeLabel.snp.bottom).offset(8)
make.leading.equalTo(titleLabel)
make.height.equalTo(24)
}
listingSwitch.snp.makeConstraints { make in
make.centerY.equalTo(statusBadge)
make.trailing.equalToSuperview().inset(12)
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
}
statsStack.snp.makeConstraints { make in
make.top.equalTo(statusBadge.snp.bottom).offset(8)
make.leading.equalTo(titleLabel)
make.trailing.lessThanOrEqualToSuperview().inset(12)
make.bottom.lessThanOrEqualToSuperview().inset(12)
}
}
@objc private func switchChanged() {
guard !isApplying else { return }
onToggle?(listingSwitch.isOn)
}
}
/// badge
///
final class MaterialStatusBadgeView: UIView {
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
layer.cornerRadius = 4
addSubview(label)
label.font = .systemFont(ofSize: 12, weight: .medium)
label.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(6)
make.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func apply(status: Int) {
let color: UIColor
switch status {
case 1: color = UIColor(hex: 0x22C55E)
case 0: color = UIColor(hex: 0xFF7B00)
case 2: color = UIColor(hex: 0xEF4444)
default: color = AppColor.textSecondary
}
label.text = MaterialDisplayFormatter.reviewStatusText(status)
label.textColor = color
backgroundColor = color.withAlphaComponent(0.1)
}
}
///
///
private final class MaterialStatView: UIView {
private let iconView = UIImageView()
private let countLabel = UILabel()
init(iconName: String? = nil, systemIconName: String? = nil) {
super.init(frame: .zero)
if let iconName {
iconView.image = UIImage(named: iconName)
} else if let systemIconName {
iconView.image = UIImage(systemName: systemIconName)
}
iconView.tintColor = UIColor(hex: 0x6B7280)
iconView.contentMode = .scaleAspectFit
countLabel.font = .systemFont(ofSize: 14)
countLabel.textColor = UIColor(hex: 0x6B7280)
addSubview(iconView)
addSubview(countLabel)
iconView.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
make.size.equalTo(16)
}
countLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(4)
make.trailing.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setCount(_ count: Int) {
countLabel.text = MaterialDisplayFormatter.formatCount(count)
}
}

View File

@ -30,6 +30,7 @@ final class ProfileViewController: BaseViewController {
private let realNameRow = ProfileInfoRowView(title: "认证状态")
private let statusRow = ProfileInfoRowView(title: "账号状态")
private let scenicRow = ProfileInfoRowView(title: "当前景区")
private let settingRow = ProfileInfoRowView(title: "设置中心")
private let logoutButton = UIButton(type: .system)
private var hasLoadedProfileOnce = false
@ -85,6 +86,7 @@ final class ProfileViewController: BaseViewController {
passwordRow.addTarget(self, action: #selector(changePasswordTapped), for: .touchUpInside)
withdrawalRow.addTarget(self, action: #selector(withdrawalTapped), for: .touchUpInside)
realNameRow.addTarget(self, action: #selector(realNameTapped), for: .touchUpInside)
settingRow.addTarget(self, action: #selector(settingTapped), for: .touchUpInside)
NotificationCenter.default.addObserver(
self,
@ -177,14 +179,15 @@ final class ProfileViewController: BaseViewController {
[
nameRow, accountRow, phoneRow, withdrawalRow,
passwordRow, realNameRow, statusRow, scenicRow
passwordRow, realNameRow, statusRow, scenicRow,
settingRow
].forEach { infoCard.addArrangedSubview($0) }
nameRow.isUserInteractionEnabled = false
phoneRow.isUserInteractionEnabled = false
statusRow.isUserInteractionEnabled = false
scenicRow.isUserInteractionEnabled = false
scenicRow.showsDivider = false
settingRow.showsDivider = false
}
private func setupLogoutButton() {
@ -224,6 +227,7 @@ final class ProfileViewController: BaseViewController {
value: viewModel.currentScenicName,
badge: .scenic
)
settingRow.configure(value: nil, accessory: .chevron)
}
private func configureWithdrawalRow() {
@ -342,6 +346,10 @@ final class ProfileViewController: BaseViewController {
}
}
@objc private func settingTapped() {
navigationController?.pushViewController(SettingViewController(), animated: true)
}
@objc private func logoutTapped() {
let alert = UIAlertController(title: "确定退出登录?", message: "退出后将需要重新登录", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel))

View File

@ -0,0 +1,67 @@
//
// AgreementWebViewController.swift
// suixinkan
//
import SnapKit
import UIKit
import WebKit
/// H5 使 WebView
final class AgreementWebViewController: BaseViewController {
private let pageTitle: String
private let url: URL
private let webView = WKWebView(frame: .zero)
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
init(title: String, url: URL) {
pageTitle = title
self.url = url
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = pageTitle
}
override func setupUI() {
view.backgroundColor = AppColor.cardBackground
webView.navigationDelegate = self
view.addSubview(webView)
view.addSubview(loadingIndicator)
loadingIndicator.hidesWhenStopped = true
loadingIndicator.startAnimating()
webView.load(URLRequest(url: url))
}
override func setupConstraints() {
webView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
loadingIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
}
}
extension AgreementWebViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
loadingIndicator.stopAnimating()
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
loadingIndicator.stopAnimating()
showToast(error.localizedDescription)
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
loadingIndicator.stopAnimating()
showToast(error.localizedDescription)
}
}

View File

@ -0,0 +1,230 @@
//
// SettingViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `SettingScreen`
final class SettingViewController: BaseViewController {
private let viewModel: SettingViewModel
private let settingAPI: SettingAPI
private let contentView = UIView()
private let cardView = UIView()
private let rowsStack = UIStackView()
private let versionRow = SettingMenuRow(title: "系统版本", showsChevron: false)
private let copyrightLabel = UILabel()
init(
viewModel: SettingViewModel = SettingViewModel(),
settingAPI: SettingAPI = NetworkServices.shared.settingAPI
) {
self.viewModel = viewModel
self.settingAPI = settingAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "设置"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
view.addSubview(contentView)
contentView.addSubview(cardView)
cardView.addSubview(rowsStack)
contentView.addSubview(copyrightLabel)
cardView.backgroundColor = .white
cardView.layer.cornerRadius = 12
cardView.clipsToBounds = true
rowsStack.axis = .vertical
copyrightLabel.text = "Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
copyrightLabel.font = .systemFont(ofSize: 14)
copyrightLabel.textColor = UIColor(hex: 0xB6BECA)
copyrightLabel.textAlignment = .center
copyrightLabel.numberOfLines = 0
let rows: [SettingMenuRow] = [
SettingMenuRow(title: "关于我们"),
versionRow,
SettingMenuRow(title: "App下载", value: "复制链接", valueColor: AppColor.primary, showsChevron: false),
SettingMenuRow(title: "用户协议"),
SettingMenuRow(title: "隐私政策", showsDivider: false),
]
rows.forEach(rowsStack.addArrangedSubview)
rows[0].addTarget(self, action: #selector(aboutTapped), for: .touchUpInside)
rows[2].addTarget(self, action: #selector(copyDownloadTapped), for: .touchUpInside)
rows[3].addTarget(self, action: #selector(userAgreementTapped), for: .touchUpInside)
rows[4].addTarget(self, action: #selector(privacyTapped), for: .touchUpInside)
}
override func setupConstraints() {
contentView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide).inset(16)
}
cardView.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
rowsStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16))
}
copyrightLabel.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview()
make.bottom.equalToSuperview().offset(-16)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
}
override func viewDidLoad() {
super.viewDidLoad()
applyViewModel()
Task { await viewModel.checkLatestVersion(api: settingAPI) }
}
private func applyViewModel() {
versionRow.configure(value: viewModel.appVersion, showsUpgrade: viewModel.hasNewVersion)
}
@objc private func aboutTapped() {
openAgreement(.aboutUs)
}
@objc private func copyDownloadTapped() {
UIPasteboard.general.string = viewModel.appDownloadURL.absoluteString
showToast("已复制到剪贴板!")
}
@objc private func userAgreementTapped() {
openAgreement(.userAgreement)
}
@objc private func privacyTapped() {
openAgreement(.privacyPolicy)
}
private func openAgreement(_ kind: SettingAgreementKind) {
let destination = viewModel.agreementDestination(for: kind)
navigationController?.pushViewController(
AgreementWebViewController(title: destination.title, url: destination.url),
animated: true
)
}
}
///
final class SettingMenuRow: UIControl {
private let titleLabel = UILabel()
private let rightStack = UIStackView()
private let valueLabel = UILabel()
private let upgradeImageView = UIImageView()
private let chevronImageView = UIImageView()
private let divider = UIView()
private let showsChevron: Bool
init(
title: String,
value: String? = nil,
valueColor: UIColor = AppColor.textPrimary,
showsChevron: Bool = true,
showsDivider: Bool = true
) {
self.showsChevron = showsChevron
super.init(frame: .zero)
setupUI()
setupConstraints()
titleLabel.text = title
valueLabel.text = value
valueLabel.textColor = valueColor
chevronImageView.isHidden = !showsChevron
divider.isHidden = !showsDivider
accessibilityTraits.insert(.button)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var isHighlighted: Bool {
didSet {
backgroundColor = isHighlighted ? UIColor.black.withAlphaComponent(0.04) : .clear
}
}
func configure(value: String?, showsUpgrade: Bool) {
valueLabel.text = value
upgradeImageView.isHidden = !showsUpgrade
}
private func setupUI() {
titleLabel.font = .systemFont(ofSize: 14)
titleLabel.textColor = UIColor(hex: 0x4B5563)
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.textPrimary
upgradeImageView.image = UIImage(systemName: "arrow.up.circle")?.withRenderingMode(.alwaysTemplate)
upgradeImageView.tintColor = UIColor(hex: 0x1F1F1F)
upgradeImageView.contentMode = .scaleAspectFit
upgradeImageView.isHidden = true
chevronImageView.image = UIImage(systemName: "chevron.right")?.withRenderingMode(.alwaysTemplate)
chevronImageView.tintColor = AppColor.textTertiary
chevronImageView.contentMode = .scaleAspectFit
divider.backgroundColor = AppColor.border
rightStack.axis = .horizontal
rightStack.alignment = .center
rightStack.spacing = 4
[valueLabel, upgradeImageView, chevronImageView].forEach(rightStack.addArrangedSubview)
[titleLabel, rightStack, divider].forEach(addSubview)
}
private func setupConstraints() {
snp.makeConstraints { make in
make.height.equalTo(52)
}
titleLabel.snp.makeConstraints { make in
make.leading.centerY.equalToSuperview()
}
upgradeImageView.snp.makeConstraints { make in
make.width.height.equalTo(24)
}
chevronImageView.snp.makeConstraints { make in
make.width.height.equalTo(24)
}
rightStack.snp.makeConstraints { make in
make.centerY.equalToSuperview()
make.trailing.equalToSuperview()
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
}
divider.snp.makeConstraints { make in
make.leading.trailing.bottom.equalToSuperview()
make.height.equalTo(0.5)
}
}
}

View File

@ -328,6 +328,19 @@ enum WildReportLocalMediaLoader {
}
}
extension MediaPreviewItem {
///
init?(wildReportAttachment attachment: WildReportAttachment) {
guard let url = attachment.localFileURL else { return nil }
switch attachment.kind {
case .image, .payment:
self = .localImage(url: url)
case .video:
self = .localVideo(url: url)
}
}
}
/// tile
final class WildReportAttachmentTileView: UIControl {
private let gradientLayer = CAGradientLayer()

View File

@ -9,11 +9,16 @@ import UIKit
///
final class WildPhotographerReportHomeViewController: BaseViewController {
private let viewModel: WildPhotographerReportHomeViewModel
private let api: any WildPhotographerReportServing
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
init(viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel()) {
init(
viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel(),
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
) {
self.viewModel = viewModel
self.api = api
super.init(nibName: nil, bundle: nil)
}
@ -25,20 +30,6 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
override func setupNavigationBar() {
title = "举报摄影师"
navigationItem.largeTitleDisplayMode = .never
let backButton = UIButton(type: .system)
backButton.backgroundColor = .white
backButton.tintColor = AppColor.textPrimary
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
backButton.layer.cornerRadius = 25
backButton.layer.shadowColor = UIColor.black.cgColor
backButton.layer.shadowOpacity = 0.05
backButton.layer.shadowOffset = CGSize(width: 0, height: 6)
backButton.layer.shadowRadius = 16
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
backButton.snp.makeConstraints { make in
make.size.equalTo(50)
}
navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backButton)
}
override func setupUI() {
@ -68,6 +59,16 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.rebuildContent() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
}
override func viewDidLoad() {
super.viewDidLoad()
Task {
await viewModel.loadReportCopy(api: api)
}
}
@MainActor
@ -81,18 +82,16 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
case .normal:
contentStack.addArrangedSubview(makeHeroCard())
contentStack.addArrangedSubview(makeRiskMapEntry())
contentStack.addArrangedSubview(makeNoticeCard())
if viewModel.shouldShowNoticeModule {
contentStack.addArrangedSubview(makeNoticeCard())
}
contentStack.addArrangedSubview(makeTrustCard())
case .loading:
contentStack.addArrangedSubview(makeStateCard(icon: "clock", title: "加载中", message: "正在加载举报服务"))
case .empty:
contentStack.addArrangedSubview(makeStateCard(icon: "exclamationmark.shield", title: viewModel.emptyTitle, message: viewModel.emptyMessage, buttonTitle: "恢复演示数据") { [weak self] in
self?.viewModel.restoreDemoData()
})
contentStack.addArrangedSubview(makeStateCard(icon: "exclamationmark.shield", title: viewModel.emptyTitle, message: viewModel.emptyMessage))
case .error:
contentStack.addArrangedSubview(makeStateCard(icon: "wifi.exclamationmark", title: "加载失败", message: "举报服务暂时不可用,请稍后重试。", buttonTitle: "重新加载") { [weak self] in
self?.viewModel.retryLoadAfterError()
})
contentStack.addArrangedSubview(makeStateCard(icon: "wifi.exclamationmark", title: "加载失败", message: "举报服务暂时不可用,请稍后重试。"))
}
}
@ -172,31 +171,28 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
headerIcon.tintColor = AppColor.primary
headerIcon.snp.makeConstraints { make in make.size.equalTo(24) }
let title = WildReportUI.label("举报须知", font: .systemFont(ofSize: 18, weight: .bold), color: AppColor.textPrimary)
let ruleButton = UIButton(type: .system)
ruleButton.setTitle("举报规则", for: .normal)
ruleButton.setImage(UIImage(systemName: "shield.lefthalf.filled"), for: .normal)
ruleButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
ruleButton.tintColor = AppColor.primary
ruleButton.setTitleColor(AppColor.primary, for: .normal)
ruleButton.backgroundColor = AppColor.primaryLight
ruleButton.layer.cornerRadius = 18
ruleButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
ruleButton.addTarget(self, action: #selector(ruleTapped), for: .touchUpInside)
ruleButton.snp.makeConstraints { make in
make.height.equalTo(36)
}
header.addArrangedSubview(headerIcon)
header.addArrangedSubview(title)
header.addArrangedSubview(UIView())
header.addArrangedSubview(ruleButton)
if viewModel.shouldShowRuleButton {
let ruleButton = UIButton(type: .system)
ruleButton.setTitle("举报规则", for: .normal)
ruleButton.setImage(UIImage(systemName: "shield.lefthalf.filled"), for: .normal)
ruleButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
ruleButton.tintColor = AppColor.primary
ruleButton.setTitleColor(AppColor.primary, for: .normal)
ruleButton.backgroundColor = AppColor.primaryLight
ruleButton.layer.cornerRadius = 18
ruleButton.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
ruleButton.addTarget(self, action: #selector(ruleTapped), for: .touchUpInside)
ruleButton.snp.makeConstraints { make in
make.height.equalTo(36)
}
header.addArrangedSubview(ruleButton)
}
let items = [
"需实名登录",
"请尽量上传清晰证据",
"恶意举报将被追责"
]
card.stack.addArrangedSubview(header)
items.forEach { item in
viewModel.noticeItems.forEach { item in
card.stack.addArrangedSubview(makeBullet(item))
}
return card
@ -206,7 +202,7 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
let badges = UIStackView()
badges.axis = .horizontal
badges.distribution = .fillEqually
badges.spacing = AppSpacing.sm
badges.spacing = AppSpacing.xs
[
("实名", "身份核验 真实可靠", "person.crop.circle.badge.checkmark", AppColor.primary, AppColor.primaryLight),
("安全", "数据加密 隐私保护", "checkmark.shield.fill", AppColor.success, AppColor.successBackground),
@ -308,19 +304,14 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
}
@objc private func mapTapped() {
let record = viewModel.reports.first ?? WildPhotographerReportMockStore.seedReports[0]
navigationController?.pushViewController(
WildReportRiskMapViewController(record: record, riskPoints: viewModel.riskPoints),
WildReportRiskMapViewController(),
animated: true
)
}
@objc private func ruleTapped() {
navigationController?.pushViewController(WildReportRuleViewController(), animated: true)
}
@objc private func backTapped() {
navigationController?.popViewController(animated: true)
navigationController?.pushViewController(WildReportRuleViewController(ruleGroups: viewModel.ruleGroups), animated: true)
}
}
@ -455,7 +446,19 @@ private final class WildReportTrustItemView: UIView {
///
final class WildReportRuleViewController: BaseViewController {
private let scrollView = UIScrollView()
private let stack = UIStackView()
private let ruleGroups: [WildReportRuleGroup]
init(ruleGroups: [WildReportRuleGroup]) {
self.ruleGroups = ruleGroups
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "举报规则"
@ -463,24 +466,32 @@ final class WildReportRuleViewController: BaseViewController {
override func setupUI() {
view.backgroundColor = AppColor.pageBackgroundSoft
scrollView.alwaysBounceVertical = true
scrollView.showsVerticalScrollIndicator = false
stack.axis = .vertical
stack.spacing = AppSpacing.sm
view.addSubview(stack)
addSection(title: "可举报行为", items: ["疑似打野摄影师主动揽客", "未佩戴工牌开展摄影服务", "引导游客线下付款或私下交易"])
addSection(title: "材料要求", items: ["尽量提供现场图片、视频、位置和文字说明", "联系方式可选填,用于景区工作人员核实"])
addSection(title: "处理说明", items: ["举报提交后景区公安会接收线索", "处理进度可在“我的举报”中查看", "请勿提交虚假或恶意举报"])
view.addSubview(scrollView)
scrollView.addSubview(stack)
ruleGroups.forEach { group in
addSection(title: group.title, items: group.items)
}
}
override func setupConstraints() {
scrollView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
stack.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
make.edges.equalTo(scrollView.contentLayoutGuide).inset(AppSpacing.md)
make.width.equalTo(scrollView.frameLayoutGuide).offset(-AppSpacing.md * 2)
}
}
private func addSection(title: String, items: [String]) {
let card = WildReportCardView(spacing: AppSpacing.sm)
card.stack.addArrangedSubview(WildReportUI.label(title, font: .app(.title), color: AppColor.textPrimary))
if !title.isEmpty {
card.stack.addArrangedSubview(WildReportUI.label(title, font: .app(.title), color: AppColor.textPrimary))
}
items.forEach { item in
card.stack.addArrangedSubview(WildReportUI.label("· \(item)", font: .app(.body), color: AppColor.textSecondary, lines: 0))
}

View File

@ -4,6 +4,7 @@
//
import Kingfisher
import MAMapKit
import SnapKit
import UIKit
@ -928,7 +929,7 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
}
viewModel.paymentScreenshotItems.forEach { item in
let tile = WildReportDetailMediaThumbnailView(reporterItem: item)
tile.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reporterMediaTapped(_:))))
tile.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(paymentMediaTapped(_:))))
tile.snp.makeConstraints { make in
make.size.equalTo(108)
}
@ -987,7 +988,8 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
section.addArrangedSubview(makeLocationHeader())
let mapPreview = WildReportDetailMapPreviewView(
scenicName: viewModel.scenicAreaName,
detailAddress: viewModel.detailAddress
detailAddress: viewModel.detailAddress,
coordinate: viewModel.mapCoordinate
)
section.addArrangedSubview(mapPreview)
mapPreview.snp.makeConstraints { make in
@ -1198,18 +1200,63 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
}
@objc private func shareTapped() { viewModel.shareReport() }
@objc private func previewTapped() { viewModel.showMediaPreview(kind: "图片/视频") }
@objc private func paymentPreviewTapped() { viewModel.showMediaPreview(kind: "支付截图") }
@objc private func previewTapped() { presentReporterMediaPreview(selectedKey: nil) }
@objc private func paymentPreviewTapped() { presentPaymentMediaPreview(selectedKey: nil) }
@objc private func locationTapped() { viewModel.showLocationPreview() }
@objc private func reporterMediaTapped(_ recognizer: UITapGestureRecognizer) {
guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return }
viewModel.showMediaPreview(kind: tile.previewKindText)
presentReporterMediaPreview(selectedKey: tile.previewKey)
}
@objc private func paymentMediaTapped(_ recognizer: UITapGestureRecognizer) {
guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return }
presentPaymentMediaPreview(selectedKey: tile.previewKey)
}
@objc private func completionMediaTapped(_ recognizer: UITapGestureRecognizer) {
guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return }
viewModel.showMediaPreview(kind: tile.previewKindText)
presentCompletionMediaPreview(selectedKey: tile.previewKey)
}
private func presentReporterMediaPreview(selectedKey: String?) {
presentMediaPreview(entries: previewEntries(for: viewModel.reporterMediaItems), selectedKey: selectedKey, emptyMessage: "当前举报材料暂无可预览文件")
}
private func presentPaymentMediaPreview(selectedKey: String?) {
presentMediaPreview(entries: previewEntries(for: viewModel.paymentScreenshotItems), selectedKey: selectedKey, emptyMessage: "当前支付截图暂无可预览文件")
}
private func presentCompletionMediaPreview(selectedKey: String?) {
let entries = viewModel.handlerInfo?.completionMedia.compactMap { item -> (key: String, item: MediaPreviewItem)? in
guard let key = item.previewKey,
let previewItem = MediaPreviewItem(wildReportCompletionItem: item)
else { return nil }
return (key, previewItem)
} ?? []
presentMediaPreview(entries: entries, selectedKey: selectedKey, emptyMessage: "当前处理凭证暂无可预览文件")
}
private func previewEntries(for mediaItems: [WildReportReporterMediaItem]) -> [(key: String, item: MediaPreviewItem)] {
mediaItems.compactMap { mediaItem in
guard let key = mediaItem.previewKey,
let previewItem = MediaPreviewItem(wildReportReporterItem: mediaItem)
else { return nil }
return (key, previewItem)
}
}
private func presentMediaPreview(
entries: [(key: String, item: MediaPreviewItem)],
selectedKey: String?,
emptyMessage: String
) {
guard !entries.isEmpty else {
showToast(emptyMessage)
return
}
let startIndex = selectedKey.flatMap { key in entries.firstIndex { $0.key == key } } ?? 0
presentMediaPreview(items: entries.map(\.item), startIndex: startIndex)
}
@objc private func supplementTapped() {
@ -1320,15 +1367,57 @@ private final class WildReportDetailInfoRowView: UIView {
}
}
private extension MediaPreviewItem {
init?(wildReportReporterItem item: WildReportReporterMediaItem) {
guard let urlText = item.previewKey else { return nil }
switch item {
case .image:
guard let previewItem = MediaPreviewItem.remoteImage(urlText) else { return nil }
self = previewItem
case .video:
guard let previewItem = MediaPreviewItem.remoteVideo(urlText) else { return nil }
self = previewItem
}
}
init?(wildReportCompletionItem item: WildReportCompletionMediaItem) {
guard let urlText = item.previewKey else { return nil }
switch item.kind {
case .image:
guard let previewItem = MediaPreviewItem.remoteImage(urlText) else { return nil }
self = previewItem
case .video:
guard let previewItem = MediaPreviewItem.remoteVideo(urlText) else { return nil }
self = previewItem
}
}
}
private extension WildReportReporterMediaItem {
var previewKey: String? {
guard let text = fileURL?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil }
return text
}
}
private extension WildReportCompletionMediaItem {
var previewKey: String? {
let text = coverURL.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
}
///
private final class WildReportDetailMediaThumbnailView: UIView {
let previewKindText: String
let previewKey: String?
private let gradient = CAGradientLayer()
private let remoteImageView = UIImageView()
private let footerLabel = PaddingLabel(insets: UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8))
private let playView = UIImageView(image: UIImage(systemName: "play.circle.fill"))
init(reporterItem: WildReportReporterMediaItem) {
previewKey = reporterItem.previewKey
switch reporterItem {
case .image(let index, let url, let fileName, let fileTypeText):
previewKindText = fileName?.isEmpty == false ? fileName! : "\(index + 1)张举报图片"
@ -1342,11 +1431,12 @@ private final class WildReportDetailMediaThumbnailView: UIView {
}
init(completionItem: WildReportCompletionMediaItem) {
previewKey = completionItem.previewKey
switch completionItem.kind {
case .image:
previewKindText = "处理凭证图片"
super.init(frame: .zero)
configure(kind: .image(index: 0), footerText: "凭证")
configure(kind: .image(index: 0), footerText: "凭证", imageURL: completionItem.coverURL)
case .video:
previewKindText = "处理凭证视频"
super.init(frame: .zero)
@ -1500,107 +1590,41 @@ private final class WildReportDetailMediaThumbnailView: UIView {
///
private final class WildReportDetailMapPreviewView: UIView {
private let scenicName: String
private let detailAddress: String
private let detailLabel = UILabel()
private let scenicLabel = UILabel()
private let pinView = UIImageView(image: UIImage(systemName: "mappin.circle.fill"))
private let mapView: MAMapView
init(scenicName: String, detailAddress: String) {
self.scenicName = scenicName
self.detailAddress = detailAddress
init(scenicName: String, detailAddress: String, coordinate: CLLocationCoordinate2D?) {
_ = AMapBootstrap.configureIfNeeded()
mapView = MAMapView(frame: .zero)
super.init(frame: .zero)
backgroundColor = UIColor(hex: 0xE1F4D8)
backgroundColor = AppColor.pageBackgroundSoft
layer.cornerRadius = 10
clipsToBounds = true
detailLabel.text = detailAddress.replacingOccurrences(of: "附近", with: "")
detailLabel.font = .systemFont(ofSize: 13, weight: .semibold)
detailLabel.textColor = UIColor(hex: 0x64748B)
detailLabel.textAlignment = .center
scenicLabel.text = scenicName
scenicLabel.font = .systemFont(ofSize: 13, weight: .semibold)
scenicLabel.textColor = UIColor(hex: 0x418C48)
scenicLabel.textAlignment = .center
pinView.tintColor = AppColor.danger
pinView.contentMode = .scaleAspectFit
addSubview(detailLabel)
addSubview(scenicLabel)
addSubview(pinView)
detailLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview().multipliedBy(0.94)
make.centerY.equalToSuperview().multipliedBy(0.64)
make.leading.greaterThanOrEqualToSuperview().offset(12)
make.trailing.lessThanOrEqualToSuperview().inset(12)
}
scenicLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview().multipliedBy(1.56)
make.centerY.equalToSuperview().multipliedBy(1.16)
make.leading.greaterThanOrEqualToSuperview().offset(12)
make.trailing.lessThanOrEqualToSuperview().inset(12)
}
pinView.snp.makeConstraints { make in
make.centerX.equalToSuperview().multipliedBy(1.06)
make.centerY.equalToSuperview().multipliedBy(1.08)
make.size.equalTo(36)
mapView.showsCompass = false
mapView.showsScale = false
mapView.isScrollEnabled = false
mapView.isZoomEnabled = false
mapView.isRotateEnabled = false
mapView.isRotateCameraEnabled = false
mapView.zoomLevel = 16
addSubview(mapView)
mapView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
guard let coordinate else { return }
let annotation = MAPointAnnotation()
annotation.coordinate = coordinate
annotation.title = scenicName
annotation.subtitle = detailAddress
mapView.addAnnotation(annotation)
mapView.setCenter(coordinate, animated: false)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
super.draw(rect)
guard let context = UIGraphicsGetCurrentContext() else { return }
let colors = [UIColor(hex: 0xE1F4D8).cgColor, UIColor(hex: 0xBDE6C7).cgColor] as CFArray
let colorSpace = CGColorSpaceCreateDeviceRGB()
if let gradient = CGGradient(colorsSpace: colorSpace, colors: colors, locations: [0, 1]) {
context.drawLinearGradient(
gradient,
start: CGPoint(x: 0, y: 0),
end: CGPoint(x: rect.width, y: rect.height),
options: []
)
}
let dashedPath = UIBezierPath()
dashedPath.move(to: CGPoint(x: rect.width * 0.05, y: rect.height * 0.32))
dashedPath.addCurve(
to: CGPoint(x: rect.width * 0.95, y: rect.height * 0.22),
controlPoint1: CGPoint(x: rect.width * 0.28, y: rect.height * 0.62),
controlPoint2: CGPoint(x: rect.width * 0.56, y: rect.height * 0.02)
)
UIColor(hex: 0x65A6D7).withAlphaComponent(0.75).setStroke()
dashedPath.lineWidth = 1.4
dashedPath.setLineDash([5, 5], count: 2, phase: 0)
dashedPath.stroke()
let road = UIBezierPath()
road.move(to: CGPoint(x: rect.width * 0.00, y: rect.height * 0.74))
road.addCurve(
to: CGPoint(x: rect.width * 1.00, y: rect.height * 0.62),
controlPoint1: CGPoint(x: rect.width * 0.26, y: rect.height * 0.20),
controlPoint2: CGPoint(x: rect.width * 0.68, y: rect.height * 1.00)
)
UIColor.white.withAlphaComponent(0.88).setStroke()
road.lineWidth = 5
road.stroke()
UIColor(hex: 0x99C59C).setStroke()
road.lineWidth = 1
road.stroke()
let pinCenter = CGPoint(x: rect.width * 0.53, y: rect.height * 0.54)
UIColor(hex: 0xEF4444).withAlphaComponent(0.18).setFill()
context.fillEllipse(in: CGRect(x: pinCenter.x - 32, y: pinCenter.y - 32, width: 64, height: 64))
UIColor(hex: 0xEF4444).withAlphaComponent(0.22).setFill()
context.fillEllipse(in: CGRect(x: pinCenter.x - 22, y: pinCenter.y - 22, width: 44, height: 44))
}
}
/// 线

View File

@ -4,7 +4,6 @@
//
import PhotosUI
import QuickLook
import SnapKit
import UIKit
@ -20,7 +19,6 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
private let contactField = UITextField()
private let submitButton = UIButton(type: .system)
private var pickerTarget: WildReportPickerTarget = .image
private var previewURL: URL?
private weak var activeInputView: UIView?
init(
@ -213,7 +211,12 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
grid.axis = .vertical
grid.spacing = 8
let types = viewModel.reportTypes.isEmpty ? WildReportType.fallbackOptions : viewModel.reportTypes
let types = viewModel.reportTypes
if types.isEmpty, !viewModel.isLoadingReportTypes {
let empty = WildReportUI.label("暂无可选举报类型,请稍后重试", font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
card.stack.addArrangedSubview(empty)
return card
}
stride(from: 0, to: types.count, by: 3).forEach { startIndex in
let rowTypes = Array(types[startIndex ..< min(startIndex + 3, types.count)])
let row = UIStackView()
@ -223,7 +226,7 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
rowTypes.forEach { type in
let button = WildReportTypeOptionButton()
button.apply(type: type, isSelected: type == viewModel.selectedReportType)
button.apply(type: type, isSelected: viewModel.selectedReportType == type)
button.addAction(UIAction { [weak self] _ in self?.viewModel.selectReportType(type) }, for: .touchUpInside)
button.snp.makeConstraints { make in make.height.equalTo(72) }
row.addArrangedSubview(button)
@ -537,14 +540,11 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
}
private func previewAttachment(_ attachment: WildReportAttachment) {
guard let url = attachment.localFileURL else {
guard let item = MediaPreviewItem(wildReportAttachment: attachment) else {
showToast("当前附件暂无本地预览")
return
}
previewURL = url
let preview = QLPreviewController()
preview.dataSource = self
present(preview, animated: true)
presentMediaPreview(items: [item], startIndex: 0)
}
@objc private func refreshLocationTapped() { viewModel.refreshCurrentLocation() }
@ -631,16 +631,6 @@ extension WildPhotographerReportSubmitViewController: PHPickerViewControllerDele
}
}
extension WildPhotographerReportSubmitViewController: QLPreviewControllerDataSource {
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
previewURL == nil ? 0 : 1
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
previewURL! as NSURL
}
}
extension WildPhotographerReportSubmitViewController: UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
activeInputView = textView

View File

@ -21,22 +21,21 @@ final class WildReportRiskMapViewController: BaseViewController {
private let mapView = MKMapView()
private let scenicCapsule = UIView()
private let scenicNameLabel = UILabel()
private let locateButton = UIButton(type: .system)
private let legendBar = UIStackView()
private let detailContainerView = UIView()
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private var annotationMap: [String: WildReportMapMarker] = [:]
private var isShowingRiskMapLoading = false
init(
record: WildReportRecord,
riskPoints: [WildReportRiskPoint],
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
locationProvider: any LocationProviding = LocationProvider.shared,
scenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId },
scenicNameProvider: @escaping () -> String = { AppStore.shared.currentScenicName }
) {
self.viewModel = WildReportRiskMapViewModel(record: record, riskPoints: riskPoints)
self.viewModel = WildReportRiskMapViewModel()
self.api = api
self.locationProvider = locationProvider
self.scenicIdProvider = scenicIdProvider
@ -61,6 +60,13 @@ final class WildReportRiskMapViewController: BaseViewController {
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
setRiskMapLoadingVisible(false)
}
}
override func setupNavigationBar() {
title = "附近风险地图"
}
@ -82,6 +88,7 @@ final class WildReportRiskMapViewController: BaseViewController {
mapContainerView.addSubview(mapView)
configureScenicCapsule()
configureLocateButton()
configureLegendBar()
configureDetailArea()
refreshContent(animated: false)
@ -101,6 +108,10 @@ final class WildReportRiskMapViewController: BaseViewController {
make.top.leading.equalToSuperview().inset(12)
make.height.equalTo(34)
}
locateButton.snp.makeConstraints { make in
make.top.trailing.equalToSuperview().inset(12)
make.size.equalTo(44)
}
detailContainerView.snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(180)
}
@ -112,9 +123,6 @@ final class WildReportRiskMapViewController: BaseViewController {
self?.refreshContent(animated: true)
}
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
}
private func configureScenicCapsule() {
@ -146,6 +154,20 @@ final class WildReportRiskMapViewController: BaseViewController {
}
}
private func configureLocateButton() {
locateButton.backgroundColor = UIColor.white.withAlphaComponent(0.96)
locateButton.tintColor = AppColor.primary
locateButton.setImage(UIImage(systemName: "location.fill"), for: .normal)
locateButton.layer.cornerRadius = 22
locateButton.layer.shadowColor = UIColor.black.cgColor
locateButton.layer.shadowOpacity = 0.1
locateButton.layer.shadowRadius = 8
locateButton.layer.shadowOffset = CGSize(width: 0, height: 4)
locateButton.accessibilityLabel = "回到我的位置"
locateButton.addTarget(self, action: #selector(locateButtonTapped), for: .touchUpInside)
mapContainerView.addSubview(locateButton)
}
private func configureLegendBar() {
legendBar.axis = .horizontal
legendBar.alignment = .center
@ -177,14 +199,16 @@ final class WildReportRiskMapViewController: BaseViewController {
make.edges.equalToSuperview()
}
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 14, left: 18, bottom: 18, right: 18))
make.width.equalTo(scrollView.snp.width).offset(-36)
make.edges.equalToSuperview().inset(16)
make.width.equalTo(scrollView.snp.width).offset(-32)
}
}
@MainActor
private func refreshContent(animated: Bool) {
setRiskMapLoadingVisible(viewModel.isLoading)
scenicNameLabel.text = viewModel.scenicAreaName
updateLocateButtonState()
syncMapAnnotations()
mapView.setRegion(viewModel.region, animated: animated)
updateAnnotationSelection()
@ -192,6 +216,20 @@ final class WildReportRiskMapViewController: BaseViewController {
rebuildPanel()
}
@MainActor
private func setRiskMapLoadingVisible(_ isVisible: Bool) {
guard isShowingRiskMapLoading != isVisible else { return }
isShowingRiskMapLoading = isVisible
isVisible ? showLoading() : hideLoading()
}
@MainActor
private func updateLocateButtonState() {
let hasCurrentLocation = viewModel.markers.contains { $0.kind == .selfLocation }
locateButton.isEnabled = hasCurrentLocation
locateButton.alpha = hasCurrentLocation ? 1 : 0.46
}
private func syncMapAnnotations() {
let oldAnnotations = mapView.annotations.compactMap { $0 as? WildReportMapAnnotation }
mapView.removeAnnotations(oldAnnotations)
@ -234,9 +272,6 @@ final class WildReportRiskMapViewController: BaseViewController {
view.removeFromSuperview()
}
if viewModel.isLoading {
contentStack.addArrangedSubview(makeLoadingView(text: "正在加载风险点位..."))
}
if let message = viewModel.errorMessage, !viewModel.isLoading {
contentStack.addArrangedSubview(makeStateCard(icon: "exclamationmark.triangle.fill", text: message))
}
@ -248,39 +283,63 @@ final class WildReportRiskMapViewController: BaseViewController {
return
}
let card = WildReportCardView(spacing: 16, inset: 16)
card.layer.cornerRadius = 16
card.stack.addArrangedSubview(makeHeader(marker: marker, clue: clue))
if viewModel.isLoadingDetail {
card.stack.addArrangedSubview(makeInlineLoading(text: "正在加载标记详情..."))
}
if let nearest = clue.nearestAbnormalText {
card.stack.addArrangedSubview(makeHighlight(text: nearest, color: AppColor.danger))
}
addInfoRows(for: clue, to: card.stack)
addDescriptionBlocks(for: clue, to: card.stack)
addMediaRow(for: clue, to: card.stack)
addProcessingBlock(for: clue, to: card.stack)
card.stack.addArrangedSubview(makeButtonRow(for: clue))
let card = makeRadarResultCard(clue: clue)
contentStack.addArrangedSubview(card)
}
private func makeHeader(marker: WildReportMapMarker, clue: WildReportRadarActiveClue) -> UIView {
private func makeRadarResultCard(clue: WildReportRadarActiveClue) -> UIView {
let card = WildReportCardView(spacing: 14, inset: 16)
card.layer.cornerRadius = 16
card.stack.spacing = 14
card.stack.addArrangedSubview(makeRadarHeader(clue: clue))
if clue.type == .blue, let nearest = clue.nearestAbnormalText {
card.stack.addArrangedSubview(makeCompareText(nearest))
}
if clue.type == .green {
addPhotographerProfile(for: clue, to: card.stack)
}
if isReportClue(clue) {
card.stack.addArrangedSubview(makeReporterInfo(clue: clue))
addReportContents(for: clue, to: card.stack)
addRadarImages(for: clue, to: card.stack)
if clue.processingStarted, let record = clue.record {
card.stack.addArrangedSubview(makeProcessingPanel(clue: clue, record: record))
}
}
if clue.type == .green, let summary = clue.summary {
card.stack.addArrangedSubview(makeCompareText(summary))
}
if clue.type == .green, let detail = clue.detail {
card.stack.addArrangedSubview(makeDetailSection(detail))
}
if !clue.completed {
card.stack.addArrangedSubview(makeRadarActionButtons(for: clue))
}
return card
}
private func makeRadarHeader(clue: WildReportRadarActiveClue) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.alignment = .top
row.spacing = 12
let iconWrap = UIView()
iconWrap.backgroundColor = marker.kind.color.withAlphaComponent(0.14)
iconWrap.layer.cornerRadius = 22
let icon = UIImageView(image: UIImage(systemName: iconName(for: marker.kind)))
icon.tintColor = marker.kind.color
iconWrap.backgroundColor = headerIconColor(for: clue)
iconWrap.layer.cornerRadius = 12
let icon = UIImageView(image: UIImage(systemName: "location.fill"))
icon.tintColor = .white
icon.contentMode = .scaleAspectFit
iconWrap.addSubview(icon)
icon.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(22)
make.size.equalTo(20)
}
iconWrap.snp.makeConstraints { make in
make.size.equalTo(44)
@ -288,14 +347,34 @@ final class WildReportRiskMapViewController: BaseViewController {
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = 4
let title = WildReportUI.label(clue.displayTitle, font: .systemFont(ofSize: 17, weight: .bold), color: AppColor.textPrimary, lines: 2)
let metaText = [clue.distanceText ?? clue.distance, clue.updatedAt].compactMap { $0 }.joined(separator: " · ")
let meta = WildReportUI.label(metaText.isEmpty ? marker.title : metaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary, lines: 1)
textStack.addArrangedSubview(title)
textStack.addArrangedSubview(meta)
textStack.spacing = 6
if isReportClue(clue) {
let title = WildReportUI.label("线索 ID\(clue.id)", font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
textStack.addArrangedSubview(title)
let metaRow = UIStackView()
metaRow.axis = .horizontal
metaRow.spacing = 12
if let distance = clue.distanceText ?? clue.distance {
metaRow.addArrangedSubview(WildReportUI.label("距离:距离您\(distance)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
}
if let updatedAt = clue.updatedAt {
metaRow.addArrangedSubview(WildReportUI.label("时间:\(updatedAt)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
}
if !metaRow.arrangedSubviews.isEmpty {
textStack.addArrangedSubview(metaRow)
}
} else {
let title = WildReportUI.label(clue.displayTitle, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
textStack.addArrangedSubview(title)
if clue.type == .green {
let metaText = [clue.distance.map { "距蓝点 \($0)" }, clue.direction, clue.updatedAt].compactMap { $0 }.joined(separator: " ")
if !metaText.isEmpty {
textStack.addArrangedSubview(WildReportUI.label(metaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
}
}
}
let badge = WildReportRiskStatusBadge(text: clue.statusText, color: marker.kind.color)
let badge = WildReportRiskStatusBadge(text: clue.statusText, color: statusColor(for: clue))
row.addArrangedSubview(iconWrap)
row.addArrangedSubview(textStack)
row.addArrangedSubview(badge)
@ -304,6 +383,341 @@ final class WildReportRiskMapViewController: BaseViewController {
return row
}
private func addPhotographerProfile(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 12
let avatarWrap = UIView()
avatarWrap.backgroundColor = AppColor.success.withAlphaComponent(0.12)
avatarWrap.layer.cornerRadius = 28
let avatar = UIImageView(image: UIImage(systemName: clue.avatar ?? "person.crop.circle.fill"))
avatar.tintColor = AppColor.success
avatar.contentMode = .scaleAspectFit
avatarWrap.addSubview(avatar)
avatar.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(42)
}
avatarWrap.snp.makeConstraints { make in
make.size.equalTo(56)
}
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = 4
if let name = clue.name {
textStack.addArrangedSubview(WildReportUI.label(name, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary))
}
if let storeName = clue.storeName {
textStack.addArrangedSubview(WildReportUI.label(storeName, font: .systemFont(ofSize: 13, weight: .semibold), color: AppColor.success))
}
row.addArrangedSubview(avatarWrap)
row.addArrangedSubview(textStack)
stack.addArrangedSubview(row)
}
private func makeReporterInfo(clue: WildReportRadarActiveClue) -> UIView {
let box = UIView()
box.backgroundColor = UIColor(hex: 0xF9FAFB)
box.layer.cornerRadius = 10
let inner = UIStackView()
inner.axis = .vertical
inner.spacing = 10
box.addSubview(inner)
inner.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(12)
}
inner.addArrangedSubview(makeInfoLabelRow(label: "举报人", value: clue.reporterName ?? "-"))
inner.addArrangedSubview(makePhoneRow(
label: "举报人手机号",
phone: clue.reporterPhone,
displayPhone: clue.maskedReporterPhone ?? clue.reporterPhone
))
return box
}
private func addReportContents(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
guard !clue.reportContents.isEmpty else { return }
let section = UIStackView()
section.axis = .vertical
section.spacing = 10
for item in clue.reportContents {
let itemStack = UIStackView()
itemStack.axis = .vertical
itemStack.spacing = 4
if !item.time.isEmpty {
itemStack.addArrangedSubview(WildReportUI.label(item.time, font: .systemFont(ofSize: 11, weight: .semibold), color: AppColor.textSecondary))
}
itemStack.addArrangedSubview(makeLineSpacedLabel(item.content, font: .systemFont(ofSize: 13), color: AppColor.textPrimary))
section.addArrangedSubview(itemStack)
}
stack.addArrangedSubview(section)
}
private func addRadarImages(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
guard !clue.images.isEmpty else { return }
let section = UIStackView()
section.axis = .vertical
section.spacing = 10
section.addArrangedSubview(WildReportUI.label("现场图片/视频", font: .systemFont(ofSize: 15, weight: .bold), color: AppColor.textPrimary))
let scroll = UIScrollView()
scroll.showsHorizontalScrollIndicator = false
let row = UIStackView()
row.axis = .horizontal
row.spacing = 10
scroll.addSubview(row)
row.snp.makeConstraints { make in
make.edges.equalToSuperview()
make.height.equalTo(108)
}
for url in clue.images {
let tile = WildReportRiskImageTile(url: url, size: 108)
tile.onTap = { [weak self] in self?.presentImagePreview(url: url) }
row.addArrangedSubview(tile)
}
section.addArrangedSubview(scroll)
scroll.snp.makeConstraints { make in
make.height.equalTo(108)
}
stack.addArrangedSubview(section)
}
private func makeProcessingPanel(clue: WildReportRadarActiveClue, record: WildReportRadarProcessRecord) -> UIView {
let box = UIView()
box.backgroundColor = UIColor(hex: 0xFFFBEB)
box.layer.cornerRadius = 10
let inner = UIStackView()
inner.axis = .vertical
inner.spacing = 10
box.addSubview(inner)
inner.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(12)
}
inner.addArrangedSubview(makeInfoLabelRow(label: "处理人", value: record.handlerName))
inner.addArrangedSubview(makeInfoLabelRow(
label: "状态",
value: clue.completed ? "已处理" : "正在处理中",
valueColor: clue.completed ? AppColor.success : UIColor(hex: 0xF59E0B)
))
inner.addArrangedSubview(makePhoneRow(label: "联系电话", phone: record.phone, displayPhone: record.maskedPhone))
let progressStack = UIStackView()
progressStack.axis = .vertical
progressStack.spacing = 10
progressStack.addArrangedSubview(WildReportUI.label("处理进度", font: .systemFont(ofSize: 14, weight: .bold), color: AppColor.textPrimary))
for item in clue.processingTimeline {
progressStack.addArrangedSubview(makeTimelineRow(item))
}
inner.addArrangedSubview(progressStack)
return box
}
private func makeTimelineRow(_ item: WildReportRadarProcessingTimelineItem) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .top
row.spacing = 10
let dot = UIView()
dot.backgroundColor = timelineDotColor(item.status)
dot.layer.cornerRadius = 5
dot.snp.makeConstraints { make in
make.size.equalTo(10)
}
let dotContainer = UIView()
dotContainer.addSubview(dot)
dot.snp.makeConstraints { make in
make.top.equalToSuperview().offset(4)
make.centerX.equalToSuperview()
}
dotContainer.snp.makeConstraints { make in
make.width.equalTo(10)
}
let textStack = UIStackView()
textStack.axis = .vertical
textStack.spacing = 2
textStack.addArrangedSubview(WildReportUI.label(item.title, font: .systemFont(ofSize: 13, weight: .semibold), color: AppColor.textPrimary))
textStack.addArrangedSubview(WildReportUI.label(item.time, font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
row.addArrangedSubview(dotContainer)
row.addArrangedSubview(textStack)
return row
}
private func makeRadarActionButtons(for clue: WildReportRadarActiveClue) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.spacing = 10
row.distribution = .fillEqually
let navButton = makeRadarButton(title: clue.actionText, imageName: "map.fill", color: AppColor.primary)
navButton.addTarget(self, action: #selector(navigateTapped), for: .touchUpInside)
row.addArrangedSubview(navButton)
if isReportClue(clue), !clue.completed {
let shareButton = makeRadarButton(title: "分享", imageName: "square.and.arrow.up", color: AppColor.success)
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
row.addArrangedSubview(shareButton)
}
[navButton].forEach { button in
button.snp.makeConstraints { make in make.height.equalTo(44) }
}
return row
}
private func makeRadarButton(title: String, imageName: String, color: UIColor) -> UIButton {
let button = UIButton(type: .system)
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
button.setTitle(title, for: .normal)
button.setImage(UIImage(systemName: imageName), for: .normal)
button.tintColor = .white
button.setTitleColor(.white, for: .normal)
button.backgroundColor = color
button.layer.cornerRadius = 12
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -3, bottom: 0, right: 3)
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
button.snp.makeConstraints { make in make.height.equalTo(44) }
return button
}
private func makeCompareText(_ text: String) -> UIView {
let label = makeLineSpacedLabel(text, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
label.backgroundColor = UIColor(hex: 0xF3F4F6)
label.layer.cornerRadius = 10
label.clipsToBounds = true
label.textInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
return label
}
private func makeDetailSection(_ detail: String) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .top
row.spacing = 8
let icon = UIImageView(image: UIImage(systemName: "doc.text.fill"))
icon.tintColor = AppColor.primary
icon.contentMode = .scaleAspectFit
icon.snp.makeConstraints { make in
make.size.equalTo(16)
}
row.addArrangedSubview(icon)
row.addArrangedSubview(makeLineSpacedLabel(detail, font: .systemFont(ofSize: 13), color: AppColor.textPrimary))
return row
}
private func makeInfoLabelRow(label: String, value: String, valueColor: UIColor = AppColor.textPrimary) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 12
let titleLabel = WildReportUI.label(label, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
let valueLabel = WildReportUI.label(value, font: .systemFont(ofSize: 14, weight: .semibold), color: valueColor, lines: 0)
valueLabel.textAlignment = .right
row.addArrangedSubview(titleLabel)
row.addArrangedSubview(valueLabel)
titleLabel.setContentHuggingPriority(.required, for: .horizontal)
valueLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
return row
}
private func makePhoneRow(label: String, phone: String?, displayPhone: String?) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.alignment = .center
row.spacing = 12
let titleLabel = WildReportUI.label(label, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
row.addArrangedSubview(titleLabel)
row.addArrangedSubview(UIView())
guard let displayPhone, !displayPhone.isEmpty else {
row.addArrangedSubview(WildReportUI.label("-", font: .systemFont(ofSize: 14, weight: .semibold), color: AppColor.textPrimary))
return row
}
let button = UIButton(type: .system)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
button.setTitle(displayPhone, for: .normal)
button.setImage(UIImage(systemName: "phone.fill"), for: .normal)
button.tintColor = AppColor.primary
button.setTitleColor(AppColor.primary, for: .normal)
button.semanticContentAttribute = .forceRightToLeft
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: -4)
if let phone {
button.addAction(UIAction { [weak self] _ in
self?.callPhone(phone)
}, for: .touchUpInside)
}
row.addArrangedSubview(button)
button.setContentHuggingPriority(.required, for: .horizontal)
return row
}
private func makeLineSpacedLabel(_ text: String, font: UIFont, color: UIColor) -> WildReportRiskInsetLabel {
let label = WildReportRiskInsetLabel()
label.font = font
label.textColor = color
label.numberOfLines = 0
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
label.attributedText = NSAttributedString(
string: text,
attributes: [.paragraphStyle: paragraph, .font: font, .foregroundColor: color]
)
return label
}
private func isReportClue(_ clue: WildReportRadarActiveClue) -> Bool {
clue.type == .red || clue.type == .yellow
}
private func headerIconColor(for clue: WildReportRadarActiveClue) -> UIColor {
if isReportClue(clue), clue.processingStarted, !clue.completed {
return UIColor(hex: 0xF59E0B)
}
switch clue.type {
case .blue: return AppColor.primary
case .green: return AppColor.success
case .red: return AppColor.danger
case .yellow: return UIColor(hex: 0xF59E0B)
}
}
private func statusColor(for clue: WildReportRadarActiveClue) -> UIColor {
if clue.completed { return AppColor.success }
if clue.processingStarted, !clue.completed { return UIColor(hex: 0xF59E0B) }
switch clue.type {
case .blue: return AppColor.primary
case .green: return AppColor.success
case .red: return AppColor.danger
case .yellow: return UIColor(hex: 0xF59E0B)
}
}
private func timelineDotColor(_ status: String) -> UIColor {
switch status {
case "done": return AppColor.success
case "current": return UIColor(hex: 0xF59E0B)
default: return UIColor(hex: 0xD1D5DB)
}
}
private func callPhone(_ phone: String) {
guard phone.contains("*") == false,
let url = URL(string: "tel://\(phone)")
else { return }
UIApplication.shared.open(url)
}
private func addInfoRows(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
if let name = clue.name {
stack.addArrangedSubview(WildReportInfoRowView(title: clue.type == .green ? "摄影师" : "点位", value: name))
@ -401,15 +815,6 @@ final class WildReportRiskMapViewController: BaseViewController {
return row
}
private func makeLoadingView(text: String) -> UIView {
let card = WildReportCardView(spacing: 12, inset: 16)
card.stack.alignment = .center
loadingIndicator.startAnimating()
card.stack.addArrangedSubview(loadingIndicator)
card.stack.addArrangedSubview(WildReportUI.label(text, font: .systemFont(ofSize: 14), color: AppColor.textSecondary))
return card
}
private func makeInlineLoading(text: String) -> UIView {
let row = UIStackView()
row.axis = .horizontal
@ -464,7 +869,7 @@ final class WildReportRiskMapViewController: BaseViewController {
}
private func presentImagePreview(url: String) {
present(WildReportRiskImagePreviewController(url: url), animated: true)
presentImagePreview(imageURLs: [url], startIndex: 0)
}
@objc private func navigateTapped() {
@ -493,6 +898,10 @@ final class WildReportRiskMapViewController: BaseViewController {
@objc private func shareTapped() {
viewModel.shareSelectedClue()
}
@objc private func locateButtonTapped() {
viewModel.focusCurrentLocation()
}
}
extension WildReportRiskMapViewController: MKMapViewDelegate {
@ -532,16 +941,17 @@ final class WildReportMapAnnotation: NSObject, MKAnnotation {
///
private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
private let pulseOuter = UIView()
private let pulseInner = UIView()
private let titleBadge = WildReportMapMarkerTitleBadge()
private let markerContainer = UIView()
private let pulseRings = (0..<3).map { _ in UIView() }
private let selectedRing = UIView()
private let dot = UIView()
private let label = UILabel()
private var showsTitleBadge = false
override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
frame = CGRect(x: 0, y: 0, width: 56, height: 56)
centerOffset = CGPoint(x: 0, y: -12)
frame = CGRect(x: 0, y: 0, width: 72, height: 96)
centerOffset = .zero
canShowCallout = false
setupUI()
}
@ -553,41 +963,51 @@ private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
func apply(marker: WildReportMapMarker, selected: Bool) {
let color = marker.kind.color
pulseOuter.layer.borderColor = color.withAlphaComponent(0.18).cgColor
pulseInner.layer.borderColor = color.withAlphaComponent(0.28).cgColor
selectedRing.layer.borderColor = color.cgColor
updateTitleBadgeVisibility(marker.kind == .selfLocation)
titleBadge.text = marker.title
pulseRings.forEach { ring in
ring.layer.borderColor = color.withAlphaComponent(0.32).cgColor
}
selectedRing.layer.borderColor = color.withAlphaComponent(0.45).cgColor
selectedRing.isHidden = !selected
dot.backgroundColor = color
label.isHidden = marker.kind != .selfLocation
label.text = ""
startPulseIfNeeded()
}
private func setupUI() {
backgroundColor = .clear
[pulseOuter, pulseInner, selectedRing, dot, label].forEach(addSubview)
[pulseOuter, pulseInner, selectedRing].forEach { view in
titleBadge.isHidden = true
addSubview(titleBadge)
addSubview(markerContainer)
pulseRings.forEach(markerContainer.addSubview)
markerContainer.addSubview(selectedRing)
markerContainer.addSubview(dot)
pulseRings.forEach { view in
view.backgroundColor = .clear
view.layer.borderWidth = 2
view.layer.cornerRadius = 10
view.isUserInteractionEnabled = false
}
pulseOuter.layer.cornerRadius = 20
pulseInner.layer.cornerRadius = 14
selectedRing.layer.cornerRadius = 14
selectedRing.layer.borderWidth = 2
dot.layer.cornerRadius = 7
dot.layer.borderWidth = 2
dot.layer.borderColor = UIColor.white.cgColor
label.font = .systemFont(ofSize: 10, weight: .bold)
label.textColor = .white
label.textAlignment = .center
pulseOuter.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(40)
titleBadge.snp.makeConstraints { make in
make.top.centerX.equalToSuperview()
make.height.equalTo(24)
}
pulseInner.snp.makeConstraints { make in
markerContainer.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(28)
make.size.equalTo(56)
}
pulseRings.forEach { ring in
ring.snp.makeConstraints { make in
make.center.equalToSuperview()
make.size.equalTo(20)
}
}
selectedRing.snp.makeConstraints { make in
make.center.equalToSuperview()
@ -597,21 +1017,81 @@ private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
make.center.equalToSuperview()
make.size.equalTo(14)
}
label.snp.makeConstraints { make in
make.center.equalTo(dot)
make.size.equalTo(14)
}
startPulse()
}
private func startPulse() {
let animation = CABasicAnimation(keyPath: "opacity")
animation.fromValue = 0.85
animation.toValue = 0.2
animation.duration = 1.2
animation.autoreverses = true
animation.repeatCount = .infinity
pulseOuter.layer.add(animation, forKey: "opacityPulse")
private func updateTitleBadgeVisibility(_ isVisible: Bool) {
guard showsTitleBadge != isVisible else { return }
showsTitleBadge = isVisible
titleBadge.isHidden = !isVisible
markerContainer.snp.remakeConstraints { make in
if isVisible {
make.top.equalTo(titleBadge.snp.bottom).offset(4)
make.centerX.equalToSuperview()
} else {
make.center.equalToSuperview()
}
make.size.equalTo(56)
}
}
private func startPulseIfNeeded() {
guard pulseRings.first?.layer.animation(forKey: "riskMapPulseOpacity") == nil else { return }
let endSizes: [CGFloat] = [28, 40, 52]
for (index, ring) in pulseRings.enumerated() {
ring.layer.opacity = 0.72
let scale = CABasicAnimation(keyPath: "transform.scale")
scale.fromValue = 1
scale.toValue = endSizes[index] / 20
let opacity = CABasicAnimation(keyPath: "opacity")
opacity.fromValue = 0.72
opacity.toValue = 0
let animation = CAAnimationGroup()
animation.animations = [scale, opacity]
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
animation.duration = 2
animation.repeatCount = .infinity
animation.isRemovedOnCompletion = false
ring.layer.add(animation, forKey: "riskMapPulseOpacity")
}
}
}
///
private final class WildReportMapMarkerTitleBadge: UIView {
var text: String? {
get { label.text }
set { label.text = newValue }
}
private let label = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupUI() {
backgroundColor = .white
layer.cornerRadius = 12
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.08
layer.shadowRadius = 4
layer.shadowOffset = CGSize(width: 0, height: 2)
label.font = .systemFont(ofSize: 11, weight: .semibold)
label.textColor = AppColor.textPrimary
addSubview(label)
label.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8))
}
}
}
@ -675,12 +1155,31 @@ private final class WildReportRiskStatusBadge: UILabel {
}
}
///
private final class WildReportRiskInsetLabel: UILabel {
var textInsets = UIEdgeInsets.zero {
didSet { invalidateIntrinsicContentSize() }
}
override func drawText(in rect: CGRect) {
super.drawText(in: rect.inset(by: textInsets))
}
override var intrinsicContentSize: CGSize {
let size = super.intrinsicContentSize
return CGSize(
width: size.width + textInsets.left + textInsets.right,
height: size.height + textInsets.top + textInsets.bottom
)
}
}
///
private final class WildReportRiskImageTile: UIView {
private let imageView = UIImageView()
var onTap: (() -> Void)?
init(url: String) {
init(url: String, size: CGFloat = 72) {
super.init(frame: .zero)
backgroundColor = AppColor.pageBackgroundSoft
layer.cornerRadius = 10
@ -698,7 +1197,7 @@ private final class WildReportRiskImageTile: UIView {
}
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped)))
snp.makeConstraints { make in
make.size.equalTo(72)
make.size.equalTo(size)
}
}
@ -711,46 +1210,3 @@ private final class WildReportRiskImageTile: UIView {
onTap?()
}
}
///
private final class WildReportRiskImagePreviewController: UIViewController {
private let url: String
private let imageView = UIImageView()
init(url: String) {
self.url = url
super.init(nibName: nil, bundle: nil)
modalPresentationStyle = .fullScreen
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
imageView.contentMode = .scaleAspectFit
view.addSubview(imageView)
imageView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
if let imageURL = URL(string: url) {
imageView.kf.setImage(with: imageURL)
}
let closeButton = UIButton(type: .system)
closeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
closeButton.tintColor = .white
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
view.addSubview(closeButton)
closeButton.snp.makeConstraints { make in
make.top.trailing.equalTo(view.safeAreaLayoutGuide).inset(18)
make.size.equalTo(36)
}
}
@objc private func closeTapped() {
dismiss(animated: true)
}
}

View File

@ -4,7 +4,6 @@
//
import PhotosUI
import QuickLook
import SnapKit
import UIKit
@ -23,7 +22,6 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
private let submitButton = UIButton(type: .system)
private var pickerTarget: WildReportPickerTarget = .image
private var previewURL: URL?
private weak var activeInputView: UIView?
init(
@ -322,14 +320,11 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
}
private func previewAttachment(_ attachment: WildReportAttachment) {
guard let url = attachment.localFileURL else {
guard let item = MediaPreviewItem(wildReportAttachment: attachment) else {
showToast("当前附件暂无本地预览")
return
}
previewURL = url
let preview = QLPreviewController()
preview.dataSource = self
present(preview, animated: true)
presentMediaPreview(items: [item], startIndex: 0)
}
@objc private func submitTapped() {
@ -420,16 +415,6 @@ extension WildReportSupplementEvidenceViewController: PHPickerViewControllerDele
}
}
extension WildReportSupplementEvidenceViewController: QLPreviewControllerDataSource {
func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
previewURL == nil ? 0 : 1
}
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem {
previewURL! as NSURL
}
}
extension WildReportSupplementEvidenceViewController: UITextViewDelegate {
func textViewDidBeginEditing(_ textView: UITextView) {
activeInputView = textView

View File

@ -0,0 +1,126 @@
//
// MaterialManagementAPITests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// API
@MainActor
final class MaterialManagementAPITests: XCTestCase {
func testListBuildsAndroidQueryWithMaterialType() async throws {
let session = MockURLSession(responses: [envelopeJSON(#"{"total":0,"list":[],"order":null}"#)])
let api = MaterialManagementAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.list(status: 4, keyword: "雪山", page: 2, pageSize: 10)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/app/media-album/list")
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(query?.first { $0.name == "status" }?.value, "4")
XCTAssertEqual(query?.first { $0.name == "keyword" }?.value, "雪山")
XCTAssertEqual(query?.first { $0.name == "type" }?.value, "1")
XCTAssertEqual(query?.first { $0.name == "page" }?.value, "2")
XCTAssertEqual(query?.first { $0.name == "page_size" }?.value, "10")
}
func testListDecodesNullableOrderAmounts() async throws {
let session = MockURLSession(responses: [envelopeJSON(#"{"total":0,"list":[],"order":{"avg_change":-1025,"avg_order_amount":null,"refund_total":null,"total_num":0}}"#)])
let api = MaterialManagementAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.list(status: 4, keyword: nil, page: 1, pageSize: 10)
XCTAssertEqual(response.order?.avgOrderAmount, "0")
XCTAssertEqual(response.order?.refundTotal, "0")
XCTAssertEqual(response.order?.avgChange, -1025)
XCTAssertEqual(response.order?.totalNum, 0)
}
func testDetailOperationDeleteAndTagUseAndroidPaths() async throws {
let detail = envelopeJSON(#"{"id":8,"status":1,"listing_status":1}"#)
let empty = envelopeJSON("{}")
let tag = envelopeJSON("23")
let session = MockURLSession(responses: [detail, empty, empty, tag])
let api = MaterialManagementAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.detail(id: 8)
try await api.setListingStatus(id: 8, listingStatus: 0)
try await api.delete(id: 8)
let tagId = try await api.addTag(name: "snow-tag")
XCTAssertEqual(tagId, 23)
XCTAssertEqual(session.requests[0].url?.path, "/api/app/media-album/detail")
XCTAssertEqual(session.requests[1].url?.path, "/api/app/media-album/operation")
XCTAssertEqual(session.requests[2].url?.path, "/api/app/media-album/delete")
XCTAssertEqual(session.requests[3].url?.path, "/api/app/media-album/add-tag")
XCTAssertTrue(session.requests.prefix(3).allSatisfy { request in
URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
.queryItems?
.first { $0.name == "type" }?
.value == "1"
})
}
func testUploadAndEditEncodeAndroidBodyKeys() async throws {
let session = MockURLSession(responses: [envelopeJSON("{}"), envelopeJSON("{}")])
let api = MaterialManagementAPI(client: APIClient(environment: .testing, session: session))
let request = MaterialUploadRequest(
name: "素材A",
mediaType: .image,
mediaList: [
MaterialUploadMediaItem(
originalName: "a.jpg",
ossUrl: "https://cdn/a.jpg",
size: 1024,
fileWidthSize: MaterialUploadFileSize(width: 1920, height: 1080)
),
],
coverUrl: "https://cdn/c.jpg",
coverSize: "512",
scenicSpotId: 9,
materialTag: "1,2"
)
try await api.upload(request)
try await api.edit(MaterialUploadRequest(
id: 7,
name: "素材B",
mediaType: .video,
mediaList: request.mediaList,
coverUrl: request.coverUrl,
coverSize: request.coverSize,
scenicSpotId: request.scenicSpotId,
materialTag: "3"
))
let uploadBody = try bodyJSON(session.requests[0])
XCTAssertEqual(session.requests[0].url?.path, "/api/app/media-album/upload")
XCTAssertEqual(uploadBody["name"] as? String, "素材A")
XCTAssertEqual(uploadBody["type"] as? String, "1")
XCTAssertEqual(uploadBody["media_type"] as? String, "1")
XCTAssertNil(uploadBody["project_id"])
XCTAssertEqual(uploadBody["material_tag"] as? String, "1,2")
let media = (uploadBody["media_list"] as? [[String: Any]])?.first
XCTAssertEqual(media?["original_name"] as? String, "a.jpg")
XCTAssertEqual(media?["oss_url"] as? String, "https://cdn/a.jpg")
let editBody = try bodyJSON(session.requests[1])
XCTAssertEqual(session.requests[1].url?.path, "/api/app/media-album/edit")
XCTAssertEqual(editBody["id"] as? Int, 7)
XCTAssertEqual(editBody["type"] as? String, "1")
XCTAssertEqual(editBody["media_type"] as? String, "2")
XCTAssertNil(editBody["project_id"])
}
private func envelopeJSON(_ dataJSON: String) -> Data {
"""
{"code":100000,"msg":"success","data":\(dataJSON)}
""".data(using: .utf8)!
}
private func bodyJSON(_ request: URLRequest) throws -> [String: Any] {
try XCTUnwrap(JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any])
}
}

View File

@ -0,0 +1,258 @@
//
// MaterialManagementViewModelTests.swift
// suixinkanTests
//
import Foundation
import XCTest
@testable import suixinkan
/// ViewModel
@MainActor
final class MaterialManagementViewModelTests: XCTestCase {
func testListLoadsMoreSearchesAndMapsFilterStatus() async {
let api = MockMaterialAPI()
api.listResponses = [
MaterialListResponse(total: 12, list: (0 ..< 10).map { makeItem(id: $0) }, order: MaterialOrderInfo(totalNum: 1386, avgOrderAmount: "12", refundTotal: "3")),
MaterialListResponse(total: 12, list: [makeItem(id: 10), makeItem(id: 11)]),
MaterialListResponse(total: 1, list: [makeItem(id: 99)]),
]
let viewModel = MaterialListViewModel()
await viewModel.loadInitial(api: api)
await viewModel.loadMoreIfNeeded(lastVisibleIndex: 8, api: api)
await viewModel.selectFilter(.pending, api: api)
XCTAssertEqual(api.listCalls.map(\.status), [4, 4, 0])
XCTAssertEqual(api.listCalls.map(\.page), [1, 2, 1])
XCTAssertEqual(viewModel.items.map(\.id), [99])
}
func testRejectedMaterialCannotToggleListing() async {
let api = MockMaterialAPI()
api.listResponses = [MaterialListResponse(total: 1, list: [makeItem(id: 1, status: 0, listingStatus: 0)])]
let viewModel = MaterialListViewModel()
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
await viewModel.loadInitial(api: api)
await viewModel.toggleListing(materialId: 1, checked: true, api: api)
XCTAssertEqual(messages.last, "审核通过才可以操作上下架")
XCTAssertTrue(api.operationCalls.isEmpty)
}
func testApprovedMaterialToggleUpdatesLocalItem() async {
let api = MockMaterialAPI()
api.listResponses = [MaterialListResponse(total: 1, list: [makeItem(id: 2, status: 1, listingStatus: 0)])]
let viewModel = MaterialListViewModel()
await viewModel.loadInitial(api: api)
await viewModel.toggleListing(materialId: 2, checked: true, api: api)
XCTAssertEqual(api.operationCalls.first?.id, 2)
XCTAssertEqual(api.operationCalls.first?.listingStatus, 1)
XCTAssertEqual(viewModel.items.first?.listingStatus, 1)
}
func testDetailDeleteCallsCallback() async {
let api = MockMaterialAPI()
let viewModel = MaterialDetailViewModel(materialId: 7)
var deletedId: Int?
viewModel.onDeleted = { deletedId = $0 }
await viewModel.delete(api: api)
XCTAssertEqual(api.deletedIds, [7])
XCTAssertEqual(deletedId, 7)
}
func testUploadValidationMessages() async {
let api = MockMaterialAPI()
let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 })
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
await viewModel.submit(api: api)
viewModel.updateMaterialName("素材")
await viewModel.submit(api: api)
await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: MockMaterialUploader(urls: ["https://cdn/a.jpg"]))
await viewModel.submit(api: api)
await viewModel.setCoverImage(makeUploadItem(fileName: "c.jpg"), uploader: MockMaterialUploader(urls: ["https://cdn/c.jpg"]))
await viewModel.submit(api: api)
XCTAssertEqual(messages.prefix(4), [
"请输入素材名称",
"请至少上传一个素材",
"请上传封面图片",
"请选择关联打卡点",
])
XCTAssertNil(api.lastUploadRequest)
}
func testUploadSuccessBuildsMaterialRequestWithTags() async {
let api = MockMaterialAPI()
api.spotResponse = ScenicSpotListResponse(list: [ScenicSpotItem(id: 9, name: "打卡点")], total: 1)
let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 })
let uploader = MockMaterialUploader(urls: ["https://cdn/a.jpg", "https://cdn/c.jpg"])
var didSucceed = false
viewModel.onSubmitSuccess = { didSucceed = true }
await viewModel.loadInitial(api: api)
viewModel.updateMaterialName("素材")
viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点"))
viewModel.updateTagInput("snow-tag")
await viewModel.addTag(api: api)
await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader)
await viewModel.setCoverImage(makeUploadItem(fileName: "c.jpg"), uploader: uploader)
await viewModel.submit(api: api)
XCTAssertTrue(didSucceed)
XCTAssertEqual(api.lastUploadRequest?.name, "素材")
XCTAssertEqual(api.lastUploadRequest?.type, "1")
XCTAssertEqual(api.lastUploadRequest?.mediaType, "1")
XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.ossUrl, "https://cdn/a.jpg")
XCTAssertEqual(api.lastUploadRequest?.coverUrl, "https://cdn/c.jpg")
XCTAssertEqual(api.lastUploadRequest?.scenicSpotId, 9)
XCTAssertEqual(api.lastUploadRequest?.materialTag, "42")
XCTAssertNil(api.lastUploadRequest?.id)
}
func testEditLoadsDetailAndBuildsEditRequest() async {
let api = MockMaterialAPI()
api.spotResponse = ScenicSpotListResponse(list: [ScenicSpotItem(id: 9, name: "打卡点")], total: 1)
api.detailResponse = try! decodeDetail("""
{
"id": 11,
"name": "",
"status": 1,
"listing_status": 1,
"cover_url": "https://cdn/cover.jpg",
"cover_size": 22,
"scenic_spot_id": 9,
"material_tag": [{"id": 3, "name": "cloudy"}],
"media_list": [{
"id": 5,
"original_name": "old.jpg",
"oss_url": "https://cdn/old.jpg",
"type": 1,
"size": 10,
"width": 100,
"height": 80
}]
}
""")
let viewModel = MaterialFormViewModel(mode: .edit(id: 11), currentScenicIdProvider: { 3 })
await viewModel.loadInitial(api: api)
await viewModel.submit(api: api)
XCTAssertEqual(viewModel.materialName, "")
XCTAssertEqual(viewModel.selectedScenicSpot?.id, 9)
XCTAssertEqual(viewModel.uploadedMediaList.first?.ossUrl, "https://cdn/old.jpg")
XCTAssertEqual(api.lastEditRequest?.id, 11)
XCTAssertEqual(api.lastEditRequest?.type, "1")
XCTAssertEqual(api.lastEditRequest?.materialTag, "3")
}
}
/// API mock
/// API mock
@MainActor
private final class MockMaterialAPI: MaterialManagementServing {
var listResponses: [MaterialListResponse] = []
var listCalls: [(status: Int, keyword: String?, page: Int, pageSize: Int)] = []
var operationCalls: [(id: Int, listingStatus: Int)] = []
var deletedIds: [Int] = []
var tagId = 42
var lastUploadRequest: MaterialUploadRequest?
var lastEditRequest: MaterialUploadRequest?
var spotResponse = ScenicSpotListResponse(list: [], total: 0)
var detailResponse = try! decodeDetail(#"{"id":1,"status":1,"listing_status":1}"#)
func list(status: Int, keyword: String?, page: Int, pageSize: Int) async throws -> MaterialListResponse {
listCalls.append((status, keyword, page, pageSize))
guard !listResponses.isEmpty else { return MaterialListResponse() }
return listResponses.removeFirst()
}
func detail(id: Int) async throws -> MaterialDetail {
detailResponse
}
func setListingStatus(id: Int, listingStatus: Int) async throws {
operationCalls.append((id, listingStatus))
}
func delete(id: Int) async throws {
deletedIds.append(id)
}
func addTag(name: String) async throws -> Int {
tagId
}
func upload(_ request: MaterialUploadRequest) async throws {
lastUploadRequest = request
}
func edit(_ request: MaterialUploadRequest) async throws {
lastEditRequest = request
}
func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse {
spotResponse
}
}
/// OSS mock
/// OSS mock
@MainActor
private final class MockMaterialUploader: MaterialOSSUploading {
private var urls: [String]
init(urls: [String]) {
self.urls = urls
}
func uploadMaterialFile(
data: Data,
fileName: String,
fileType: Int,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String {
onProgress(100)
return urls.isEmpty ? "https://cdn/default.jpg" : urls.removeFirst()
}
}
private func makeItem(id: Int, status: Int = 1, listingStatus: Int = 0) -> MaterialItem {
MaterialItem(
id: id,
name: "素材\(id)",
coverUrl: "",
createdAt: "",
status: status,
downloadCount: 0,
likesCount: 0,
collectCount: 0,
shareCount: 0,
listingStatus: listingStatus
)
}
private func makeUploadItem(fileName: String, ossUrl: String = "") -> MaterialUploadedMediaItem {
MaterialUploadedMediaItem(
data: Data(repeating: 1, count: 10),
fileName: fileName,
size: 10,
width: 1,
height: 1,
ossUrl: ossUrl
)
}
private func decodeDetail(_ json: String) throws -> MaterialDetail {
try JSONDecoder().decode(MaterialDetail.self, from: Data(json.utf8))
}

View File

@ -0,0 +1,55 @@
//
// MediaPreviewModelsTests.swift
// suixinkanTests
//
import UIKit
import XCTest
@testable import suixinkan
///
final class MediaPreviewModelsTests: XCTestCase {
func testClampedStartIndexKeepsIndexInBounds() {
XCTAssertEqual(MediaPreviewItem.clampedStartIndex(-3, itemCount: 4), 0)
XCTAssertEqual(MediaPreviewItem.clampedStartIndex(2, itemCount: 4), 2)
XCTAssertEqual(MediaPreviewItem.clampedStartIndex(8, itemCount: 4), 3)
XCTAssertEqual(MediaPreviewItem.clampedStartIndex(8, itemCount: 0), 0)
}
func testRemoteImageAndVideoURLConstructionFiltersInvalidText() {
let image = MediaPreviewItem.remoteImage(" https://cdn.example.com/a.jpg ")
let video = MediaPreviewItem.remoteVideo("https://cdn.example.com/a.mp4")
XCTAssertEqual(image?.mediaType, .image)
XCTAssertEqual(video?.mediaType, .video)
XCTAssertNil(MediaPreviewItem.remoteImage(""))
XCTAssertNil(MediaPreviewItem.remoteImage(" "))
XCTAssertNil(MediaPreviewItem.remoteVideo("file:///tmp/a.mp4"))
XCTAssertNil(MediaPreviewItem.remoteVideo("not a url"))
}
func testLocalImageAndVideoTypeRecognition() {
let localImage = MediaPreviewItem.localImage(url: URL(fileURLWithPath: "/tmp/a.jpg"))
let memoryImage = MediaPreviewItem.image(UIImage())
let localVideo = MediaPreviewItem.localVideo(url: URL(fileURLWithPath: "/tmp/a.mp4"))
XCTAssertTrue(localImage.isImage)
XCTAssertTrue(memoryImage.isImage)
XCTAssertTrue(localVideo.isVideo)
XCTAssertEqual(localVideo.mediaType, .video)
}
func testCurrentItemReadsClampedItemFromMixedMedia() {
let items = [
MediaPreviewItem.localImage(url: URL(fileURLWithPath: "/tmp/a.jpg")),
MediaPreviewItem.localVideo(url: URL(fileURLWithPath: "/tmp/a.mp4")),
MediaPreviewItem.remoteImage("https://cdn.example.com/b.jpg")!,
]
XCTAssertEqual(MediaPreviewItem.currentItem(in: items, startIndex: -1)?.mediaType, .image)
XCTAssertEqual(MediaPreviewItem.currentItem(in: items, startIndex: 1)?.mediaType, .video)
XCTAssertEqual(MediaPreviewItem.currentItem(in: items, startIndex: 99)?.mediaType, .image)
XCTAssertNil(MediaPreviewItem.currentItem(in: [], startIndex: 0))
}
}

View File

@ -13,13 +13,15 @@ final class MockLocationProvider: LocationProviding, @unchecked Sendable {
var reverseGeocodeResult = "北京市东城区"
var requestSnapshotError: Error?
var requestCoordinateError: Error?
var requestedAccuracies: [CLLocationAccuracy] = []
func requestSnapshot() async throws -> HomeLocationSnapshot {
if let requestSnapshotError { throw requestSnapshotError }
return snapshot
}
func requestCoordinate() async throws -> CLLocationCoordinate2D {
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
requestedAccuracies.append(desiredAccuracy)
if let requestCoordinateError { throw requestCoordinateError }
return coordinate
}

View File

@ -259,7 +259,7 @@ final class ProfileSpaceSettingsViewModelTests: XCTestCase {
XCTAssertTrue(navigationController.topViewController is ProfileSpaceSettingsViewController)
}
func testSystemSettingsRouteStillSelectsProfileTab() {
func testSystemSettingsRoutePushesSettingPage() {
AppStore.shared.currentScenicId = 11
let tabBarController = MainTabBarController()
tabBarController.loadViewIfNeeded()
@ -273,7 +273,8 @@ final class ProfileSpaceSettingsViewModelTests: XCTestCase {
HomeRouteHandler.open(uri: "system_settings", from: root, storeItem: nil)
XCTAssertEqual(tabBarController.selectedIndex, 4)
XCTAssertEqual(tabBarController.selectedIndex, 0)
XCTAssertTrue(navigationController.topViewController is SettingViewController)
}
private func makeViewModel() -> ProfileSpaceSettingsViewModel {

View File

@ -6,8 +6,8 @@
import XCTest
@testable import suixinkan
@MainActor
/// API
@MainActor
final class SampleManagementAPITests: XCTestCase {
func testListBuildsAndroidQuery() async throws {
let session = MockURLSession(responses: [envelopeJSON(#"{"total":0,"list":[],"order":null}"#)])
@ -26,6 +26,18 @@ final class SampleManagementAPITests: XCTestCase {
XCTAssertEqual(query?.first { $0.name == "page_size" }?.value, "10")
}
func testListDecodesNullableOrderAmounts() async throws {
let session = MockURLSession(responses: [envelopeJSON(#"{"total":0,"list":[],"order":{"avg_change":-1025,"avg_order_amount":null,"refund_total":null,"total_num":0}}"#)])
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.list(status: 4, keyword: nil, page: 1, pageSize: 10)
XCTAssertEqual(response.order?.avgOrderAmount, "0")
XCTAssertEqual(response.order?.refundTotal, "0")
XCTAssertEqual(response.order?.avgChange, -1025)
XCTAssertEqual(response.order?.totalNum, 0)
}
func testDetailAndOperationUseSampleType() async throws {
let detail = envelopeJSON(#"{"id":8,"status":1,"listing_status":1}"#)
let empty = envelopeJSON("{}")

View File

@ -0,0 +1,91 @@
//
// SettingViewModelTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// ViewModel
final class SettingViewModelTests: XCTestCase {
func testAppVersionUsesClientInfoFormatting() {
let viewModel = SettingViewModel(
infoDictionary: [
"CFBundleShortVersionString": "1.2",
"CFBundleVersion": "9",
],
environment: .testing
)
XCTAssertEqual(viewModel.appVersion, "1.2.9")
}
func testAppDownloadURLUsesEnvironmentBaseURL() {
let viewModel = SettingViewModel(infoDictionary: [:], environment: .testing)
XCTAssertEqual(viewModel.appDownloadURL.absoluteString, "https://api-test.zhifly.cn/h5/app/download")
}
func testVersionCodeComparison() {
XCTAssertTrue(SettingViewModel.isNewerVersionCode(11, than: 10))
XCTAssertFalse(SettingViewModel.isNewerVersionCode(10, than: 10))
XCTAssertFalse(SettingViewModel.isNewerVersionCode(9, than: 10))
XCTAssertFalse(SettingViewModel.isNewerVersionCode(11, than: nil))
}
func testAgreementDestinationsMapToAndroidPaths() {
let viewModel = SettingViewModel(infoDictionary: [:], environment: .testing)
let about = viewModel.agreementDestination(for: .aboutUs)
let userAgreement = viewModel.agreementDestination(for: .userAgreement)
let privacy = viewModel.agreementDestination(for: .privacyPolicy)
XCTAssertEqual(about.title, "关于我们")
XCTAssertEqual(about.url.absoluteString, "https://api-test.zhifly.cn/h5/app/about-us")
XCTAssertEqual(userAgreement.title, "用户协议")
XCTAssertEqual(userAgreement.url.absoluteString, "https://api-test.zhifly.cn/h5/app/user-agreement")
XCTAssertEqual(privacy.title, "隐私政策")
XCTAssertEqual(privacy.url.absoluteString, "https://api-test.zhifly.cn/h5/app/privacy-policy")
}
@MainActor
func testCheckLatestVersionMarksNewVersionWhenVersionCodeIsHigher() async throws {
let json = """
{
"code": 100000,
"msg": "success",
"data": {
"id": 1,
"type": 1,
"version": "1.0.1",
"operator": "admin",
"description": "update",
"status": 1,
"created_at": "2026-07-09 00:00:00",
"updated_at": "2026-07-09 00:00:00",
"deleted_at": null,
"version_code": 8,
"apk_url": "https://example.com/app.apk",
"is_force_update": 0,
"approve_notes": ""
}
}
""".data(using: .utf8)!
let session = MockURLSession(responses: [json])
let client = APIClient(environment: .testing, session: session)
let api = SettingAPI(client: client)
let viewModel = SettingViewModel(
infoDictionary: [
"CFBundleShortVersionString": "1.0.0",
"CFBundleVersion": "7",
],
environment: .testing
)
await viewModel.checkLatestVersion(api: api)
XCTAssertEqual(viewModel.latestVersion?.versionCode, 8)
XCTAssertTrue(viewModel.hasNewVersion)
XCTAssertEqual(session.requests.first?.url?.path, "/api/app/latest-version")
}
}

View File

@ -4,40 +4,13 @@
//
import CoreLocation
import MapKit
import XCTest
@testable import suixinkan
@MainActor
/// Mock
///
final class WildPhotographerReportTests: XCTestCase {
func testSeedDataLoadsReportsAndRiskPoints() {
let viewModel = WildPhotographerReportHomeViewModel()
XCTAssertFalse(viewModel.reports.isEmpty)
XCTAssertFalse(viewModel.riskPoints.isEmpty)
XCTAssertEqual(viewModel.reports.count, WildPhotographerReportMockStore.seedReports.count)
}
func testSubmitReportGeneratesDynamicIDAndInsertsFirst() {
let viewModel = WildPhotographerReportHomeViewModel()
let beforeCount = viewModel.reports.count
let record = viewModel.submitReport(
reportType: WildReportType(value: 2, label: "疑似打野"),
description: "测试举报说明",
location: "测试景区 · 测试点位",
contact: "13800138000",
imageCount: 1,
videoCount: 0
)
XCTAssertEqual(viewModel.reports.count, beforeCount + 1)
XCTAssertEqual(viewModel.reports.first?.id, record.id)
XCTAssertTrue(record.id.hasPrefix("JB"))
XCTAssertEqual(record.status, .pending)
XCTAssertEqual(record.phoneNumber, "13800138000")
}
func testReportTypesAPILoadsRemoteOptions() async throws {
let json = Data("""
{
@ -68,6 +41,75 @@ final class WildPhotographerReportTests: XCTestCase {
])
}
func testReportCopyAPILoadsNoticeAndRuleGroups() async throws {
let json = Data("""
{
"code": 100000,
"msg": "success",
"data": {
"notice": ["", ""],
"rule_groups": [
{
"title": "",
"items": [""]
}
]
},
"time": "2026-07-09 10:15:17"
}
""".utf8)
let session = MockURLSession(responses: [json])
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.reportCopy()
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/copy")
XCTAssertEqual(response.notice, ["需实名登录", "请尽量上传清晰证据"])
XCTAssertEqual(response.ruleGroups, [
WildReportRuleGroup(title: "可举报行为", items: ["疑似打野摄影师主动揽客"])
])
}
func testHomeViewModelLoadsReportCopyAndDisplayRules() async {
let viewModel = WildPhotographerReportHomeViewModel()
let api = MockWildPhotographerReportAPI()
api.reportCopyResponse = WildReportCopyResponse(
notice: ["需实名登录", "请尽量上传清晰证据"],
ruleGroups: [WildReportRuleGroup(title: "材料要求", items: ["尽量提供现场图片"])]
)
await viewModel.loadReportCopy(api: api)
XCTAssertEqual(viewModel.noticeItems, ["需实名登录", "请尽量上传清晰证据"])
XCTAssertEqual(viewModel.ruleGroups, [WildReportRuleGroup(title: "材料要求", items: ["尽量提供现场图片"])])
XCTAssertTrue(viewModel.shouldShowNoticeModule)
XCTAssertTrue(viewModel.shouldShowRuleButton)
}
func testHomeViewModelHidesNoticeModuleWhenCopyIsEmpty() async {
let viewModel = WildPhotographerReportHomeViewModel()
let api = MockWildPhotographerReportAPI()
api.reportCopyResponse = WildReportCopyResponse(notice: [" "], ruleGroups: [WildReportRuleGroup(title: " ", items: [])])
await viewModel.loadReportCopy(api: api)
XCTAssertTrue(viewModel.noticeItems.isEmpty)
XCTAssertTrue(viewModel.ruleGroups.isEmpty)
XCTAssertFalse(viewModel.shouldShowNoticeModule)
XCTAssertFalse(viewModel.shouldShowRuleButton)
}
func testHomeViewModelShowsNoticeWithoutRuleButtonWhenRulesAreEmpty() async {
let viewModel = WildPhotographerReportHomeViewModel()
let api = MockWildPhotographerReportAPI()
api.reportCopyResponse = WildReportCopyResponse(notice: ["需实名登录"], ruleGroups: [])
await viewModel.loadReportCopy(api: api)
XCTAssertTrue(viewModel.shouldShowNoticeModule)
XCTAssertFalse(viewModel.shouldShowRuleButton)
}
func testReportListAPILoadsRemoteReports() async throws {
let json = Data("""
{
@ -239,7 +281,7 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(evidences.first?["file_name"] as? String, "supplement.jpg")
}
func testRiskMapAPISendsRequestBodyAndDecodesMarkers() async throws {
func testRiskMapAPISendsGETQueryAndDecodesMarkers() async throws {
let json = Data("""
{
"code": 100000,
@ -291,20 +333,20 @@ final class WildPhotographerReportTests: XCTestCase {
))
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/risk-map")
let bodyData = try XCTUnwrap(request.httpBody)
let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
XCTAssertEqual(body["scenic_id"] as? Int, 128)
XCTAssertEqual(body["latitude"] as? Double, 43.3779)
XCTAssertEqual(body["longitude"] as? Double, 84.0217)
XCTAssertNil(request.httpBody)
let queryItems = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems ?? []
XCTAssertEqual(queryItems.first(where: { $0.name == "scenic_id" })?.value, "128")
XCTAssertEqual(queryItems.first(where: { $0.name == "latitude" })?.value, "43.3779")
XCTAssertEqual(queryItems.first(where: { $0.name == "longitude" })?.value, "84.0217")
XCTAssertEqual(response.scenicName, "伊犁那拉提景区-5A")
XCTAssertEqual(response.greenMarkerTip, "绿色为景区内摄影师")
XCTAssertEqual(response.markers.map(\.markerType), ["red", "yellow"])
XCTAssertEqual(response.markers.last?.handleStatus?.displayText, WildReportStatus.processing.rawValue)
}
func testMarkerDetailAPISendsRequestBodyAndDecodesGreenDetail() async throws {
func testMarkerDetailAPISendsGETQueryAndDecodesGreenDetail() async throws {
let json = Data("""
{
"code": 100000,
@ -340,15 +382,16 @@ final class WildPhotographerReportTests: XCTestCase {
))
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/marker-detail")
let bodyData = try XCTUnwrap(request.httpBody)
let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
XCTAssertEqual(body["scenic_id"] as? Int, 128)
XCTAssertEqual(body["marker_type"] as? String, "green")
XCTAssertEqual(body["id"] as? Int, 901)
XCTAssertEqual(body["latitude"] as? Double, 43.3779)
XCTAssertEqual(body["longitude"] as? Double, 84.0217)
XCTAssertNil(request.httpBody)
let components = try XCTUnwrap(URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false))
let queryItems = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value) })
XCTAssertEqual(queryItems["scenic_id"], "128")
XCTAssertEqual(queryItems["marker_type"], "green")
XCTAssertEqual(queryItems["id"], "901")
XCTAssertEqual(queryItems["latitude"], "43.3779")
XCTAssertEqual(queryItems["longitude"], "84.0217")
XCTAssertEqual(response.name, "")
XCTAssertEqual(response.storeName, "")
XCTAssertTrue(response.online)
@ -428,10 +471,10 @@ final class WildPhotographerReportTests: XCTestCase {
WildReportType(value: 2, label: ""),
WildReportType(value: 4, label: "")
])
XCTAssertEqual(viewModel.selectedReportType, .defaultSelected)
XCTAssertEqual(viewModel.selectedReportType, WildReportType(value: 1, label: ""))
}
func testSubmitViewModelFallsBackWhenReportTypesFail() async {
func testSubmitViewModelKeepsReportTypesEmptyWhenReportTypesFail() async {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
@ -441,43 +484,61 @@ final class WildPhotographerReportTests: XCTestCase {
await viewModel.loadReportTypes(api: api)
XCTAssertEqual(viewModel.reportTypes, WildReportType.fallbackOptions)
XCTAssertEqual(viewModel.selectedReportType, .defaultSelected)
XCTAssertEqual(messages.last, "使")
XCTAssertTrue(viewModel.reportTypes.isEmpty)
XCTAssertNil(viewModel.selectedReportType)
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
}
func testSubmitFailsWithoutEvidence() {
func testSubmitFailsWithoutEvidence() async {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.updateDescription("")
viewModel.applyResolvedLocation("")
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: ""))
viewModel.submitReport()
await viewModel.submitReport(
api: MockWildPhotographerReportAPI(),
uploader: MockWildReportAttachmentUploader(),
scenicId: 9,
scenicName: ""
)
XCTAssertEqual(viewModel.validationMessage, "")
XCTAssertNil(viewModel.submitResult)
}
func testSubmitFailsWithoutDescription() {
func testSubmitFailsWithoutDescription() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.addImage()
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
viewModel.updateDescription(" ")
viewModel.applyResolvedLocation("")
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: ""))
viewModel.submitReport()
await viewModel.submitReport(
api: MockWildPhotographerReportAPI(),
uploader: MockWildReportAttachmentUploader(),
scenicId: 9,
scenicName: ""
)
XCTAssertEqual(viewModel.validationMessage, "")
XCTAssertNil(viewModel.submitResult)
}
func testSubmitFailsWithoutConfirmedLocation() {
func testSubmitFailsWithoutConfirmedLocation() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.addImage()
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
viewModel.updateDescription("")
viewModel.submitReport()
await viewModel.submitReport(
api: MockWildPhotographerReportAPI(),
uploader: MockWildReportAttachmentUploader(),
scenicId: 9,
scenicName: ""
)
XCTAssertEqual(viewModel.validationMessage, "")
XCTAssertNil(viewModel.submitResult)
@ -488,6 +549,7 @@ final class WildPhotographerReportTests: XCTestCase {
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
let uploader = MockWildReportAttachmentUploader()
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")])
viewModel.updateDescription("")
@ -497,7 +559,7 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertNil(viewModel.validationMessage)
XCTAssertNotNil(viewModel.submitResult)
XCTAssertEqual(homeViewModel.reports.first?.id, viewModel.submitResult?.record.id)
XCTAssertEqual(viewModel.submitResult?.record.id, "")
XCTAssertEqual(uploader.uploadedFileNames, ["scene.jpg", "payment.jpg"])
XCTAssertEqual(api.submitRequests.first?.scenicId, 9)
XCTAssertEqual(api.submitRequests.first?.reportType, 2)
@ -512,9 +574,10 @@ final class WildPhotographerReportTests: XCTestCase {
func testPaymentScreenshotsDoNotReplaceRequiredEvidence() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")])
viewModel.updateDescription("")
viewModel.applyResolvedLocation(" · ")
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: " · "))
await viewModel.submitReport(
api: MockWildPhotographerReportAPI(),
@ -546,9 +609,10 @@ final class WildPhotographerReportTests: XCTestCase {
let api = MockWildPhotographerReportAPI()
let uploader = MockWildReportAttachmentUploader()
uploader.error = OSSUploadError.emptyFile
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
viewModel.updateDescription("")
viewModel.applyResolvedLocation(" · ")
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: " · "))
await viewModel.submitReport(api: api, uploader: uploader, scenicId: 9, scenicName: "")
@ -556,23 +620,6 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(viewModel.validationMessage, OSSUploadError.emptyFile.localizedDescription)
}
func testListFilterReturnsMatchingStatus() {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
viewModel.selectFilter(.pending)
XCTAssertTrue(viewModel.filteredReports.allSatisfy { $0.status == .pending })
viewModel.selectFilter(.processing)
XCTAssertTrue(viewModel.filteredReports.allSatisfy { $0.status == .processing })
viewModel.selectFilter(.processed)
XCTAssertTrue(viewModel.filteredReports.allSatisfy { $0.status == .processed })
viewModel.selectFilter(.all)
XCTAssertEqual(viewModel.filteredReports.count, homeViewModel.reports.count)
}
func testListInitialLoadUsesAllStatusAndFirstPage() async {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
@ -668,9 +715,6 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(api.supplementRequests.first?.id, 32)
XCTAssertEqual(api.supplementRequests.first?.desc, "")
XCTAssertEqual(api.supplementRequests.first?.evidences?.map(\.fileType), [1, 2])
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: record.id).first?.text, "")
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: record.id).first?.imageCount, 1)
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: record.id).first?.videoCount, 1)
}
func testSupplementEvidenceFailsWithoutServerID() async {
@ -757,13 +801,8 @@ final class WildPhotographerReportTests: XCTestCase {
}
func testRiskMapViewModelRejectsInvalidScenicID() async {
let viewModel = WildReportRiskMapViewModel(
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
riskPoints: []
)
let viewModel = WildReportRiskMapViewModel()
let api = MockWildPhotographerReportAPI()
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
await viewModel.loadInitial(
api: api,
@ -772,27 +811,27 @@ final class WildPhotographerReportTests: XCTestCase {
)
XCTAssertTrue(api.riskMapRequests.isEmpty)
XCTAssertEqual(messages.last, "")
XCTAssertEqual(viewModel.errorMessage, "")
}
func testRiskMapViewModelLoadsWithLocationAndMapsMarkerTypes() async {
let viewModel = WildReportRiskMapViewModel(
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
riskPoints: []
)
let viewModel = WildReportRiskMapViewModel()
let api = MockWildPhotographerReportAPI()
api.riskMapResponses = [makeRiskMapResponse()]
let locationProvider = MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217))
await viewModel.loadInitial(
api: api,
locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)),
locationProvider: locationProvider,
scenicId: 128,
scenicName: ""
)
XCTAssertEqual(api.riskMapRequests.count, 1)
XCTAssertEqual(api.riskMapRequests.first?.scenicId, 128)
XCTAssertEqual(api.riskMapRequests.first?.latitude, 43.3779)
XCTAssertEqual(api.riskMapRequests.first?.longitude, 84.0217)
XCTAssertEqual(locationProvider.requestedAccuracies.last, kCLLocationAccuracyHundredMeters)
XCTAssertEqual(viewModel.scenicAreaName, "-5A")
XCTAssertTrue(viewModel.markers.contains { $0.kind == .selfLocation })
XCTAssertTrue(viewModel.markers.contains { $0.kind == .wildPhotographer })
@ -807,10 +846,7 @@ final class WildPhotographerReportTests: XCTestCase {
}
func testRiskMapViewModelLoadsWithoutLocationWhenLocationFails() async {
let viewModel = WildReportRiskMapViewModel(
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
riskPoints: []
)
let viewModel = WildReportRiskMapViewModel()
let api = MockWildPhotographerReportAPI()
api.riskMapResponses = [makeRiskMapResponse()]
@ -826,11 +862,29 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertFalse(viewModel.markers.contains { $0.kind == .selfLocation })
}
func testRiskMapViewModelLoadsMarkerDetailAndUpdatesSelectedClue() async {
let viewModel = WildReportRiskMapViewModel(
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
riskPoints: []
func testRiskMapViewModelFocusCurrentLocationSelectsSelfMarker() async {
let viewModel = WildReportRiskMapViewModel()
let api = MockWildPhotographerReportAPI()
api.riskMapResponses = [makeRiskMapResponse()]
await viewModel.loadInitial(
api: api,
locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)),
scenicId: 128
)
let redMarker = try! XCTUnwrap(viewModel.markers.first { $0.kind == .wildPhotographer })
await viewModel.selectMarker(redMarker)
let didFocus = viewModel.focusCurrentLocation()
XCTAssertTrue(didFocus)
XCTAssertEqual(viewModel.selectedMarker?.kind, .selfLocation)
XCTAssertEqual(viewModel.region.center.latitude, 43.3779, accuracy: 0.000001)
XCTAssertEqual(viewModel.region.center.longitude, 84.0217, accuracy: 0.000001)
XCTAssertTrue(api.markerDetailRequests.isEmpty)
}
func testRiskMapViewModelLoadsMarkerDetailAndUpdatesSelectedClue() async {
let viewModel = WildReportRiskMapViewModel()
let api = MockWildPhotographerReportAPI()
api.riskMapResponses = [makeRiskMapResponse()]
api.markerDetailResponses = [
@ -866,16 +920,56 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(viewModel.selectedClue?.detail, "")
}
func testRiskMapViewModelMarkerDetailFailureKeepsSummary() async {
let viewModel = WildReportRiskMapViewModel(
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
riskPoints: []
func testRiskMapViewModelMapsProcessingMarkerDetailForBottomCard() async {
let viewModel = WildReportRiskMapViewModel()
let api = MockWildPhotographerReportAPI()
api.riskMapResponses = [makeRiskMapResponse()]
api.markerDetailResponses = [
WildReportMarkerDetailResponse(
markerType: "yellow",
id: 33,
clueId: "202679-0039",
title: "",
statusTag: "",
latitude: "32.3774100",
longitude: "119.3979350",
distanceM: 7068,
reportTypeText: "",
desc: "",
reporterName: "",
reporterPhone: "121****1212",
createdAt: "2026-07-09 11:37:26",
evidences: [
WildReportDetailEvidence(fileType: 1, fileTypeText: "", fileURL: "https://example.com/one.jpg"),
WildReportDetailEvidence(fileType: 1, fileTypeText: "图片", fileURL: "https://example.com/two.jpg")
]
)
]
await viewModel.loadInitial(
api: api,
locationProvider: MockRiskMapLocationProvider(.success(latitude: 32.429943033854165, longitude: 119.44032552083333)),
scenicId: 128
)
let yellowMarker = try! XCTUnwrap(viewModel.markers.first { $0.kind == .processingClue })
await viewModel.selectMarker(yellowMarker, api: api, scenicId: 128)
XCTAssertEqual(api.markerDetailRequests.first?.markerType, "yellow")
XCTAssertEqual(viewModel.selectedClue?.id, "2026年7月9日-0039")
XCTAssertEqual(viewModel.selectedClue?.statusText, "正在处理中")
XCTAssertEqual(viewModel.selectedClue?.reporterName, "游客")
XCTAssertEqual(viewModel.selectedClue?.reporterPhone, "121****1212")
XCTAssertEqual(viewModel.selectedClue?.reportContents.first?.content, "色地方噶是的法规")
XCTAssertEqual(viewModel.selectedClue?.images, ["https://example.com/one.jpg", "https://example.com/two.jpg"])
XCTAssertEqual(viewModel.selectedClue?.processingStarted, true)
}
func testRiskMapViewModelMarkerDetailFailureKeepsSummary() async {
let viewModel = WildReportRiskMapViewModel()
let api = MockWildPhotographerReportAPI()
api.riskMapResponses = [makeRiskMapResponse()]
api.markerDetailError = URLError(.badServerResponse)
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
await viewModel.loadInitial(
api: api,
locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)),
@ -886,7 +980,7 @@ final class WildPhotographerReportTests: XCTestCase {
await viewModel.selectMarker(redMarker, api: api, scenicId: 128)
XCTAssertEqual(viewModel.selectedClue?.summary, "疑似打野")
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
XCTAssertEqual(viewModel.errorMessage, URLError(.badServerResponse).localizedDescription)
}
func testDetailViewModelLoadsRemoteDetailWhenServerIDExists() async {
@ -946,6 +1040,8 @@ final class WildPhotographerReportTests: XCTestCase {
@MainActor
private final class MockWildPhotographerReportAPI: WildPhotographerReportServing {
var reportTypeResponse = WildReportTypeListResponse(list: [])
var reportCopyResponse = WildReportCopyResponse()
var reportCopyError: Error?
var error: Error?
var submitRequests: [WildReportSubmitRequest] = []
var reportListResponses: [WildReportListResponse] = []
@ -968,6 +1064,11 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
return reportTypeResponse
}
func reportCopy() async throws -> WildReportCopyResponse {
if let reportCopyError { throw reportCopyError }
return reportCopyResponse
}
func submitReport(_ request: WildReportSubmitRequest) async throws {
if let error { throw error }
submitRequests.append(request)
@ -1018,11 +1119,24 @@ private enum MockRiskMapLocationResult: Sendable {
case failure
}
private struct MockRiskMapLocationProvider: LocationProviding {
private final class MockRiskMapLocationProvider: LocationProviding, @unchecked Sendable {
private let result: MockRiskMapLocationResult
private let onCoordinateRequest: (@Sendable (CLLocationAccuracy) async -> Void)?
private let lock = NSLock()
private var recordedAccuracies: [CLLocationAccuracy] = []
init(_ result: MockRiskMapLocationResult) {
var requestedAccuracies: [CLLocationAccuracy] {
lock.lock()
defer { lock.unlock() }
return recordedAccuracies
}
init(
_ result: MockRiskMapLocationResult,
onCoordinateRequest: (@Sendable (CLLocationAccuracy) async -> Void)? = nil
) {
self.result = result
self.onCoordinateRequest = onCoordinateRequest
}
func requestSnapshot() async throws -> HomeLocationSnapshot {
@ -1034,7 +1148,11 @@ private struct MockRiskMapLocationProvider: LocationProviding {
}
}
func requestCoordinate() async throws -> CLLocationCoordinate2D {
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
lock.lock()
recordedAccuracies.append(desiredAccuracy)
lock.unlock()
await onCoordinateRequest?(desiredAccuracy)
switch result {
case .success(let latitude, let longitude):
return CLLocationCoordinate2D(latitude: latitude, longitude: longitude)