diff --git a/suixinkan/App/NetworkServices.swift b/suixinkan/App/NetworkServices.swift index f16ac13..2525a05 100644 --- a/suixinkan/App/NetworkServices.swift +++ b/suixinkan/App/NetworkServices.swift @@ -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) diff --git a/suixinkan/Base/BaseViewController.swift b/suixinkan/Base/BaseViewController.swift index e7bdf43..078b7ef 100644 --- a/suixinkan/Base/BaseViewController.swift +++ b/suixinkan/Base/BaseViewController.swift @@ -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 diff --git a/suixinkan/Config/AppAgreementURLs.swift b/suixinkan/Config/AppAgreementURLs.swift index 3f8dcdc..c1a7b69 100644 --- a/suixinkan/Config/AppAgreementURLs.swift +++ b/suixinkan/Config/AppAgreementURLs.swift @@ -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/")! } diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index 8788200..a9f8b01 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -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 = ["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": diff --git a/suixinkan/Features/Home/Services/HomeRouteHandler.swift b/suixinkan/Features/Home/Services/HomeRouteHandler.swift index 7860009..f818663 100644 --- a/suixinkan/Features/Home/Services/HomeRouteHandler.swift +++ b/suixinkan/Features/Home/Services/HomeRouteHandler.swift @@ -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": diff --git a/suixinkan/Features/Location/LocationProvider.swift b/suixinkan/Features/Location/LocationProvider.swift index 0012137..1c94c3e 100644 --- a/suixinkan/Features/Location/LocationProvider.swift +++ b/suixinkan/Features/Location/LocationProvider.swift @@ -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 } } diff --git a/suixinkan/Features/Location/LocationProviding.swift b/suixinkan/Features/Location/LocationProviding.swift index 41451dd..45b3990 100644 --- a/suixinkan/Features/Location/LocationProviding.swift +++ b/suixinkan/Features/Location/LocationProviding.swift @@ -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 diff --git a/suixinkan/Features/MaterialManagement/API/MaterialManagementAPI.swift b/suixinkan/Features/MaterialManagement/API/MaterialManagementAPI.swift new file mode 100644 index 0000000..f3420ab --- /dev/null +++ b/suixinkan/Features/MaterialManagement/API/MaterialManagementAPI.swift @@ -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)] + ) + ) + } +} diff --git a/suixinkan/Features/MaterialManagement/Models/MaterialManagementModels.swift b/suixinkan/Features/MaterialManagement/Models/MaterialManagementModels.swift new file mode 100644 index 0000000..e31e7bf --- /dev/null +++ b/suixinkan/Features/MaterialManagement/Models/MaterialManagementModels.swift @@ -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 } +} diff --git a/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift b/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift new file mode 100644 index 0000000..1311b53 --- /dev/null +++ b/suixinkan/Features/MaterialManagement/ViewModels/MaterialManagementViewModels.swift @@ -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 } +} diff --git a/suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift b/suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift index 52c574e..51122a9 100644 --- a/suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift +++ b/suixinkan/Features/SampleManagement/Models/SampleManagementModels.swift @@ -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`。 diff --git a/suixinkan/Features/Setting/API/SettingAPI.swift b/suixinkan/Features/Setting/API/SettingAPI.swift new file mode 100644 index 0000000..0cec4cc --- /dev/null +++ b/suixinkan/Features/Setting/API/SettingAPI.swift @@ -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") + ) + } +} diff --git a/suixinkan/Features/Setting/Models/SettingModels.swift b/suixinkan/Features/Setting/Models/SettingModels.swift new file mode 100644 index 0000000..05450a9 --- /dev/null +++ b/suixinkan/Features/Setting/Models/SettingModels.swift @@ -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 + } +} diff --git a/suixinkan/Features/Setting/ViewModels/SettingViewModel.swift b/suixinkan/Features/Setting/ViewModels/SettingViewModel.swift new file mode 100644 index 0000000..7dd6e14 --- /dev/null +++ b/suixinkan/Features/Setting/ViewModels/SettingViewModel.swift @@ -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?() + } +} diff --git a/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift b/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift index 5f6e821..13b4ff7 100644 --- a/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift +++ b/suixinkan/Features/WildPhotographerReport/API/WildPhotographerReportAPI.swift @@ -10,6 +10,8 @@ import Foundation protocol WildPhotographerReportServing { /// 拉取提交举报可选类型。 func reportTypes() async throws -> WildReportTypeListResponse + /// 拉取举报首页须知与规则文案。 + func reportCopy() async throws -> WildReportCopyResponse /// 提交摄影师举报。 func submitReport(_ request: WildReportSubmitRequest) async throws /// 为已有举报补充证据。 @@ -41,6 +43,13 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing { ) } + /// 拉取举报首页须知与规则文案。 + func reportCopy() async throws -> WildReportCopyResponse { + try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/report/copy") + ) + } + /// 提交摄影师举报。 func submitReport(_ request: WildReportSubmitRequest) async throws { let _: EmptyPayload = try await client.send( @@ -67,9 +76,9 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing { func riskMap(_ request: WildReportRiskMapRequest) async throws -> WildReportRiskMapResponse { try await client.send( APIRequest( - method: .post, + method: .get, path: "/api/yf-handset-app/photog/report/risk-map", - body: request + queryItems: request.queryItems ) ) } @@ -115,12 +124,23 @@ struct WildReportSupplementRequest: Encodable, Equatable { let evidences: [WildReportEvidenceRequest]? } -/// 附近风险地图接口请求体。 +/// 附近风险地图接口请求参数。 struct WildReportRiskMapRequest: Encodable, Equatable { let scenicId: Int let latitude: Double? let longitude: Double? + var queryItems: [URLQueryItem] { + var items = [URLQueryItem(name: "scenic_id", value: String(scenicId))] + if let latitude { + items.append(URLQueryItem(name: "latitude", value: String(latitude))) + } + if let longitude { + items.append(URLQueryItem(name: "longitude", value: String(longitude))) + } + return items + } + enum CodingKeys: String, CodingKey { case scenicId = "scenic_id" case latitude diff --git a/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift index 517f509..242e337 100644 --- a/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift +++ b/suixinkan/Features/WildPhotographerReport/Models/WildPhotographerReportModels.swift @@ -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 diff --git a/suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift b/suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift deleted file mode 100644 index fd356cf..0000000 --- a/suixinkan/Features/WildPhotographerReport/Services/WildPhotographerReportMockStore.swift +++ /dev/null @@ -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 = [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)) - } -} diff --git a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift index ed6bf07..90d06be 100644 --- a/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift +++ b/suixinkan/Features/WildPhotographerReport/ViewModels/WildPhotographerReportViewModels.swift @@ -22,126 +22,83 @@ protocol WildReportAttachmentUploading { extension OSSUploadService: WildReportAttachmentUploading {} -/// 举报摄影师首页 ViewModel,持有 Mock 举报数据并供子页面共享。 +/// 举报摄影师模块通用日期格式化工具。 +enum WildReportDateFormatters { + static let displayDateTime: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + formatter.locale = Locale(identifier: "zh_CN") + formatter.timeZone = .current + return formatter + }() +} + +/// 举报摄影师模块通用联系方式解析工具。 +enum WildReportContactParser { + /// 将用户输入的联系方式拆分为手机号和微信号。 + static func parse(_ contact: String?) -> (phone: String?, wechat: String?) { + let trimmed = contact?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !trimmed.isEmpty else { return (nil, nil) } + let digits = trimmed.filter(\.isNumber) + if digits.count >= 7 { + return (trimmed, nil) + } + return (nil, trimmed) + } +} + +/// 举报摄影师首页 ViewModel,维护首页远端文案和跨页面共享状态。 final class WildPhotographerReportHomeViewModel { private(set) var pageState: WildReportPageState = .normal - private(set) var reports: [WildReportRecord] = [] - private(set) var riskPoints: [WildReportRiskPoint] = [] - private(set) var supplementaryEvidenceMap: [String: [WildReportSupplementaryEvidence]] = [:] + private(set) var noticeItems: [String] = [] + private(set) var ruleGroups: [WildReportRuleGroup] = [] + private(set) var isLoadingCopy = false var onStateChange: (() -> Void)? - - private var nextSequence = 0 - private var nextSupplementSequence = 0 - - init() { - resetDemoData(notify: false) - } + var onShowMessage: ((String) -> Void)? var emptyTitle: String { "暂无举报服务" } - var emptyMessage: String { "当前景区暂未开放举报摄影师演示入口。" } + var emptyMessage: String { "当前景区暂未开放举报摄影师入口。" } + var shouldShowNoticeModule: Bool { !noticeItems.isEmpty || !ruleGroups.isEmpty } + var shouldShowRuleButton: Bool { !ruleGroups.isEmpty } - /// 恢复演示数据并回到正常状态。 - func restoreDemoData() { - resetDemoData(notify: false) - pageState = .normal + /// 加载首页举报须知与规则文案。 + func loadReportCopy(api: any WildPhotographerReportServing) async { + guard !isLoadingCopy else { return } + isLoadingCopy = true notifyStateChange() - } - - /// 模拟错误后重新加载。 - func retryLoadAfterError() { - pageState = .loading - notifyStateChange() - Task { - try? await Task.sleep(nanoseconds: 450_000_000) - pageState = .normal + defer { + isLoadingCopy = false notifyStateChange() } + do { + let response = try await api.reportCopy() + noticeItems = response.notice + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + ruleGroups = response.ruleGroups.compactMap { group in + let title = group.title.trimmingCharacters(in: .whitespacesAndNewlines) + let items = group.items + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + guard !title.isEmpty || !items.isEmpty else { return nil } + return WildReportRuleGroup(title: title, items: items) + } + } catch is CancellationError { + return + } catch { + noticeItems = [] + ruleGroups = [] + onShowMessage?(error.localizedDescription) + } } - /// 切换页面演示状态。 + /// 切换页面状态。 func setPageState(_ state: WildReportPageState) { pageState = state notifyStateChange() } - /// 提交举报并写入 Mock 列表,返回新记录。 - @discardableResult - func submitReport( - reportType: WildReportType, - description: String, - location: String, - contact: String?, - imageCount: Int, - videoCount: Int - ) -> WildReportRecord { - let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines) - let parsedContact = WildPhotographerReportMockStore.parseContact(contact) - let record = WildReportRecord( - id: nextReportID(), - title: "摄影师举报", - reportType: reportType, - status: .pending, - location: location, - submitTime: WildPhotographerReportMockStore.submitTimeFormatter.string(from: Date()), - description: trimmedDescription, - wechatID: parsedContact.wechat, - phoneNumber: parsedContact.phone, - imageCount: imageCount, - videoCount: videoCount, - handlerInfo: nil - ) - reports.insert(record, at: 0) - notifyStateChange() - return record - } - - /// 读取指定举报的补充证据列表。 - func supplementaryEvidences(for reportID: String) -> [WildReportSupplementaryEvidence] { - supplementaryEvidenceMap[reportID] ?? [] - } - - /// 追加一条补充证据记录。 - func appendSupplementaryEvidence( - reportID: String, - text: String, - imageCount: Int, - videoCount: Int - ) { - let item = WildReportSupplementaryEvidence( - id: nextSupplementID(), - submitTime: WildPhotographerReportMockStore.submitTimeFormatter.string(from: Date()), - text: text.trimmingCharacters(in: .whitespacesAndNewlines), - imageCount: imageCount, - videoCount: videoCount - ) - var items = supplementaryEvidenceMap[reportID] ?? [] - items.insert(item, at: 0) - supplementaryEvidenceMap[reportID] = items - notifyStateChange() - } - - /// 重置为种子 Mock 数据。 - func resetDemoData(notify: Bool = true) { - reports = WildPhotographerReportMockStore.seedReports - riskPoints = WildPhotographerReportMockStore.seedRiskPoints - supplementaryEvidenceMap = WildPhotographerReportMockStore.seedSupplementaryEvidences - nextSequence = WildPhotographerReportMockStore.maxSequence(from: WildPhotographerReportMockStore.seedReports) + 1 - nextSupplementSequence = 100 - if notify { notifyStateChange() } - } - - private func nextSupplementID() -> String { - defer { nextSupplementSequence += 1 } - return String(format: "SE%03d", nextSupplementSequence) - } - - private func nextReportID() -> String { - let datePart = WildPhotographerReportMockStore.reportDateFormatter.string(from: Date()) - defer { nextSequence += 1 } - return String(format: "JB\(datePart)%03d", nextSequence) - } - private func notifyStateChange() { onStateChange?() } @@ -153,8 +110,8 @@ final class WildPhotographerReportSubmitViewModel { private(set) var images: [WildReportAttachment] private(set) var videos: [WildReportAttachment] private(set) var paymentImages: [WildReportAttachment] = [] - private(set) var reportTypes: [WildReportType] = WildReportType.fallbackOptions - private(set) var selectedReportType: WildReportType = .defaultSelected + private(set) var reportTypes: [WildReportType] = [] + private(set) var selectedReportType: WildReportType? private(set) var description = "" private(set) var contact = "" private(set) var reportLocation = "" @@ -209,7 +166,7 @@ final class WildPhotographerReportSubmitViewModel { refreshCurrentLocation() } - /// 从服务端加载举报类型,失败时回退到本地兜底选项。 + /// 从服务端加载举报类型。 func loadReportTypes(api: any WildPhotographerReportServing) async { guard !isLoadingReportTypes else { return } isLoadingReportTypes = true @@ -222,22 +179,19 @@ final class WildPhotographerReportSubmitViewModel { do { let response = try await api.reportTypes() let loadedTypes = uniqueReportTypes(response.list) - reportTypes = loadedTypes.isEmpty ? WildReportType.fallbackOptions : loadedTypes - if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) { + reportTypes = loadedTypes + if let currentSelectedType = selectedReportType, + let updatedType = reportTypes.first(where: { $0.value == currentSelectedType.value }) { selectedReportType = updatedType } else { - selectedReportType = reportTypes.first ?? .defaultSelected + selectedReportType = reportTypes.first } } catch is CancellationError { return } catch { - reportTypes = WildReportType.fallbackOptions - if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) { - selectedReportType = updatedType - } else { - selectedReportType = .defaultSelected - } - onShowMessage?("举报类型获取失败,已使用默认类型") + reportTypes = [] + selectedReportType = nil + onShowMessage?(error.localizedDescription) } } @@ -270,11 +224,6 @@ final class WildPhotographerReportSubmitViewModel { notifyStateChange() } - /// 添加演示用现场图片。 - func addImage() { - addImages([WildReportAttachment(kind: .image, title: "现场图片\(images.count + 1)")]) - } - /// 添加本地现场视频。 func addVideos(_ attachments: [WildReportAttachment]) { let videoRemaining = max(0, 3 - videos.count) @@ -288,11 +237,6 @@ final class WildPhotographerReportSubmitViewModel { notifyStateChange() } - /// 添加演示用现场视频。 - func addVideo() { - addVideos([WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)")]) - } - /// 添加本地支付截图。 func addPaymentImages(_ attachments: [WildReportAttachment]) { guard paymentImages.count < 3 else { @@ -303,11 +247,6 @@ final class WildPhotographerReportSubmitViewModel { notifyStateChange() } - /// 添加演示用支付截图。 - func addPaymentImage() { - addPaymentImages([WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)")]) - } - /// 删除指定附件。 func removeAttachment(_ attachment: WildReportAttachment) { images.removeAll { $0.id == attachment.id } @@ -329,11 +268,7 @@ final class WildPhotographerReportSubmitViewModel { let snapshot = try await locationProvider.requestSnapshot() applyResolvedLocation(snapshot) } catch { - applyResolvedLocation(HomeLocationSnapshot( - latitude: WildPhotographerReportMockStore.nalatiCenter.latitude, - longitude: WildPhotographerReportMockStore.nalatiCenter.longitude, - address: WildPhotographerReportMockStore.fallbackLocation - )) + applyLocationFailure(error.localizedDescription) } } } @@ -389,14 +324,7 @@ final class WildPhotographerReportSubmitViewModel { paymentScreenshots: paymentRequests )) - let record = homeViewModel.submitReport( - reportType: selectedReportType, - description: description, - location: reportLocation, - contact: contact.isEmpty ? nil : contact, - imageCount: images.count, - videoCount: videos.count - ) + let record = buildSubmittedRecord() let result = WildReportSubmitResult( record: record, imageCount: images.count, @@ -412,36 +340,13 @@ final class WildPhotographerReportSubmitViewModel { } } - /// 校验表单并写入本地 Mock 记录。 - func submitReport() { - validationMessage = nil - guard validateBeforeSubmit(scenicId: 1) else { return } - - let record = homeViewModel.submitReport( - reportType: selectedReportType, - description: description, - location: reportLocation, - contact: contact.isEmpty ? nil : contact, - imageCount: images.count, - videoCount: videos.count - ) - let result = WildReportSubmitResult( - record: record, - imageCount: images.count, - videoCount: videos.count - ) - submitResult = result - notifyStateChange() - onSubmitSuccess?(result) - } - /// 重置表单到初始状态。 func resetForm() { images = [] videos = [] paymentImages = [] - reportTypes = WildReportType.fallbackOptions - selectedReportType = .defaultSelected + reportTypes = [] + selectedReportType = nil description = "" contact = "" reportLocation = "" @@ -456,14 +361,6 @@ final class WildPhotographerReportSubmitViewModel { notifyStateChange() } - func applyResolvedLocation(_ location: String) { - applyResolvedLocation(HomeLocationSnapshot( - latitude: WildPhotographerReportMockStore.nalatiCenter.latitude, - longitude: WildPhotographerReportMockStore.nalatiCenter.longitude, - address: location - )) - } - func applyResolvedLocation(_ snapshot: HomeLocationSnapshot) { let address = snapshot.address.isEmpty ? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude) @@ -479,6 +376,15 @@ final class WildPhotographerReportSubmitViewModel { notifyStateChange() } + private func applyLocationFailure(_ message: String) { + reportLocation = "" + locationSnapshot = nil + locationConfirmed = false + isUpdatingLocation = false + notifyStateChange() + onShowMessage?(message) + } + private func setValidationMessage(_ message: String) { validationMessage = message notifyStateChange() @@ -503,6 +409,10 @@ final class WildPhotographerReportSubmitViewModel { setValidationMessage("请先选择景区后再提交举报。") return false } + guard selectedReportType != nil else { + setValidationMessage("请先选择举报类型。") + return false + } guard !images.isEmpty || !videos.isEmpty else { setValidationMessage("请至少上传一项现场证据。") return false @@ -527,6 +437,10 @@ final class WildPhotographerReportSubmitViewModel { setValidationMessage("请先更新举报位置后再提交举报。") return false } + guard locationSnapshot != nil else { + setValidationMessage("缺少定位坐标,请重新更新举报位置。") + return false + } return true } @@ -572,15 +486,11 @@ final class WildPhotographerReportSubmitViewModel { let locationParts = WildReportLocationParser.split(reportLocation) let trimmedScenicName = scenicName.trimmingCharacters(in: .whitespacesAndNewlines) let locationName = !trimmedScenicName.isEmpty ? trimmedScenicName : locationParts.scenic - let snapshot = locationSnapshot ?? HomeLocationSnapshot( - latitude: WildPhotographerReportMockStore.nalatiCenter.latitude, - longitude: WildPhotographerReportMockStore.nalatiCenter.longitude, - address: reportLocation - ) + let snapshot = locationSnapshot ?? HomeLocationSnapshot(latitude: 0, longitude: 0, address: reportLocation) let trimmedContact = contact.trimmingCharacters(in: .whitespacesAndNewlines) return WildReportSubmitRequest( scenicId: scenicId, - reportType: selectedReportType.value, + reportType: selectedReportType?.value ?? 0, desc: description.trimmingCharacters(in: .whitespacesAndNewlines), storeUserId: nil, contactPhone: trimmedContact.isEmpty ? nil : trimmedContact, @@ -592,6 +502,25 @@ final class WildPhotographerReportSubmitViewModel { paymentScreenshots: paymentScreenshots ) } + + private func buildSubmittedRecord() -> WildReportRecord { + let parsedContact = WildReportContactParser.parse(contact) + return WildReportRecord( + serverID: nil, + id: "", + title: "摄影师举报", + reportType: selectedReportType ?? WildReportType(value: 0, label: ""), + status: .pending, + location: reportLocation, + submitTime: WildReportDateFormatters.displayDateTime.string(from: Date()), + description: description.trimmingCharacters(in: .whitespacesAndNewlines), + wechatID: parsedContact.wechat ?? "", + phoneNumber: parsedContact.phone ?? "", + imageCount: images.count, + videoCount: videos.count, + handlerInfo: nil + ) + } } /// 举报成功页 ViewModel。 @@ -642,6 +571,17 @@ final class WildPhotographerReportDetailViewModel { let detail = WildReportLocationParser.split(record.location).detail return detail.contains("附近") ? detail : "\(detail)附近" } + var mapCoordinate: CLLocationCoordinate2D? { + guard let latitudeText = detail?.latitude, + let longitudeText = detail?.longitude, + let latitude = Double(latitudeText), + let longitude = Double(longitudeText) else { + return nil + } + let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + guard CLLocationCoordinate2DIsValid(coordinate), latitude != 0 || longitude != 0 else { return nil } + return coordinate + } var displayStatusText: String { detail?.displayStatusText ?? record.status.rawValue } var descriptionText: String { record.description } @@ -651,7 +591,7 @@ final class WildPhotographerReportDetailViewModel { if let detail { return detail.supplementRecords } - return homeViewModel?.supplementaryEvidences(for: record.id) ?? [] + return [] } var reporterMediaItems: [WildReportReporterMediaItem] { if let detail { @@ -718,7 +658,7 @@ final class WildPhotographerReportDetailViewModel { /// 展示媒体预览提示。 func showMediaPreview(kind: String) { - showActionMessage("演示环境暂不支持预览\(kind),可在提交页查看上传内容。") + showActionMessage("暂不支持预览\(kind)") } /// 展示位置预览提示。 @@ -738,30 +678,14 @@ final class WildPhotographerReportDetailViewModel { } private static func buildProgressSteps(for record: WildReportRecord) -> [WildReportProgressStep] { - let baseDate = submitDate(from: record.submitTime) ?? Date() - let expectedFeedback = formatDate(baseDate.addingTimeInterval(8 * 3_600)) - - switch record.status { - case .pending, .processing: - return [ - makeStep(id: "1", title: "已提交", date: baseDate, state: .completed), - makeStep(id: "2", title: "已派工作人员前往", date: baseDate.addingTimeInterval(22 * 60), state: .completed), - makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .current), - makeStep(id: "4", title: "待结果反馈", date: nil, state: .pending, pendingText: "预计 \(expectedFeedback) 前") - ] - case .processed, .rejected: - return [ - makeStep(id: "1", title: "已提交", date: baseDate, state: .completed), - makeStep(id: "2", title: "已派工作人员前往", date: baseDate.addingTimeInterval(22 * 60), state: .completed), - makeStep(id: "3", title: "现场核查中", date: baseDate.addingTimeInterval(57 * 60), state: .completed), - makeStep( - id: "4", - title: record.status == .rejected ? "已驳回举报" : "已反馈核查结果", - date: baseDate.addingTimeInterval(3 * 3_600), - state: .completed - ) - ] - } + [ + WildReportProgressStep( + id: "current-status", + title: record.status.rawValue, + timeText: record.submitTime, + state: record.status == .pending || record.status == .processing ? .current : .completed + ) + ] } private static func buildProgressSteps( @@ -780,29 +704,6 @@ final class WildPhotographerReportDetailViewModel { } } - private static func makeStep( - id: String, - title: String, - date: Date?, - state: WildReportProgressStepState, - pendingText: String? = nil - ) -> WildReportProgressStep { - WildReportProgressStep( - id: id, - title: title, - timeText: pendingText ?? (date.map(formatDate) ?? ""), - state: state - ) - } - - private static func submitDate(from text: String) -> Date? { - WildPhotographerReportMockStore.submitTimeFormatter.date(from: text) - } - - private static func formatDate(_ date: Date) -> String { - WildPhotographerReportMockStore.submitTimeFormatter.string(from: date) - } - private func notifyStateChange() { onStateChange?() } @@ -855,13 +756,6 @@ final class WildPhotographerReportListViewModel { await reload(api: api, scenicId: scenicId) } - /// 保留本地筛选切换能力,供旧单元测试和 Mock 场景使用。 - func selectFilter(_ filter: WildReportListFilter) { - selectedFilter = filter - reports = filterLocalReports(homeViewModel.reports) - notifyStateChange() - } - /// 滚动到底部时加载下一页。 func loadMoreIfNeeded(api: any WildPhotographerReportServing, scenicId: Int?) async { guard hasMore, !isLoading, !isLoadingMore else { return } @@ -935,15 +829,6 @@ final class WildPhotographerReportListViewModel { ) } - private func filterLocalReports(_ localReports: [WildReportRecord]) -> [WildReportRecord] { - switch selectedFilter { - case .all: return localReports - case .pending: return localReports.filter { $0.status == .pending } - case .processing: return localReports.filter { $0.status == .processing } - case .processed: return localReports.filter { $0.status == .processed } - } - } - private func notifyStateChange() { onStateChange?() } @@ -990,11 +875,6 @@ final class WildReportSupplementEvidenceViewModel { notifyStateChange() } - /// 添加演示用补充图片。 - func addImage() { - addImages([WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)")]) - } - /// 添加本地补充视频。 func addVideos(_ attachments: [WildReportAttachment]) { let videoRemaining = max(0, 3 - videos.count) @@ -1008,11 +888,6 @@ final class WildReportSupplementEvidenceViewModel { notifyStateChange() } - /// 添加演示用补充视频。 - func addVideo() { - addVideos([WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)")]) - } - /// 删除指定附件。 func removeAttachment(_ attachment: WildReportAttachment) { images.removeAll { $0.id == attachment.id } @@ -1055,12 +930,6 @@ final class WildReportSupplementEvidenceViewModel { evidences: evidenceRequests.isEmpty ? nil : evidenceRequests )) - homeViewModel.appendSupplementaryEvidence( - reportID: reportID, - text: trimmedText, - imageCount: images.count, - videoCount: videos.count - ) didSubmit = true notifyStateChange() onSubmitSuccess?() @@ -1141,7 +1010,8 @@ final class WildReportSupplementEvidenceViewModel { /// 附近风险地图 ViewModel。 final class WildReportRiskMapViewModel { - private let fallbackMarkers: [WildReportMapMarker] + private static let riskMapLocationAccuracy = kCLLocationAccuracyHundredMeters + private var loadedDetailMarkerIDs: Set = [] private(set) var region: MKCoordinateRegion @@ -1159,14 +1029,12 @@ final class WildReportRiskMapViewModel { var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? - init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) { - let markers = WildPhotographerReportMockStore.buildMarkers(record: record, riskPoints: riskPoints) - self.fallbackMarkers = markers - self.markers = markers - self.region = WildPhotographerReportMockStore.region(for: markers) + init() { + self.markers = [] + self.region = Self.region(for: []) let scenicName = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines) - self.scenicAreaName = scenicName.isEmpty ? "那拉提景区" : scenicName - self.selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id + self.scenicAreaName = scenicName.isEmpty ? "当前景区" : scenicName + self.selectedMarkerID = nil } var selectedMarker: WildReportMapMarker? { @@ -1272,7 +1140,7 @@ final class WildReportRiskMapViewModel { /// 生成图片预览提示。 func showImagePreview(_ url: String) { _ = url - showToast("演示环境暂不支持预览图片") + showToast("暂不支持预览图片") } private func showToast(_ message: String) { @@ -1295,16 +1163,37 @@ final class WildReportRiskMapViewModel { return } + let coordinate = await requestRiskMapCoordinate(locationProvider: locationProvider) + currentCoordinate = coordinate + await requestRiskMap( + api: api, + scenicId: resolvedScenicId, + scenicName: scenicName, + coordinate: coordinate, + selectCurrentLocation: selectCurrentLocation, + showsLocationRefreshToast: coordinate != nil + ) + } + + private func requestRiskMapCoordinate(locationProvider: any LocationProviding) async -> CLLocationCoordinate2D? { + try? await locationProvider.requestCoordinate(desiredAccuracy: Self.riskMapLocationAccuracy) + } + + private func requestRiskMap( + api: any WildPhotographerReportServing, + scenicId: Int, + scenicName: String?, + coordinate: CLLocationCoordinate2D?, + selectCurrentLocation: Bool, + showsLocationRefreshToast: Bool + ) async { isLoading = true errorMessage = nil notifyStateChange() - let coordinate = try? await locationProvider.requestCoordinate() - currentCoordinate = coordinate - do { let response = try await api.riskMap(WildReportRiskMapRequest( - scenicId: resolvedScenicId, + scenicId: scenicId, latitude: coordinate?.latitude, longitude: coordinate?.longitude )) @@ -1315,25 +1204,17 @@ final class WildReportRiskMapViewModel { greenMarkerTip = response.greenMarkerTip legendItems = response.legend markers = buildMarkers(response: response, currentCoordinate: coordinate) - if markers.isEmpty { - markers = fallbackMarkers - } - region = WildPhotographerReportMockStore.region(for: markers) + region = Self.region(for: markers) if selectCurrentLocation, let current = markers.first(where: { $0.kind == .selfLocation }) { selectedMarkerID = current.id } else if selectedMarker == nil { selectedMarkerID = markers.first?.id } - if coordinate != nil { + if showsLocationRefreshToast { showToast("已更新附近风险点位") } } catch { errorMessage = error.localizedDescription - if markers.isEmpty { - markers = fallbackMarkers - region = WildPhotographerReportMockStore.region(for: markers) - selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id ?? markers.first?.id - } showToast(error.localizedDescription) } @@ -1538,7 +1419,7 @@ final class WildReportRiskMapViewModel { case .yellow: return "处理中线索" case .red, .unknown: - return dto.reportTypeText.nonEmpty ?? "疑似打野" + return dto.reportTypeText.nonEmpty ?? "异常线索" case .blue: return "当前位置" } @@ -1563,6 +1444,32 @@ final class WildReportRiskMapViewModel { return String(format: "%.1f公里", meters / 1000) } + private static func region(for markers: [WildReportMapMarker]) -> MKCoordinateRegion { + guard !markers.isEmpty else { + return MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 0, longitude: 0), + span: MKCoordinateSpan(latitudeDelta: 0.08, longitudeDelta: 0.08) + ) + } + let latitudes = markers.map(\.coordinate.latitude) + let longitudes = markers.map(\.coordinate.longitude) + let minLat = latitudes.min() ?? markers[0].coordinate.latitude + let maxLat = latitudes.max() ?? markers[0].coordinate.latitude + let minLon = longitudes.min() ?? markers[0].coordinate.longitude + let maxLon = longitudes.max() ?? markers[0].coordinate.longitude + let center = CLLocationCoordinate2D( + latitude: (minLat + maxLat) / 2, + longitude: (minLon + maxLon) / 2 + ) + return MKCoordinateRegion( + center: center, + span: MKCoordinateSpan( + latitudeDelta: max(0.018, (maxLat - minLat) * 1.6), + longitudeDelta: max(0.018, (maxLon - minLon) * 1.6) + ) + ) + } + private static func maskPhone(_ phone: String) -> String { guard phone.count == 11, phone.allSatisfy(\.isNumber) else { return phone } return "\(phone.prefix(3))****\(phone.suffix(4))" diff --git a/suixinkan/Features/WildPhotographerReport/举报摄影师接口.md b/suixinkan/Features/WildPhotographerReport/举报摄影师接口.md index 91a2705..936be80 100644 --- a/suixinkan/Features/WildPhotographerReport/举报摄影师接口.md +++ b/suixinkan/Features/WildPhotographerReport/举报摄影师接口.md @@ -1,6 +1,6 @@ # 举报摄影师接口文档 -本文档记录 iOS `WildPhotographerReport` 模块当前使用的后端接口,以及仍处于 Mock 阶段的业务点。 +本文档记录 iOS `WildPhotographerReport` 模块当前使用的后端接口。 ## 基础约定 @@ -9,7 +9,59 @@ - 网络入口:`NetworkServices.shared.wildPhotographerReportAPI` - 后端成功码:`100000` - 响应包裹:工程统一 `APIEnvelope` -- 当前状态:“举报类型”、“提交举报”、“我的举报列表”、“举报详情”和“补充证据提交”已接接口;风险地图仍使用 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` 返回;提交成功页只展示本次用户提交快照,不伪造服务端举报编号,也不写入“我的举报”本地列表。 diff --git a/suixinkan/UI/Common/MediaPreviewItem.swift b/suixinkan/UI/Common/MediaPreviewItem.swift new file mode 100644 index 0000000..b812deb --- /dev/null +++ b/suixinkan/UI/Common/MediaPreviewItem.swift @@ -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 + } +} diff --git a/suixinkan/UI/Common/MediaPreviewViewController.swift b/suixinkan/UI/Common/MediaPreviewViewController.swift new file mode 100644 index 0000000..38c2065 --- /dev/null +++ b/suixinkan/UI/Common/MediaPreviewViewController.swift @@ -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! + + 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( + 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() + 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") + } +} diff --git a/suixinkan/UI/CooperationOrder/ImagePreviewViewController.swift b/suixinkan/UI/CooperationOrder/ImagePreviewViewController.swift index fb244c2..d481c22 100644 --- a/suixinkan/UI/CooperationOrder/ImagePreviewViewController.swift +++ b/suixinkan/UI/CooperationOrder/ImagePreviewViewController.swift @@ -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 - } } diff --git a/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift b/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift new file mode 100644 index 0000000..f039b7b --- /dev/null +++ b/suixinkan/UI/MaterialManagement/MaterialDetailViewController.swift @@ -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") + } +} diff --git a/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift new file mode 100644 index 0000000..b0bfbab --- /dev/null +++ b/suixinkan/UI/MaterialManagement/MaterialFormViewController.swift @@ -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)))%" + } +} diff --git a/suixinkan/UI/MaterialManagement/MaterialListViewController.swift b/suixinkan/UI/MaterialManagement/MaterialListViewController.swift new file mode 100644 index 0000000..b17995d --- /dev/null +++ b/suixinkan/UI/MaterialManagement/MaterialListViewController.swift @@ -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! + 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(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() + 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) + } +} diff --git a/suixinkan/UI/Profile/ProfileViewController.swift b/suixinkan/UI/Profile/ProfileViewController.swift index 02577ce..8c12209 100644 --- a/suixinkan/UI/Profile/ProfileViewController.swift +++ b/suixinkan/UI/Profile/ProfileViewController.swift @@ -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)) diff --git a/suixinkan/UI/Setting/AgreementWebViewController.swift b/suixinkan/UI/Setting/AgreementWebViewController.swift new file mode 100644 index 0000000..1ac7dea --- /dev/null +++ b/suixinkan/UI/Setting/AgreementWebViewController.swift @@ -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) + } +} diff --git a/suixinkan/UI/Setting/SettingViewController.swift b/suixinkan/UI/Setting/SettingViewController.swift new file mode 100644 index 0000000..58c9290 --- /dev/null +++ b/suixinkan/UI/Setting/SettingViewController.swift @@ -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) + } + } +} diff --git a/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift b/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift index 4c08f88..73e1c5f 100644 --- a/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift +++ b/suixinkan/UI/WildPhotographerReport/Views/WildReportCommonViews.swift @@ -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() diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift index 30056b1..498723d 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportHomeViewController.swift @@ -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)) } diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift index 3cb2445..e1d08c4 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportListViewController.swift @@ -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)) - } } /// 举报详情页处理进度时间线单行。 diff --git a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift index e3760e7..2ed1dfb 100644 --- a/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildPhotographerReportSubmitViewController.swift @@ -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 diff --git a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift index cfd28f1..51da986 100644 --- a/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildReportRiskMapViewController.swift @@ -25,18 +25,16 @@ final class WildReportRiskMapViewController: BaseViewController { 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 +59,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 = "附近风险地图" } @@ -184,6 +189,7 @@ final class WildReportRiskMapViewController: BaseViewController { @MainActor private func refreshContent(animated: Bool) { + setRiskMapLoadingVisible(viewModel.isLoading) scenicNameLabel.text = viewModel.scenicAreaName syncMapAnnotations() mapView.setRegion(viewModel.region, animated: animated) @@ -192,6 +198,13 @@ final class WildReportRiskMapViewController: BaseViewController { rebuildPanel() } + @MainActor + private func setRiskMapLoadingVisible(_ isVisible: Bool) { + guard isShowingRiskMapLoading != isVisible else { return } + isShowingRiskMapLoading = isVisible + isVisible ? showLoading() : hideLoading() + } + private func syncMapAnnotations() { let oldAnnotations = mapView.annotations.compactMap { $0 as? WildReportMapAnnotation } mapView.removeAnnotations(oldAnnotations) @@ -234,9 +247,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)) } @@ -401,15 +411,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 +465,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() { @@ -711,46 +712,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) - } -} diff --git a/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift b/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift index 675d4af..1a93e14 100644 --- a/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift +++ b/suixinkan/UI/WildPhotographerReport/WildReportSupplementEvidenceViewController.swift @@ -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 diff --git a/suixinkanTests/MaterialManagementAPITests.swift b/suixinkanTests/MaterialManagementAPITests.swift new file mode 100644 index 0000000..7366ef3 --- /dev/null +++ b/suixinkanTests/MaterialManagementAPITests.swift @@ -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]) + } +} diff --git a/suixinkanTests/MaterialManagementViewModelTests.swift b/suixinkanTests/MaterialManagementViewModelTests.swift new file mode 100644 index 0000000..8b3005a --- /dev/null +++ b/suixinkanTests/MaterialManagementViewModelTests.swift @@ -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)) +} diff --git a/suixinkanTests/MediaPreviewModelsTests.swift b/suixinkanTests/MediaPreviewModelsTests.swift new file mode 100644 index 0000000..9a2f809 --- /dev/null +++ b/suixinkanTests/MediaPreviewModelsTests.swift @@ -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)) + } +} diff --git a/suixinkanTests/MockLocationProvider.swift b/suixinkanTests/MockLocationProvider.swift index bc4e7b4..1fdd3d1 100644 --- a/suixinkanTests/MockLocationProvider.swift +++ b/suixinkanTests/MockLocationProvider.swift @@ -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 } diff --git a/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift b/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift index 7940e6d..8cf0b2c 100644 --- a/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift +++ b/suixinkanTests/ProfileSpaceSettingsViewModelTests.swift @@ -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 { diff --git a/suixinkanTests/SampleManagementAPITests.swift b/suixinkanTests/SampleManagementAPITests.swift index df7770a..79cd956 100644 --- a/suixinkanTests/SampleManagementAPITests.swift +++ b/suixinkanTests/SampleManagementAPITests.swift @@ -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("{}") diff --git a/suixinkanTests/SettingViewModelTests.swift b/suixinkanTests/SettingViewModelTests.swift new file mode 100644 index 0000000..402a865 --- /dev/null +++ b/suixinkanTests/SettingViewModelTests.swift @@ -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") + } +} diff --git a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift index e4e1004..3a4227a 100644 --- a/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift +++ b/suixinkanTests/WildPhotographerReport/WildPhotographerReportTests.swift @@ -8,36 +8,8 @@ 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 +40,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 +280,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,13 +332,13 @@ 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"]) @@ -428,10 +469,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 +482,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 +547,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 +557,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 +572,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 +607,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 +618,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 +713,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,10 +799,7 @@ 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) } @@ -776,23 +815,23 @@ final class WildPhotographerReportTests: XCTestCase { } 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()] @@ -827,10 +863,7 @@ final class WildPhotographerReportTests: XCTestCase { } func testRiskMapViewModelLoadsMarkerDetailAndUpdatesSelectedClue() async { - let viewModel = WildReportRiskMapViewModel( - record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(), - riskPoints: [] - ) + let viewModel = WildReportRiskMapViewModel() let api = MockWildPhotographerReportAPI() api.riskMapResponses = [makeRiskMapResponse()] api.markerDetailResponses = [ @@ -867,10 +900,7 @@ final class WildPhotographerReportTests: XCTestCase { } func testRiskMapViewModelMarkerDetailFailureKeepsSummary() async { - let viewModel = WildReportRiskMapViewModel( - record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(), - riskPoints: [] - ) + let viewModel = WildReportRiskMapViewModel() let api = MockWildPhotographerReportAPI() api.riskMapResponses = [makeRiskMapResponse()] api.markerDetailError = URLError(.badServerResponse) @@ -946,6 +976,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 +1000,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 +1055,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 +1084,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)