Compare commits
2 Commits
9b92d81902
...
2970f1514b
| Author | SHA1 | Date | |
|---|---|---|---|
| 2970f1514b | |||
| 42aca73588 |
@ -22,7 +22,9 @@ final class NetworkServices {
|
|||||||
let walletAPI: WalletAPI
|
let walletAPI: WalletAPI
|
||||||
let scenicQueueAPI: ScenicQueueAPI
|
let scenicQueueAPI: ScenicQueueAPI
|
||||||
let travelAlbumAPI: TravelAlbumAPI
|
let travelAlbumAPI: TravelAlbumAPI
|
||||||
|
let settingAPI: SettingAPI
|
||||||
let sampleManagementAPI: SampleManagementAPI
|
let sampleManagementAPI: SampleManagementAPI
|
||||||
|
let materialManagementAPI: MaterialManagementAPI
|
||||||
let liveAPI: LiveAPI
|
let liveAPI: LiveAPI
|
||||||
let wildPhotographerReportAPI: WildPhotographerReportAPI
|
let wildPhotographerReportAPI: WildPhotographerReportAPI
|
||||||
let uploadAPI: UploadAPI
|
let uploadAPI: UploadAPI
|
||||||
@ -42,7 +44,9 @@ final class NetworkServices {
|
|||||||
walletAPI = WalletAPI(client: client)
|
walletAPI = WalletAPI(client: client)
|
||||||
scenicQueueAPI = ScenicQueueAPI(client: client)
|
scenicQueueAPI = ScenicQueueAPI(client: client)
|
||||||
travelAlbumAPI = TravelAlbumAPI(client: client)
|
travelAlbumAPI = TravelAlbumAPI(client: client)
|
||||||
|
settingAPI = SettingAPI(client: client)
|
||||||
sampleManagementAPI = SampleManagementAPI(client: client)
|
sampleManagementAPI = SampleManagementAPI(client: client)
|
||||||
|
materialManagementAPI = MaterialManagementAPI(client: client)
|
||||||
liveAPI = LiveAPI(client: client)
|
liveAPI = LiveAPI(client: client)
|
||||||
wildPhotographerReportAPI = WildPhotographerReportAPI(client: client)
|
wildPhotographerReportAPI = WildPhotographerReportAPI(client: client)
|
||||||
uploadAPI = UploadAPI(client: client)
|
uploadAPI = UploadAPI(client: client)
|
||||||
@ -67,7 +71,9 @@ final class NetworkServices {
|
|||||||
walletAPI = WalletAPI(client: apiClient)
|
walletAPI = WalletAPI(client: apiClient)
|
||||||
scenicQueueAPI = ScenicQueueAPI(client: apiClient)
|
scenicQueueAPI = ScenicQueueAPI(client: apiClient)
|
||||||
travelAlbumAPI = TravelAlbumAPI(client: apiClient)
|
travelAlbumAPI = TravelAlbumAPI(client: apiClient)
|
||||||
|
settingAPI = SettingAPI(client: apiClient)
|
||||||
sampleManagementAPI = SampleManagementAPI(client: apiClient)
|
sampleManagementAPI = SampleManagementAPI(client: apiClient)
|
||||||
|
materialManagementAPI = MaterialManagementAPI(client: apiClient)
|
||||||
liveAPI = LiveAPI(client: apiClient)
|
liveAPI = LiveAPI(client: apiClient)
|
||||||
wildPhotographerReportAPI = WildPhotographerReportAPI(client: apiClient)
|
wildPhotographerReportAPI = WildPhotographerReportAPI(client: apiClient)
|
||||||
uploadAPI = UploadAPI(client: apiClient)
|
uploadAPI = UploadAPI(client: apiClient)
|
||||||
|
|||||||
@ -137,10 +137,17 @@ class BaseViewController: UIViewController {
|
|||||||
topMostPresentedViewController().present(viewController, animated: animated, completion: completion)
|
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) {
|
func presentImagePreview(imageURLs: [String], startIndex: Int) {
|
||||||
guard !imageURLs.isEmpty else { return }
|
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
|
// MARK: - Notifications
|
||||||
|
|||||||
@ -7,6 +7,11 @@ import Foundation
|
|||||||
|
|
||||||
/// App 协议与隐私政策链接,对齐 Android `AgreementRoutes`。
|
/// App 协议与隐私政策链接,对齐 Android `AgreementRoutes`。
|
||||||
enum AppAgreementURLs {
|
enum AppAgreementURLs {
|
||||||
|
/// 关于我们 H5 地址。
|
||||||
|
nonisolated static var aboutUs: URL {
|
||||||
|
APIEnvironment.current.baseURL.appending(path: "h5/app/about-us")
|
||||||
|
}
|
||||||
|
|
||||||
/// 隐私政策 H5 地址。
|
/// 隐私政策 H5 地址。
|
||||||
nonisolated static var privacyPolicy: URL {
|
nonisolated static var privacyPolicy: URL {
|
||||||
APIEnvironment.current.baseURL.appending(path: "h5/app/privacy-policy")
|
APIEnvironment.current.baseURL.appending(path: "h5/app/privacy-policy")
|
||||||
@ -17,6 +22,11 @@ enum AppAgreementURLs {
|
|||||||
APIEnvironment.current.baseURL.appending(path: "h5/app/user-agreement")
|
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/")!
|
nonisolated static let amapPrivacyPolicy = URL(string: "https://lbs.amap.com/pages/privacy/")!
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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`。
|
/// 上传直播封面,对齐 Android `moduleType = live_covers`。
|
||||||
func uploadLiveCover(
|
func uploadLiveCover(
|
||||||
data: Data,
|
data: Data,
|
||||||
@ -217,6 +235,8 @@ extension OSSUploadService: OSSUploadServing {}
|
|||||||
|
|
||||||
extension OSSUploadService: SampleOSSUploading {}
|
extension OSSUploadService: SampleOSSUploading {}
|
||||||
|
|
||||||
|
extension OSSUploadService: MaterialOSSUploading {}
|
||||||
|
|
||||||
enum OSSUploadPolicy {
|
enum OSSUploadPolicy {
|
||||||
static let maxFileSize = 2_048 * 1_024 * 1_024
|
static let maxFileSize = 2_048 * 1_024 * 1_024
|
||||||
private static let allowedExtensions: Set<String> = ["mp4", "mov", "m4v", "avi", "png", "jpg", "jpeg", "heic", "heif"]
|
private static let allowedExtensions: Set<String> = ["mp4", "mov", "m4v", "avi", "png", "jpg", "jpeg", "heic", "heif"]
|
||||||
@ -256,6 +276,8 @@ enum OSSUploadPolicy {
|
|||||||
return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
case "sample":
|
case "sample":
|
||||||
return "sample/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "sample/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
|
case "material":
|
||||||
|
return "material/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
case "scenic_apply":
|
case "scenic_apply":
|
||||||
return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
case "live_covers":
|
case "live_covers":
|
||||||
|
|||||||
@ -36,7 +36,7 @@ enum HomeRouteHandler {
|
|||||||
case "photographer_stats":
|
case "photographer_stats":
|
||||||
selectTab(.statistics, from: viewController)
|
selectTab(.statistics, from: viewController)
|
||||||
case "system_settings":
|
case "system_settings":
|
||||||
selectTab(.profile, from: viewController)
|
viewController.navigationController?.pushViewController(SettingViewController(), animated: true)
|
||||||
case "space_settings":
|
case "space_settings":
|
||||||
viewController.navigationController?.pushViewController(ProfileSpaceSettingsViewController(), animated: true)
|
viewController.navigationController?.pushViewController(ProfileSpaceSettingsViewController(), animated: true)
|
||||||
case "pilot_cert":
|
case "pilot_cert":
|
||||||
@ -66,6 +66,8 @@ enum HomeRouteHandler {
|
|||||||
viewController.navigationController?.pushViewController(TravelAlbumEntryViewController(), animated: true)
|
viewController.navigationController?.pushViewController(TravelAlbumEntryViewController(), animated: true)
|
||||||
case "sample_management":
|
case "sample_management":
|
||||||
viewController.navigationController?.pushViewController(SampleListViewController(), animated: true)
|
viewController.navigationController?.pushViewController(SampleListViewController(), animated: true)
|
||||||
|
case "asset_management":
|
||||||
|
viewController.navigationController?.pushViewController(MaterialListViewController(), animated: true)
|
||||||
case "live_stream_management":
|
case "live_stream_management":
|
||||||
viewController.navigationController?.pushViewController(LiveManageViewController(), animated: true)
|
viewController.navigationController?.pushViewController(LiveManageViewController(), animated: true)
|
||||||
case "live_album":
|
case "live_album":
|
||||||
|
|||||||
@ -24,7 +24,10 @@ final class LocationProvider: NSObject, LocationProviding {
|
|||||||
try AMapBootstrap.requireConfigured()
|
try AMapBootstrap.requireConfigured()
|
||||||
try await ensureLocationPermission()
|
try await ensureLocationPermission()
|
||||||
|
|
||||||
let manager = makeLocationManager()
|
let manager = makeLocationManager(
|
||||||
|
desiredAccuracy: kCLLocationAccuracyBest,
|
||||||
|
locatingWithReGeocode: true
|
||||||
|
)
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
let started = manager.requestLocation(withReGeocode: true) { location, regeocode, error in
|
let started = manager.requestLocation(withReGeocode: true) { location, regeocode, error in
|
||||||
if let error {
|
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 AMapBootstrap.requireConfigured()
|
||||||
try await ensureLocationPermission()
|
try await ensureLocationPermission()
|
||||||
|
|
||||||
let manager = makeLocationManager()
|
let manager = makeLocationManager(
|
||||||
|
desiredAccuracy: desiredAccuracy,
|
||||||
|
locatingWithReGeocode: false
|
||||||
|
)
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
return try await withCheckedThrowingContinuation { continuation in
|
||||||
let started = manager.requestLocation(withReGeocode: false) { location, _, error in
|
let started = manager.requestLocation(withReGeocode: false) { location, _, error in
|
||||||
if let error {
|
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()
|
let manager = AMapLocationManager()
|
||||||
manager.desiredAccuracy = kCLLocationAccuracyBest
|
manager.desiredAccuracy = desiredAccuracy
|
||||||
manager.locationTimeout = 30
|
manager.locationTimeout = 30
|
||||||
manager.reGeocodeTimeout = 10
|
manager.reGeocodeTimeout = 10
|
||||||
manager.locatingWithReGeocode = true
|
manager.locatingWithReGeocode = locatingWithReGeocode
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,12 +17,19 @@ struct HomeLocationSnapshot: Sendable, Equatable {
|
|||||||
protocol LocationProviding: Sendable {
|
protocol LocationProviding: Sendable {
|
||||||
/// 请求当前坐标与地址。
|
/// 请求当前坐标与地址。
|
||||||
func requestSnapshot() async throws -> HomeLocationSnapshot
|
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
|
func reverseGeocode(latitude: Double, longitude: Double) async -> String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension LocationProviding {
|
||||||
|
/// 请求当前坐标,默认使用最高精度以保持既有业务行为。
|
||||||
|
func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
||||||
|
try await requestCoordinate(desiredAccuracy: kCLLocationAccuracyBest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 定位错误。
|
/// 定位错误。
|
||||||
enum LocationProviderError: LocalizedError, Equatable {
|
enum LocationProviderError: LocalizedError, Equatable {
|
||||||
case privacyNotAccepted
|
case privacyNotAccepted
|
||||||
|
|||||||
@ -0,0 +1,140 @@
|
|||||||
|
//
|
||||||
|
// MaterialManagementAPI.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 素材管理服务协议,抽象列表、详情、上下架、删除、标签、上传与编辑接口。
|
||||||
|
@MainActor
|
||||||
|
protocol MaterialManagementServing {
|
||||||
|
/// 拉取素材列表。
|
||||||
|
func list(status: Int, keyword: String?, page: Int, pageSize: Int) async throws -> MaterialListResponse
|
||||||
|
|
||||||
|
/// 拉取素材详情。
|
||||||
|
func detail(id: Int) async throws -> MaterialDetail
|
||||||
|
|
||||||
|
/// 设置素材上下架状态。
|
||||||
|
func setListingStatus(id: Int, listingStatus: Int) async throws
|
||||||
|
|
||||||
|
/// 删除素材。
|
||||||
|
func delete(id: Int) async throws
|
||||||
|
|
||||||
|
/// 新增素材标签。
|
||||||
|
func addTag(name: String) async throws -> Int
|
||||||
|
|
||||||
|
/// 上传素材。
|
||||||
|
func upload(_ request: MaterialUploadRequest) async throws
|
||||||
|
|
||||||
|
/// 编辑素材。
|
||||||
|
func edit(_ request: MaterialUploadRequest) async throws
|
||||||
|
|
||||||
|
/// 拉取景区全部打卡点。
|
||||||
|
func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材管理 API,封装 Android `NetworkApi` 中 `media-album type=1` 接口。
|
||||||
|
@MainActor
|
||||||
|
final class MaterialManagementAPI: MaterialManagementServing {
|
||||||
|
private let client: APIClient
|
||||||
|
|
||||||
|
/// 初始化素材管理 API。
|
||||||
|
init(client: APIClient) {
|
||||||
|
self.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取素材列表,固定 `type=1`。
|
||||||
|
func list(status: Int, keyword: String?, page: Int = 1, pageSize: Int = 10) async throws -> MaterialListResponse {
|
||||||
|
var items = [
|
||||||
|
URLQueryItem(name: "status", value: String(status)),
|
||||||
|
URLQueryItem(name: "type", value: "1"),
|
||||||
|
URLQueryItem(name: "page", value: String(max(page, 1))),
|
||||||
|
URLQueryItem(name: "page_size", value: String(max(pageSize, 1))),
|
||||||
|
]
|
||||||
|
if let keyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines), !keyword.isEmpty {
|
||||||
|
items.append(URLQueryItem(name: "keyword", value: keyword))
|
||||||
|
}
|
||||||
|
return try await client.send(
|
||||||
|
APIRequest(method: .get, path: "/api/app/media-album/list", queryItems: items)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取素材详情,固定 `type=1`。
|
||||||
|
func detail(id: Int) async throws -> MaterialDetail {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "/api/app/media-album/detail",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "id", value: String(id)),
|
||||||
|
URLQueryItem(name: "type", value: "1"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置素材上下架状态,固定 `type=1`。
|
||||||
|
func setListingStatus(id: Int, listingStatus: Int) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: "/api/app/media-album/operation",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "id", value: String(id)),
|
||||||
|
URLQueryItem(name: "listing_status", value: String(listingStatus)),
|
||||||
|
URLQueryItem(name: "type", value: "1"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除素材,固定 `type=1`。
|
||||||
|
func delete(id: Int) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: "/api/app/media-album/delete",
|
||||||
|
queryItems: [
|
||||||
|
URLQueryItem(name: "id", value: String(id)),
|
||||||
|
URLQueryItem(name: "type", value: "1"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新增素材标签。
|
||||||
|
func addTag(name: String) async throws -> Int {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .post,
|
||||||
|
path: "/api/app/media-album/add-tag",
|
||||||
|
queryItems: [URLQueryItem(name: "name", value: name)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传素材。
|
||||||
|
func upload(_ request: MaterialUploadRequest) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/app/media-album/upload", body: request)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 编辑素材。
|
||||||
|
func edit(_ request: MaterialUploadRequest) async throws {
|
||||||
|
let _: EmptyPayload = try await client.send(
|
||||||
|
APIRequest(method: .post, path: "/api/app/media-album/edit", body: request)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取景区全部打卡点。
|
||||||
|
func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(
|
||||||
|
method: .get,
|
||||||
|
path: "/api/yf-handset-app/photog/scenic-spot/list-all",
|
||||||
|
queryItems: [URLQueryItem(name: "scenic_id", value: scenicId)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,541 @@
|
|||||||
|
//
|
||||||
|
// MaterialManagementModels.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 素材管理筛选状态,对齐 Android `MaterialListViewModel` 的四档筛选。
|
||||||
|
enum MaterialFilterStatus: Int, CaseIterable, Sendable, Equatable {
|
||||||
|
case all = 0
|
||||||
|
case approved = 1
|
||||||
|
case pending = 2
|
||||||
|
case rejected = 3
|
||||||
|
|
||||||
|
/// 展示文案。
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .all: "全部"
|
||||||
|
case .approved: "已通过"
|
||||||
|
case .pending: "待审核"
|
||||||
|
case .rejected: "未通过"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 服务端 `status` 参数,Android 映射为:全部 4、已通过 1、待审核 0、未通过 2。
|
||||||
|
var apiStatus: Int {
|
||||||
|
switch self {
|
||||||
|
case .all: 4
|
||||||
|
case .approved: 1
|
||||||
|
case .pending: 0
|
||||||
|
case .rejected: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材媒体类型,对齐 Android `mediaType`:1 图片,2 视频。
|
||||||
|
enum MaterialMediaType: Int, CaseIterable, Sendable, Equatable {
|
||||||
|
case image = 1
|
||||||
|
case video = 2
|
||||||
|
|
||||||
|
/// 展示文案。
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .image: "图片"
|
||||||
|
case .video: "视频"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 最大可选数量。
|
||||||
|
var maxCount: Int {
|
||||||
|
switch self {
|
||||||
|
case .image: 9
|
||||||
|
case .video: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OSS 文件类型,Android 上传任务中视频为 1、图片为 2。
|
||||||
|
var uploadFileType: Int {
|
||||||
|
switch self {
|
||||||
|
case .image: 2
|
||||||
|
case .video: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材列表项,对齐 Android `MaterialItemEntity`。
|
||||||
|
struct MaterialItem: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
let coverUrl: String
|
||||||
|
let createdAt: String
|
||||||
|
let status: Int
|
||||||
|
let downloadCount: Int
|
||||||
|
let likesCount: Int
|
||||||
|
let collectCount: Int
|
||||||
|
let shareCount: Int
|
||||||
|
let scenicSpotName: String
|
||||||
|
let projectName: String
|
||||||
|
let listingStatus: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case name
|
||||||
|
case coverUrl = "cover_url"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case status
|
||||||
|
case downloadCount = "download_count"
|
||||||
|
case likesCount = "likes_count"
|
||||||
|
case collectCount = "collect_count"
|
||||||
|
case shareCount = "share_count"
|
||||||
|
case scenicSpotName = "scenic_spot_name"
|
||||||
|
case projectName = "project_name"
|
||||||
|
case listingStatus = "listing_status"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: Int = 0,
|
||||||
|
name: String = "",
|
||||||
|
coverUrl: String = "",
|
||||||
|
createdAt: String = "",
|
||||||
|
status: Int = 0,
|
||||||
|
downloadCount: Int = 0,
|
||||||
|
likesCount: Int = 0,
|
||||||
|
collectCount: Int = 0,
|
||||||
|
shareCount: Int = 0,
|
||||||
|
scenicSpotName: String = "",
|
||||||
|
projectName: String = "",
|
||||||
|
listingStatus: Int = 0
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.coverUrl = coverUrl
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.status = status
|
||||||
|
self.downloadCount = downloadCount
|
||||||
|
self.likesCount = likesCount
|
||||||
|
self.collectCount = collectCount
|
||||||
|
self.shareCount = shareCount
|
||||||
|
self.scenicSpotName = scenicSpotName
|
||||||
|
self.projectName = projectName
|
||||||
|
self.listingStatus = listingStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材列表响应,对齐 Android `MaterialListResponse`。
|
||||||
|
struct MaterialListResponse: Decodable, Sendable, Equatable {
|
||||||
|
let total: Int
|
||||||
|
let list: [MaterialItem]
|
||||||
|
let order: MaterialOrderInfo?
|
||||||
|
|
||||||
|
init(total: Int = 0, list: [MaterialItem] = [], order: MaterialOrderInfo? = nil) {
|
||||||
|
self.total = total
|
||||||
|
self.list = list
|
||||||
|
self.order = order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材订单统计信息,对齐 Android `MaterialOrderInfo`。
|
||||||
|
struct MaterialOrderInfo: Decodable, Sendable, Equatable, Hashable {
|
||||||
|
let totalNum: Int
|
||||||
|
let avgOrderAmount: String
|
||||||
|
let refundTotal: String
|
||||||
|
let avgChange: Int
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case totalNum = "total_num"
|
||||||
|
case avgOrderAmount = "avg_order_amount"
|
||||||
|
case refundTotal = "refund_total"
|
||||||
|
case avgChange = "avg_change"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(totalNum: Int = 0, avgOrderAmount: String = "0", refundTotal: String = "0", avgChange: Int = 0) {
|
||||||
|
self.totalNum = totalNum
|
||||||
|
self.avgOrderAmount = avgOrderAmount
|
||||||
|
self.refundTotal = refundTotal
|
||||||
|
self.avgChange = avgChange
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
totalNum = try container.decodeIfPresent(Int.self, forKey: .totalNum) ?? 0
|
||||||
|
avgOrderAmount = try container.decodeIfPresent(String.self, forKey: .avgOrderAmount) ?? "0"
|
||||||
|
refundTotal = try container.decodeIfPresent(String.self, forKey: .refundTotal) ?? "0"
|
||||||
|
avgChange = try container.decodeIfPresent(Int.self, forKey: .avgChange) ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材详情响应,对齐 Android `MaterialDetailResponse`。
|
||||||
|
struct MaterialDetail: Decodable, Sendable, Equatable {
|
||||||
|
let id: Int
|
||||||
|
let name: String?
|
||||||
|
let type: String?
|
||||||
|
let ossUrl: String?
|
||||||
|
let coverUrl: String?
|
||||||
|
let coverSize: Int?
|
||||||
|
let description: String?
|
||||||
|
let scenicSpotId: Int64?
|
||||||
|
let scenicAreaId: Int?
|
||||||
|
let materialTag: [MaterialTagItem]?
|
||||||
|
let uploaderId: Int?
|
||||||
|
let status: Int
|
||||||
|
let likesCount: Int
|
||||||
|
let downloadCount: Int
|
||||||
|
let collectCount: Int
|
||||||
|
let isRecommended: Bool
|
||||||
|
let createdAt: String?
|
||||||
|
let updatedAt: String?
|
||||||
|
let deletedAt: String?
|
||||||
|
let listingStatus: Int
|
||||||
|
let reviewNotes: String?
|
||||||
|
let reviewTime: String?
|
||||||
|
let reviewerId: Int?
|
||||||
|
let reviewer: String?
|
||||||
|
let provinceId: Int?
|
||||||
|
let cityId: Int?
|
||||||
|
let shareCount: Int
|
||||||
|
let isWeb: Int
|
||||||
|
let isMini: Int
|
||||||
|
let uploaderName: String?
|
||||||
|
let uploaderAvatar: String?
|
||||||
|
let uploaderDescription: String?
|
||||||
|
let platformCertification: Bool
|
||||||
|
let scenicCertification: Bool
|
||||||
|
let mediaList: [MaterialDetailMediaItem]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case name
|
||||||
|
case type
|
||||||
|
case ossUrl = "oss_url"
|
||||||
|
case coverUrl = "cover_url"
|
||||||
|
case coverSize = "cover_size"
|
||||||
|
case description
|
||||||
|
case scenicSpotId = "scenic_spot_id"
|
||||||
|
case scenicAreaId = "scenic_area_id"
|
||||||
|
case materialTag = "material_tag"
|
||||||
|
case uploaderId = "uploader_id"
|
||||||
|
case status
|
||||||
|
case likesCount = "likes_count"
|
||||||
|
case downloadCount = "download_count"
|
||||||
|
case collectCount = "collect_count"
|
||||||
|
case isRecommended = "is_recommended"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case updatedAt = "updated_at"
|
||||||
|
case deletedAt = "deleted_at"
|
||||||
|
case listingStatus = "listing_status"
|
||||||
|
case reviewNotes = "review_notes"
|
||||||
|
case reviewTime = "review_time"
|
||||||
|
case reviewerId = "reviewer_id"
|
||||||
|
case reviewer
|
||||||
|
case provinceId = "province_id"
|
||||||
|
case cityId = "city_id"
|
||||||
|
case shareCount = "share_count"
|
||||||
|
case isWeb = "is_web"
|
||||||
|
case isMini = "is_mini"
|
||||||
|
case uploaderName = "uploader_name"
|
||||||
|
case uploaderAvatar = "uploader_avatar"
|
||||||
|
case uploaderDescription = "uploader_description"
|
||||||
|
case platformCertification = "platform_certification"
|
||||||
|
case scenicCertification = "scenic_certification"
|
||||||
|
case mediaList = "media_list"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
|
||||||
|
name = try container.decodeIfPresent(String.self, forKey: .name)
|
||||||
|
type = try container.decodeFlexibleString(forKey: .type)
|
||||||
|
ossUrl = try container.decodeIfPresent(String.self, forKey: .ossUrl)
|
||||||
|
coverUrl = try container.decodeIfPresent(String.self, forKey: .coverUrl)
|
||||||
|
coverSize = try container.decodeIfPresent(Int.self, forKey: .coverSize)
|
||||||
|
description = try container.decodeIfPresent(String.self, forKey: .description)
|
||||||
|
scenicSpotId = try container.decodeFlexibleInt64(forKey: .scenicSpotId)
|
||||||
|
scenicAreaId = try container.decodeIfPresent(Int.self, forKey: .scenicAreaId)
|
||||||
|
materialTag = try container.decodeIfPresent([MaterialTagItem].self, forKey: .materialTag)
|
||||||
|
uploaderId = try container.decodeIfPresent(Int.self, forKey: .uploaderId)
|
||||||
|
status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0
|
||||||
|
likesCount = try container.decodeIfPresent(Int.self, forKey: .likesCount) ?? 0
|
||||||
|
downloadCount = try container.decodeIfPresent(Int.self, forKey: .downloadCount) ?? 0
|
||||||
|
collectCount = try container.decodeIfPresent(Int.self, forKey: .collectCount) ?? 0
|
||||||
|
isRecommended = try container.decodeIfPresent(Bool.self, forKey: .isRecommended) ?? false
|
||||||
|
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt)
|
||||||
|
updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt)
|
||||||
|
deletedAt = try container.decodeIfPresent(String.self, forKey: .deletedAt)
|
||||||
|
listingStatus = try container.decodeIfPresent(Int.self, forKey: .listingStatus) ?? 0
|
||||||
|
reviewNotes = try container.decodeIfPresent(String.self, forKey: .reviewNotes)
|
||||||
|
reviewTime = try container.decodeIfPresent(String.self, forKey: .reviewTime)
|
||||||
|
reviewerId = try container.decodeIfPresent(Int.self, forKey: .reviewerId)
|
||||||
|
reviewer = try container.decodeIfPresent(String.self, forKey: .reviewer)
|
||||||
|
provinceId = try container.decodeIfPresent(Int.self, forKey: .provinceId)
|
||||||
|
cityId = try container.decodeIfPresent(Int.self, forKey: .cityId)
|
||||||
|
shareCount = try container.decodeIfPresent(Int.self, forKey: .shareCount) ?? 0
|
||||||
|
isWeb = try container.decodeIfPresent(Int.self, forKey: .isWeb) ?? 0
|
||||||
|
isMini = try container.decodeIfPresent(Int.self, forKey: .isMini) ?? 0
|
||||||
|
uploaderName = try container.decodeIfPresent(String.self, forKey: .uploaderName)
|
||||||
|
uploaderAvatar = try container.decodeIfPresent(String.self, forKey: .uploaderAvatar)
|
||||||
|
uploaderDescription = try container.decodeIfPresent(String.self, forKey: .uploaderDescription)
|
||||||
|
platformCertification = try container.decodeIfPresent(Bool.self, forKey: .platformCertification) ?? false
|
||||||
|
scenicCertification = try container.decodeIfPresent(Bool.self, forKey: .scenicCertification) ?? false
|
||||||
|
mediaList = try container.decodeIfPresent([MaterialDetailMediaItem].self, forKey: .mediaList)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材标签项。
|
||||||
|
struct MaterialTagItem: Codable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材详情媒体项,对齐 Android `MaterialDetailMediaItem`。
|
||||||
|
struct MaterialDetailMediaItem: Decodable, Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
let id: Int
|
||||||
|
let originalName: String?
|
||||||
|
let paidMaterialId: Int?
|
||||||
|
let ossUrl: String?
|
||||||
|
let thumbnailUrl: String?
|
||||||
|
let type: Int
|
||||||
|
let size: Int64?
|
||||||
|
let createdAt: String?
|
||||||
|
let updatedAt: String?
|
||||||
|
let deletedAt: String?
|
||||||
|
let likesCount: Int
|
||||||
|
let downloadCount: Int
|
||||||
|
let showUrl: String?
|
||||||
|
let width: Int?
|
||||||
|
let height: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case originalName = "original_name"
|
||||||
|
case paidMaterialId = "paid_material_id"
|
||||||
|
case ossUrl = "oss_url"
|
||||||
|
case thumbnailUrl = "thumbnail_url"
|
||||||
|
case type
|
||||||
|
case size
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case updatedAt = "updated_at"
|
||||||
|
case deletedAt = "deleted_at"
|
||||||
|
case likesCount = "likes_count"
|
||||||
|
case downloadCount = "download_count"
|
||||||
|
case showUrl = "show_url"
|
||||||
|
case width
|
||||||
|
case height
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
|
||||||
|
originalName = try container.decodeIfPresent(String.self, forKey: .originalName)
|
||||||
|
paidMaterialId = try container.decodeIfPresent(Int.self, forKey: .paidMaterialId)
|
||||||
|
ossUrl = try container.decodeIfPresent(String.self, forKey: .ossUrl)
|
||||||
|
thumbnailUrl = try container.decodeIfPresent(String.self, forKey: .thumbnailUrl)
|
||||||
|
type = try container.decodeIfPresent(Int.self, forKey: .type) ?? 1
|
||||||
|
size = try container.decodeIfPresent(Int64.self, forKey: .size)
|
||||||
|
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt)
|
||||||
|
updatedAt = try container.decodeIfPresent(String.self, forKey: .updatedAt)
|
||||||
|
deletedAt = try container.decodeIfPresent(String.self, forKey: .deletedAt)
|
||||||
|
likesCount = try container.decodeIfPresent(Int.self, forKey: .likesCount) ?? 0
|
||||||
|
downloadCount = try container.decodeIfPresent(Int.self, forKey: .downloadCount) ?? 0
|
||||||
|
showUrl = try container.decodeIfPresent(String.self, forKey: .showUrl)
|
||||||
|
width = try container.decodeIfPresent(Int.self, forKey: .width)
|
||||||
|
height = try container.decodeIfPresent(Int.self, forKey: .height)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 详情轮播优先展示地址。
|
||||||
|
var displayURL: String {
|
||||||
|
(ossUrl?.trimmingCharacters(in: .whitespacesAndNewlines).sxkNonEmpty)
|
||||||
|
?? (showUrl?.trimmingCharacters(in: .whitespacesAndNewlines).sxkNonEmpty)
|
||||||
|
?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材上传与编辑请求,对齐 Android `UploadMaterialRequest` / `EditMaterialRequest`。
|
||||||
|
struct MaterialUploadRequest: Encodable, Sendable, Equatable {
|
||||||
|
let id: Int?
|
||||||
|
let name: String
|
||||||
|
let type: String
|
||||||
|
let mediaType: String
|
||||||
|
let mediaList: [MaterialUploadMediaItem]
|
||||||
|
let coverUrl: String
|
||||||
|
let coverSize: String
|
||||||
|
let scenicSpotId: Int64
|
||||||
|
let description: String
|
||||||
|
let materialTag: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case name
|
||||||
|
case type
|
||||||
|
case mediaType = "media_type"
|
||||||
|
case mediaList = "media_list"
|
||||||
|
case coverUrl = "cover_url"
|
||||||
|
case coverSize = "cover_size"
|
||||||
|
case scenicSpotId = "scenic_spot_id"
|
||||||
|
case description
|
||||||
|
case materialTag = "material_tag"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: Int? = nil,
|
||||||
|
name: String,
|
||||||
|
mediaType: MaterialMediaType,
|
||||||
|
mediaList: [MaterialUploadMediaItem],
|
||||||
|
coverUrl: String,
|
||||||
|
coverSize: String,
|
||||||
|
scenicSpotId: Int64,
|
||||||
|
materialTag: String
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.type = "1"
|
||||||
|
self.mediaType = String(mediaType.rawValue)
|
||||||
|
self.mediaList = mediaList
|
||||||
|
self.coverUrl = coverUrl
|
||||||
|
self.coverSize = coverSize
|
||||||
|
self.scenicSpotId = scenicSpotId
|
||||||
|
self.description = ""
|
||||||
|
self.materialTag = materialTag
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encodeIfPresent(id, forKey: .id)
|
||||||
|
try container.encode(name, forKey: .name)
|
||||||
|
try container.encode(type, forKey: .type)
|
||||||
|
try container.encode(mediaType, forKey: .mediaType)
|
||||||
|
try container.encode(mediaList, forKey: .mediaList)
|
||||||
|
try container.encode(coverUrl, forKey: .coverUrl)
|
||||||
|
try container.encode(coverSize, forKey: .coverSize)
|
||||||
|
try container.encode(scenicSpotId, forKey: .scenicSpotId)
|
||||||
|
try container.encode(description, forKey: .description)
|
||||||
|
try container.encode(materialTag, forKey: .materialTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材上传媒体项,对齐 Android `MediaItem`。
|
||||||
|
struct MaterialUploadMediaItem: Encodable, Sendable, Equatable {
|
||||||
|
let originalName: String
|
||||||
|
let ossUrl: String
|
||||||
|
let size: Int64
|
||||||
|
let fileWidthSize: MaterialUploadFileSize
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case originalName = "original_name"
|
||||||
|
case ossUrl = "oss_url"
|
||||||
|
case size
|
||||||
|
case fileWidthSize = "file_width_size"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材上传文件宽高。
|
||||||
|
struct MaterialUploadFileSize: Encodable, Sendable, Equatable {
|
||||||
|
let width: Int
|
||||||
|
let height: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传页本地媒体项,不依赖 UIKit,供 ViewModel 管理上传状态。
|
||||||
|
struct MaterialUploadedMediaItem: Sendable, Equatable, Hashable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let data: Data
|
||||||
|
let thumbnailData: Data?
|
||||||
|
let previewURL: String?
|
||||||
|
let fileName: String
|
||||||
|
let size: Int64
|
||||||
|
let width: Int
|
||||||
|
let height: Int
|
||||||
|
var ossUrl: String
|
||||||
|
var isUploading: Bool
|
||||||
|
var uploadProgress: Int
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
data: Data,
|
||||||
|
thumbnailData: Data? = nil,
|
||||||
|
previewURL: String? = nil,
|
||||||
|
fileName: String,
|
||||||
|
size: Int64? = nil,
|
||||||
|
width: Int = 0,
|
||||||
|
height: Int = 0,
|
||||||
|
ossUrl: String = "",
|
||||||
|
isUploading: Bool = false,
|
||||||
|
uploadProgress: Int = 0
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.data = data
|
||||||
|
self.thumbnailData = thumbnailData
|
||||||
|
self.previewURL = previewURL
|
||||||
|
self.fileName = fileName
|
||||||
|
self.size = size ?? Int64(data.count)
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.ossUrl = ossUrl
|
||||||
|
self.isUploading = isUploading
|
||||||
|
self.uploadProgress = uploadProgress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材上传进度弹窗状态。
|
||||||
|
struct MaterialUploadDialogState: Sendable, Equatable {
|
||||||
|
let title: String
|
||||||
|
let progress: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材 UI 展示格式化工具。
|
||||||
|
enum MaterialDisplayFormatter {
|
||||||
|
/// 格式化计数,超过 1000 按 Android 规则显示 k。
|
||||||
|
static func formatCount(_ count: Int) -> String {
|
||||||
|
guard count >= 1000 else { return String(count) }
|
||||||
|
let value = Double(count) / 1000.0
|
||||||
|
if value.truncatingRemainder(dividingBy: 1) == 0 {
|
||||||
|
return "\(Int(value))k"
|
||||||
|
}
|
||||||
|
return String(format: "%.1fk", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 格式化整数,增加千位分隔符。
|
||||||
|
static func formatNumber(_ number: Int) -> String {
|
||||||
|
let formatter = NumberFormatter()
|
||||||
|
formatter.numberStyle = .decimal
|
||||||
|
return formatter.string(from: NSNumber(value: number)) ?? String(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 审核状态文案。
|
||||||
|
static func reviewStatusText(_ status: Int) -> String {
|
||||||
|
switch status {
|
||||||
|
case 1: "已通过"
|
||||||
|
case 0: "待审核"
|
||||||
|
case 2: "未通过"
|
||||||
|
default: "未知"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension KeyedDecodingContainer {
|
||||||
|
func decodeFlexibleString(forKey key: Key) throws -> String? {
|
||||||
|
if let value = try decodeIfPresent(String.self, forKey: key) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if let value = try decodeIfPresent(Int.self, forKey: key) {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeFlexibleInt64(forKey key: Key) throws -> Int64? {
|
||||||
|
if let value = try decodeIfPresent(Int64.self, forKey: key) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if let value = try decodeIfPresent(Int.self, forKey: key) {
|
||||||
|
return Int64(value)
|
||||||
|
}
|
||||||
|
if let value = try decodeIfPresent(String.self, forKey: key) {
|
||||||
|
return Int64(value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var sxkNonEmpty: String? { isEmpty ? nil : self }
|
||||||
|
}
|
||||||
@ -0,0 +1,612 @@
|
|||||||
|
//
|
||||||
|
// MaterialManagementViewModels.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 素材 OSS 上传协议,便于上传和编辑 ViewModel 在测试中注入替身。
|
||||||
|
@MainActor
|
||||||
|
protocol MaterialOSSUploading {
|
||||||
|
/// 上传素材文件并返回 OSS URL。
|
||||||
|
func uploadMaterialFile(
|
||||||
|
data: Data,
|
||||||
|
fileName: String,
|
||||||
|
fileType: Int,
|
||||||
|
scenicId: Int,
|
||||||
|
onProgress: @escaping (Int) -> Void
|
||||||
|
) async throws -> String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材列表页 ViewModel,负责搜索、筛选、分页和上下架操作。
|
||||||
|
final class MaterialListViewModel {
|
||||||
|
private(set) var items: [MaterialItem] = []
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var isRefreshing = false
|
||||||
|
private(set) var canLoadMore = false
|
||||||
|
private(set) var filterStatus: MaterialFilterStatus = .all
|
||||||
|
private(set) var searchText = ""
|
||||||
|
private(set) var orderInfo = MaterialOrderInfo()
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
|
||||||
|
private var currentPage = 1
|
||||||
|
private var totalCount = 0
|
||||||
|
private let pageSize = 10
|
||||||
|
|
||||||
|
/// 首次加载素材列表。
|
||||||
|
func loadInitial(api: any MaterialManagementServing) async {
|
||||||
|
await load(reset: true, showLoading: true, api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下拉刷新素材列表。
|
||||||
|
func refresh(api: any MaterialManagementServing) async {
|
||||||
|
isRefreshing = true
|
||||||
|
notifyStateChange()
|
||||||
|
await load(reset: true, showLoading: false, api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 滚动到底部附近时加载更多。
|
||||||
|
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any MaterialManagementServing) async {
|
||||||
|
guard lastVisibleIndex >= items.count - 3 else { return }
|
||||||
|
await load(reset: false, showLoading: false, api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新搜索文本并重新加载第一页。
|
||||||
|
func updateSearchText(_ text: String, api: any MaterialManagementServing) async {
|
||||||
|
searchText = text
|
||||||
|
await load(reset: true, showLoading: false, api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择筛选状态并重新加载第一页。
|
||||||
|
func selectFilter(_ status: MaterialFilterStatus, api: any MaterialManagementServing) async {
|
||||||
|
guard filterStatus != status else { return }
|
||||||
|
filterStatus = status
|
||||||
|
notifyStateChange()
|
||||||
|
await load(reset: true, showLoading: false, api: api)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换素材上下架状态。
|
||||||
|
func toggleListing(materialId: Int, checked: Bool, api: any MaterialManagementServing) async {
|
||||||
|
guard let currentItem = items.first(where: { $0.id == materialId }) else { return }
|
||||||
|
guard currentItem.status == 1 else {
|
||||||
|
onShowMessage?("审核通过才可以操作上下架")
|
||||||
|
notifyStateChange()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetStatus = checked ? 1 : 0
|
||||||
|
guard currentItem.listingStatus != targetStatus else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await api.setListingStatus(id: materialId, listingStatus: targetStatus)
|
||||||
|
items = items.map { item in
|
||||||
|
guard item.id == materialId else { return item }
|
||||||
|
return MaterialItem(
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
coverUrl: item.coverUrl,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
status: item.status,
|
||||||
|
downloadCount: item.downloadCount,
|
||||||
|
likesCount: item.likesCount,
|
||||||
|
collectCount: item.collectCount,
|
||||||
|
shareCount: item.shareCount,
|
||||||
|
scenicSpotName: item.scenicSpotName,
|
||||||
|
projectName: item.projectName,
|
||||||
|
listingStatus: targetStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription.isEmpty ? "操作失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从列表中移除已删除素材。
|
||||||
|
func removeMaterial(id: Int) {
|
||||||
|
items.removeAll { $0.id == id }
|
||||||
|
totalCount = max(0, totalCount - 1)
|
||||||
|
canLoadMore = items.count < totalCount
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load(reset: Bool, showLoading: Bool, api: any MaterialManagementServing) async {
|
||||||
|
if reset {
|
||||||
|
currentPage = 1
|
||||||
|
canLoadMore = false
|
||||||
|
} else {
|
||||||
|
guard canLoadMore, !isLoading else { return }
|
||||||
|
currentPage += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = showLoading
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
isRefreshing = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines).sxkNonEmpty
|
||||||
|
let response = try await api.list(
|
||||||
|
status: filterStatus.apiStatus,
|
||||||
|
keyword: keyword,
|
||||||
|
page: currentPage,
|
||||||
|
pageSize: pageSize
|
||||||
|
)
|
||||||
|
totalCount = response.total
|
||||||
|
orderInfo = response.order ?? MaterialOrderInfo()
|
||||||
|
items = reset ? response.list : items + response.list
|
||||||
|
canLoadMore = items.count < totalCount
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
if !reset, currentPage > 1 {
|
||||||
|
currentPage -= 1
|
||||||
|
}
|
||||||
|
if reset {
|
||||||
|
items = []
|
||||||
|
totalCount = 0
|
||||||
|
canLoadMore = false
|
||||||
|
}
|
||||||
|
onShowMessage?(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyStateChange() {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材详情页 ViewModel,负责详情加载、删除和编辑跳转状态。
|
||||||
|
final class MaterialDetailViewModel {
|
||||||
|
private(set) var detail: MaterialDetail?
|
||||||
|
private(set) var isLoading = false
|
||||||
|
private(set) var isDeleting = false
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
var onDeleted: ((Int) -> Void)?
|
||||||
|
|
||||||
|
let materialId: Int
|
||||||
|
|
||||||
|
/// 初始化素材详情 ViewModel。
|
||||||
|
init(materialId: Int) {
|
||||||
|
self.materialId = materialId
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载素材详情。
|
||||||
|
func load(api: any MaterialManagementServing) async {
|
||||||
|
guard materialId > 0 else {
|
||||||
|
onShowMessage?("素材ID无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoading = true
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
detail = try await api.detail(id: materialId)
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription.isEmpty ? "获取素材详情失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除当前素材。
|
||||||
|
func delete(api: any MaterialManagementServing) async {
|
||||||
|
guard materialId > 0 else {
|
||||||
|
onShowMessage?("素材ID无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isDeleting = true
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isDeleting = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try await api.delete(id: materialId)
|
||||||
|
onShowMessage?("删除成功")
|
||||||
|
onDeleted?(materialId)
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription.isEmpty ? "删除失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyStateChange() {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材上传与编辑页 ViewModel,负责表单、标签、选择数据、上传进度与提交。
|
||||||
|
final class MaterialFormViewModel {
|
||||||
|
enum Mode: Equatable {
|
||||||
|
case create
|
||||||
|
case edit(id: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
private(set) var materialName = ""
|
||||||
|
private(set) var selectedScenicSpot: ScenicSpotItem?
|
||||||
|
private(set) var scenicSpotList: [ScenicSpotItem] = []
|
||||||
|
private(set) var mediaType: MaterialMediaType = .image
|
||||||
|
private(set) var uploadedMediaList: [MaterialUploadedMediaItem] = []
|
||||||
|
private(set) var coverImage: MaterialUploadedMediaItem?
|
||||||
|
private(set) var tagList: [MaterialTagItem] = []
|
||||||
|
private(set) var tagInput = ""
|
||||||
|
private(set) var uploadDialogState: MaterialUploadDialogState?
|
||||||
|
private(set) var isSubmitting = false
|
||||||
|
private(set) var isLoadingDetail = false
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
|
var onSubmitSuccess: (() -> Void)?
|
||||||
|
|
||||||
|
let mode: Mode
|
||||||
|
private let currentScenicIdProvider: () -> Int
|
||||||
|
private var pendingScenicSpotId: Int64?
|
||||||
|
|
||||||
|
/// 初始化素材表单 ViewModel。
|
||||||
|
init(
|
||||||
|
mode: Mode = .create,
|
||||||
|
currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId }
|
||||||
|
) {
|
||||||
|
self.mode = mode
|
||||||
|
self.currentScenicIdProvider = currentScenicIdProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始加载,编辑模式会同时拉详情。
|
||||||
|
func loadInitial(api: any MaterialManagementServing) async {
|
||||||
|
await loadScenicSpots(api: api)
|
||||||
|
if case .edit(let id) = mode {
|
||||||
|
await loadDetail(id: id, api: api)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新素材名称,最多保留 30 个字符。
|
||||||
|
func updateMaterialName(_ name: String) {
|
||||||
|
materialName = String(name.prefix(30))
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择关联打卡点。
|
||||||
|
func selectScenicSpot(_ spot: ScenicSpotItem) {
|
||||||
|
selectedScenicSpot = spot
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 切换素材类型,类型变化时清空已选素材。
|
||||||
|
func selectMediaType(_ type: MaterialMediaType) {
|
||||||
|
guard mediaType != type else { return }
|
||||||
|
mediaType = type
|
||||||
|
uploadedMediaList = []
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新标签输入。
|
||||||
|
func updateTagInput(_ text: String) {
|
||||||
|
tagInput = text
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新增标签。
|
||||||
|
func addTag(api: any MaterialManagementServing) async {
|
||||||
|
let name = tagInput.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard name.count >= 5, name.count <= 15 else {
|
||||||
|
onShowMessage?("标签长度必须在5-15个字符之间")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard tagList.count < 30 else {
|
||||||
|
onShowMessage?("最多只能添加30个标签")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !tagList.contains(where: { $0.name == name }) else {
|
||||||
|
onShowMessage?("标签已存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let id = try await api.addTag(name: name)
|
||||||
|
tagList.append(MaterialTagItem(id: id, name: name))
|
||||||
|
tagInput = ""
|
||||||
|
onShowMessage?("添加标签成功")
|
||||||
|
notifyStateChange()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription.isEmpty ? "添加标签失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除本地标签。
|
||||||
|
func deleteTag(_ tag: MaterialTagItem) {
|
||||||
|
tagList.removeAll { $0.id == tag.id }
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 增加并立即顺序上传素材。
|
||||||
|
func addLocalMedia(_ mediaItems: [MaterialUploadedMediaItem], uploader: any MaterialOSSUploading) async {
|
||||||
|
guard !mediaItems.isEmpty else { return }
|
||||||
|
let remaining = mediaType.maxCount - uploadedMediaList.count
|
||||||
|
guard remaining > 0 else {
|
||||||
|
onShowMessage?(mediaType == .video ? "视频只能上传1个" : "图片最多上传9张")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let accepted = Array(mediaItems.prefix(remaining))
|
||||||
|
uploadedMediaList.append(contentsOf: accepted)
|
||||||
|
notifyStateChange()
|
||||||
|
|
||||||
|
for offset in accepted.indices {
|
||||||
|
let index = uploadedMediaList.count - accepted.count + offset
|
||||||
|
await uploadMedia(at: index, title: "正在上传素材(\(offset + 1)/\(accepted.count))", uploader: uploader)
|
||||||
|
}
|
||||||
|
uploadDialogState = nil
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置封面并立即上传。
|
||||||
|
func setCoverImage(_ item: MaterialUploadedMediaItem, uploader: any MaterialOSSUploading) async {
|
||||||
|
coverImage = item
|
||||||
|
notifyStateChange()
|
||||||
|
await uploadCover(uploader: uploader)
|
||||||
|
uploadDialogState = nil
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除素材。
|
||||||
|
func deleteMaterial(at index: Int) {
|
||||||
|
guard uploadedMediaList.indices.contains(index) else { return }
|
||||||
|
uploadedMediaList.remove(at: index)
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除封面。
|
||||||
|
func deleteCoverImage() {
|
||||||
|
coverImage = nil
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传或编辑素材。
|
||||||
|
func submit(api: any MaterialManagementServing) async {
|
||||||
|
guard !materialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
onShowMessage?("请输入素材名称")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard materialName.count <= 30 else {
|
||||||
|
onShowMessage?("素材名称最多30个字符")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !uploadedMediaList.isEmpty else {
|
||||||
|
onShowMessage?("请至少上传一个素材")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let coverImage else {
|
||||||
|
onShowMessage?("请上传封面图片")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let selectedScenicSpot else {
|
||||||
|
onShowMessage?("请选择关联打卡点")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !uploadedMediaList.contains(where: { $0.ossUrl.isEmpty }) else {
|
||||||
|
onShowMessage?("请等待素材上传完成")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !coverImage.ossUrl.isEmpty else {
|
||||||
|
onShowMessage?("请等待封面图片上传完成")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = MaterialUploadRequest(
|
||||||
|
id: editID,
|
||||||
|
name: materialName,
|
||||||
|
mediaType: mediaType,
|
||||||
|
mediaList: uploadedMediaList.map {
|
||||||
|
MaterialUploadMediaItem(
|
||||||
|
originalName: $0.fileName,
|
||||||
|
ossUrl: $0.ossUrl,
|
||||||
|
size: $0.size,
|
||||||
|
fileWidthSize: MaterialUploadFileSize(width: $0.width, height: $0.height)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
coverUrl: coverImage.ossUrl,
|
||||||
|
coverSize: String(coverImage.size),
|
||||||
|
scenicSpotId: selectedScenicSpot.id,
|
||||||
|
materialTag: tagList.filter { $0.id > 0 }.map { String($0.id) }.joined(separator: ",")
|
||||||
|
)
|
||||||
|
|
||||||
|
isSubmitting = true
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isSubmitting = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
switch mode {
|
||||||
|
case .create:
|
||||||
|
try await api.upload(request)
|
||||||
|
onShowMessage?("上传成功")
|
||||||
|
case .edit:
|
||||||
|
try await api.edit(request)
|
||||||
|
onShowMessage?("编辑成功")
|
||||||
|
}
|
||||||
|
onSubmitSuccess?()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription.isEmpty ? "提交失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var editID: Int? {
|
||||||
|
if case .edit(let id) = mode { return id }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadScenicSpots(api: any MaterialManagementServing) async {
|
||||||
|
let scenicId = currentScenicIdProvider()
|
||||||
|
guard scenicId > 0 else {
|
||||||
|
onShowMessage?("请先选择景区")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let response = try await api.scenicSpotListAll(scenicId: String(scenicId))
|
||||||
|
scenicSpotList = response.list
|
||||||
|
if let pendingScenicSpotId {
|
||||||
|
selectedScenicSpot = scenicSpotList.first { $0.id == pendingScenicSpotId }
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription.isEmpty ? "获取打卡点失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadDetail(id: Int, api: any MaterialManagementServing) async {
|
||||||
|
isLoadingDetail = true
|
||||||
|
notifyStateChange()
|
||||||
|
defer {
|
||||||
|
isLoadingDetail = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let detail = try await api.detail(id: id)
|
||||||
|
materialName = detail.name ?? ""
|
||||||
|
mediaType = detail.mediaList?.first?.type == 2 ? .video : .image
|
||||||
|
pendingScenicSpotId = detail.scenicSpotId
|
||||||
|
selectedScenicSpot = scenicSpotList.first { $0.id == detail.scenicSpotId }
|
||||||
|
tagList = detail.materialTag ?? []
|
||||||
|
uploadedMediaList = detail.mediaList?.map { media in
|
||||||
|
MaterialUploadedMediaItem(
|
||||||
|
data: Data(),
|
||||||
|
thumbnailData: nil,
|
||||||
|
previewURL: media.displayURL,
|
||||||
|
fileName: media.originalName ?? "material",
|
||||||
|
size: media.size ?? 0,
|
||||||
|
width: media.width ?? 0,
|
||||||
|
height: media.height ?? 0,
|
||||||
|
ossUrl: media.ossUrl ?? media.showUrl ?? "",
|
||||||
|
isUploading: false,
|
||||||
|
uploadProgress: 100
|
||||||
|
)
|
||||||
|
} ?? []
|
||||||
|
if let coverUrl = detail.coverUrl, !coverUrl.isEmpty {
|
||||||
|
coverImage = MaterialUploadedMediaItem(
|
||||||
|
data: Data(),
|
||||||
|
previewURL: coverUrl,
|
||||||
|
fileName: "cover",
|
||||||
|
size: Int64(detail.coverSize ?? 0),
|
||||||
|
ossUrl: coverUrl,
|
||||||
|
isUploading: false,
|
||||||
|
uploadProgress: 100
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
onShowMessage?(error.localizedDescription.isEmpty ? "加载素材详情失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadMedia(at index: Int, title: String, uploader: any MaterialOSSUploading) async {
|
||||||
|
guard uploadedMediaList.indices.contains(index) else { return }
|
||||||
|
guard currentScenicIdProvider() > 0 else {
|
||||||
|
onShowMessage?("请先选择景区")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uploadedMediaList[index].isUploading = true
|
||||||
|
uploadedMediaList[index].uploadProgress = 0
|
||||||
|
uploadDialogState = MaterialUploadDialogState(title: title, progress: 0)
|
||||||
|
notifyStateChange()
|
||||||
|
|
||||||
|
do {
|
||||||
|
let item = uploadedMediaList[index]
|
||||||
|
let url = try await uploader.uploadMaterialFile(
|
||||||
|
data: item.data,
|
||||||
|
fileName: item.fileName,
|
||||||
|
fileType: mediaType.uploadFileType,
|
||||||
|
scenicId: currentScenicIdProvider(),
|
||||||
|
onProgress: { [weak self] progress in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
guard self.uploadedMediaList.indices.contains(index) else { return }
|
||||||
|
self.uploadedMediaList[index].uploadProgress = progress
|
||||||
|
self.uploadedMediaList[index].isUploading = progress < 100
|
||||||
|
self.uploadDialogState = MaterialUploadDialogState(title: title, progress: progress)
|
||||||
|
self.notifyStateChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
guard uploadedMediaList.indices.contains(index) else { return }
|
||||||
|
uploadedMediaList[index].ossUrl = url
|
||||||
|
uploadedMediaList[index].isUploading = false
|
||||||
|
uploadedMediaList[index].uploadProgress = 100
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
guard uploadedMediaList.indices.contains(index) else { return }
|
||||||
|
uploadedMediaList[index].isUploading = false
|
||||||
|
uploadedMediaList[index].uploadProgress = 0
|
||||||
|
onShowMessage?(error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadCover(uploader: any MaterialOSSUploading) async {
|
||||||
|
guard let coverImage else { return }
|
||||||
|
guard currentScenicIdProvider() > 0 else {
|
||||||
|
onShowMessage?("请先选择景区")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.coverImage?.isUploading = true
|
||||||
|
self.coverImage?.uploadProgress = 0
|
||||||
|
uploadDialogState = MaterialUploadDialogState(title: "正在上传封面", progress: 0)
|
||||||
|
notifyStateChange()
|
||||||
|
|
||||||
|
do {
|
||||||
|
let url = try await uploader.uploadMaterialFile(
|
||||||
|
data: coverImage.data,
|
||||||
|
fileName: coverImage.fileName,
|
||||||
|
fileType: 2,
|
||||||
|
scenicId: currentScenicIdProvider(),
|
||||||
|
onProgress: { [weak self] progress in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { @MainActor in
|
||||||
|
self.coverImage?.uploadProgress = progress
|
||||||
|
self.coverImage?.isUploading = progress < 100
|
||||||
|
self.uploadDialogState = MaterialUploadDialogState(title: "正在上传封面", progress: progress)
|
||||||
|
self.notifyStateChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.coverImage?.ossUrl = url
|
||||||
|
self.coverImage?.isUploading = false
|
||||||
|
self.coverImage?.uploadProgress = 100
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
self.coverImage?.isUploading = false
|
||||||
|
self.coverImage?.uploadProgress = 0
|
||||||
|
onShowMessage?(error.localizedDescription.isEmpty ? "封面上传失败" : error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyStateChange() {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
var sxkNonEmpty: String? { isEmpty ? nil : self }
|
||||||
|
}
|
||||||
@ -158,6 +158,14 @@ struct SampleMaterialOrderInfo: Decodable, Sendable, Equatable {
|
|||||||
self.refundTotal = refundTotal
|
self.refundTotal = refundTotal
|
||||||
self.avgChange = avgChange
|
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`。
|
/// 样片详情响应,对齐 Android `SampleDetailResponse`。
|
||||||
|
|||||||
23
suixinkan/Features/Setting/API/SettingAPI.swift
Normal file
23
suixinkan/Features/Setting/API/SettingAPI.swift
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// SettingAPI.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
/// 设置中心 API,封装版本检查等设置页相关接口。
|
||||||
|
final class SettingAPI {
|
||||||
|
private let client: APIClient
|
||||||
|
|
||||||
|
init(client: APIClient) {
|
||||||
|
self.client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 拉取后端配置的最新 App 版本。
|
||||||
|
func checkLatestVersion() async throws -> VersionResponse {
|
||||||
|
try await client.send(
|
||||||
|
APIRequest(method: .get, path: "/api/app/latest-version")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
136
suixinkan/Features/Setting/Models/SettingModels.swift
Normal file
136
suixinkan/Features/Setting/Models/SettingModels.swift
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
//
|
||||||
|
// SettingModels.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 最新版本响应实体,对齐 Android `VersionResponse`。
|
||||||
|
struct VersionResponse: Decodable, Equatable {
|
||||||
|
let id: Int
|
||||||
|
let type: Int
|
||||||
|
let version: String
|
||||||
|
let operatorName: String
|
||||||
|
let description: String
|
||||||
|
let status: Int
|
||||||
|
let createdAt: String
|
||||||
|
let updatedAt: String
|
||||||
|
let deletedAt: String?
|
||||||
|
let versionCode: Int
|
||||||
|
let apkURL: String
|
||||||
|
let isForceUpdate: Int
|
||||||
|
let approveNotes: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case type
|
||||||
|
case version
|
||||||
|
case operatorName = "operator"
|
||||||
|
case description
|
||||||
|
case status
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case updatedAt = "updated_at"
|
||||||
|
case deletedAt = "deleted_at"
|
||||||
|
case versionCode = "version_code"
|
||||||
|
case apkURL = "apk_url"
|
||||||
|
case isForceUpdate = "is_force_update"
|
||||||
|
case approveNotes = "approve_notes"
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: Int = 0,
|
||||||
|
type: Int = 0,
|
||||||
|
version: String = "",
|
||||||
|
operatorName: String = "",
|
||||||
|
description: String = "",
|
||||||
|
status: Int = 0,
|
||||||
|
createdAt: String = "",
|
||||||
|
updatedAt: String = "",
|
||||||
|
deletedAt: String? = nil,
|
||||||
|
versionCode: Int = 0,
|
||||||
|
apkURL: String = "",
|
||||||
|
isForceUpdate: Int = 0,
|
||||||
|
approveNotes: String = ""
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.type = type
|
||||||
|
self.version = version
|
||||||
|
self.operatorName = operatorName
|
||||||
|
self.description = description
|
||||||
|
self.status = status
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
self.deletedAt = deletedAt
|
||||||
|
self.versionCode = versionCode
|
||||||
|
self.apkURL = apkURL
|
||||||
|
self.isForceUpdate = isForceUpdate
|
||||||
|
self.approveNotes = approveNotes
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||||
|
type = try container.decodeLossyInt(forKey: .type) ?? 0
|
||||||
|
version = try container.decodeLossyString(forKey: .version)
|
||||||
|
operatorName = try container.decodeLossyString(forKey: .operatorName)
|
||||||
|
description = try container.decodeLossyString(forKey: .description)
|
||||||
|
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||||
|
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||||
|
updatedAt = try container.decodeLossyString(forKey: .updatedAt)
|
||||||
|
deletedAt = try container.decodeIfPresent(String.self, forKey: .deletedAt)
|
||||||
|
versionCode = try container.decodeLossyInt(forKey: .versionCode) ?? 0
|
||||||
|
apkURL = try container.decodeLossyString(forKey: .apkURL)
|
||||||
|
isForceUpdate = try container.decodeLossyInt(forKey: .isForceUpdate) ?? 0
|
||||||
|
approveNotes = try container.decodeLossyString(forKey: .approveNotes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置页协议入口实体,表示标题和对应 H5 地址。
|
||||||
|
struct SettingAgreementDestination: Equatable {
|
||||||
|
let title: String
|
||||||
|
let url: URL
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置页协议类型。
|
||||||
|
enum SettingAgreementKind: CaseIterable {
|
||||||
|
case aboutUs
|
||||||
|
case userAgreement
|
||||||
|
case privacyPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension KeyedDecodingContainer {
|
||||||
|
func decodeLossyString(forKey key: Key) throws -> String {
|
||||||
|
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||||
|
return value ? "true" : "false"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||||
|
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||||
|
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if let intValue = Int(text) {
|
||||||
|
return intValue
|
||||||
|
}
|
||||||
|
if let doubleValue = Double(text) {
|
||||||
|
return Int(doubleValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||||
|
return Int(value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
87
suixinkan/Features/Setting/ViewModels/SettingViewModel.swift
Normal file
87
suixinkan/Features/Setting/ViewModels/SettingViewModel.swift
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
//
|
||||||
|
// SettingViewModel.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 设置中心 ViewModel,管理版本展示、协议入口和更新标记。
|
||||||
|
final class SettingViewModel {
|
||||||
|
private let infoDictionary: [String: Any]?
|
||||||
|
private let environment: APIEnvironment
|
||||||
|
|
||||||
|
private(set) var latestVersion: VersionResponse?
|
||||||
|
private(set) var hasNewVersion = false
|
||||||
|
|
||||||
|
var onStateChange: (() -> Void)?
|
||||||
|
|
||||||
|
init(
|
||||||
|
infoDictionary: [String: Any]? = Bundle.main.infoDictionary,
|
||||||
|
environment: APIEnvironment = .current
|
||||||
|
) {
|
||||||
|
self.infoDictionary = infoDictionary
|
||||||
|
self.environment = environment
|
||||||
|
}
|
||||||
|
|
||||||
|
var appVersion: String {
|
||||||
|
AppClientInfo.appVersion(infoDictionary: infoDictionary)
|
||||||
|
}
|
||||||
|
|
||||||
|
var appDownloadURL: URL {
|
||||||
|
environment.baseURL.appending(path: "h5/app/download")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 静默检查最新版本,失败时保持当前页面状态。
|
||||||
|
func checkLatestVersion(api: SettingAPI) async {
|
||||||
|
do {
|
||||||
|
let response = try await api.checkLatestVersion()
|
||||||
|
latestVersion = response
|
||||||
|
hasNewVersion = Self.isNewerVersionCode(response.versionCode, than: currentVersionCode(infoDictionary: infoDictionary))
|
||||||
|
notifyStateChange()
|
||||||
|
} catch is CancellationError {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
latestVersion = nil
|
||||||
|
hasNewVersion = false
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回指定协议入口的标题和 URL。
|
||||||
|
func agreementDestination(for kind: SettingAgreementKind) -> SettingAgreementDestination {
|
||||||
|
switch kind {
|
||||||
|
case .aboutUs:
|
||||||
|
SettingAgreementDestination(
|
||||||
|
title: "关于我们",
|
||||||
|
url: environment.baseURL.appending(path: "h5/app/about-us")
|
||||||
|
)
|
||||||
|
case .userAgreement:
|
||||||
|
SettingAgreementDestination(
|
||||||
|
title: "用户协议",
|
||||||
|
url: environment.baseURL.appending(path: "h5/app/user-agreement")
|
||||||
|
)
|
||||||
|
case .privacyPolicy:
|
||||||
|
SettingAgreementDestination(
|
||||||
|
title: "隐私政策",
|
||||||
|
url: environment.baseURL.appending(path: "h5/app/privacy-policy")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断后端版本号是否高于当前构建号。
|
||||||
|
nonisolated static func isNewerVersionCode(_ latestVersionCode: Int, than currentVersionCode: Int?) -> Bool {
|
||||||
|
guard let currentVersionCode else { return false }
|
||||||
|
return latestVersionCode > currentVersionCode
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentVersionCode(infoDictionary: [String: Any]?) -> Int? {
|
||||||
|
let build = (infoDictionary?["CFBundleVersion"] as? String)?
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard let build, !build.isEmpty else { return nil }
|
||||||
|
return Int(build)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyStateChange() {
|
||||||
|
onStateChange?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,8 @@ import Foundation
|
|||||||
protocol WildPhotographerReportServing {
|
protocol WildPhotographerReportServing {
|
||||||
/// 拉取提交举报可选类型。
|
/// 拉取提交举报可选类型。
|
||||||
func reportTypes() async throws -> WildReportTypeListResponse
|
func reportTypes() async throws -> WildReportTypeListResponse
|
||||||
|
/// 拉取举报首页须知与规则文案。
|
||||||
|
func reportCopy() async throws -> WildReportCopyResponse
|
||||||
/// 提交摄影师举报。
|
/// 提交摄影师举报。
|
||||||
func submitReport(_ request: WildReportSubmitRequest) async throws
|
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 {
|
func submitReport(_ request: WildReportSubmitRequest) async throws {
|
||||||
let _: EmptyPayload = try await client.send(
|
let _: EmptyPayload = try await client.send(
|
||||||
@ -67,9 +76,9 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing {
|
|||||||
func riskMap(_ request: WildReportRiskMapRequest) async throws -> WildReportRiskMapResponse {
|
func riskMap(_ request: WildReportRiskMapRequest) async throws -> WildReportRiskMapResponse {
|
||||||
try await client.send(
|
try await client.send(
|
||||||
APIRequest(
|
APIRequest(
|
||||||
method: .post,
|
method: .get,
|
||||||
path: "/api/yf-handset-app/photog/report/risk-map",
|
path: "/api/yf-handset-app/photog/report/risk-map",
|
||||||
body: request
|
queryItems: request.queryItems
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -78,9 +87,9 @@ final class WildPhotographerReportAPI: WildPhotographerReportServing {
|
|||||||
func markerDetail(_ request: WildReportMarkerDetailRequest) async throws -> WildReportMarkerDetailResponse {
|
func markerDetail(_ request: WildReportMarkerDetailRequest) async throws -> WildReportMarkerDetailResponse {
|
||||||
try await client.send(
|
try await client.send(
|
||||||
APIRequest(
|
APIRequest(
|
||||||
method: .post,
|
method: .get,
|
||||||
path: "/api/yf-handset-app/photog/report/marker-detail",
|
path: "/api/yf-handset-app/photog/report/marker-detail",
|
||||||
body: request
|
queryItems: request.queryItems
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -115,12 +124,23 @@ struct WildReportSupplementRequest: Encodable, Equatable {
|
|||||||
let evidences: [WildReportEvidenceRequest]?
|
let evidences: [WildReportEvidenceRequest]?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 附近风险地图接口请求体。
|
/// 附近风险地图接口请求参数。
|
||||||
struct WildReportRiskMapRequest: Encodable, Equatable {
|
struct WildReportRiskMapRequest: Encodable, Equatable {
|
||||||
let scenicId: Int
|
let scenicId: Int
|
||||||
let latitude: Double?
|
let latitude: Double?
|
||||||
let longitude: 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 {
|
enum CodingKeys: String, CodingKey {
|
||||||
case scenicId = "scenic_id"
|
case scenicId = "scenic_id"
|
||||||
case latitude
|
case latitude
|
||||||
@ -128,7 +148,7 @@ struct WildReportRiskMapRequest: Encodable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 风险地图标记详情接口请求体。
|
/// 风险地图标记详情接口请求参数。
|
||||||
struct WildReportMarkerDetailRequest: Encodable, Equatable {
|
struct WildReportMarkerDetailRequest: Encodable, Equatable {
|
||||||
let scenicId: Int
|
let scenicId: Int
|
||||||
let markerType: String
|
let markerType: String
|
||||||
@ -136,6 +156,21 @@ struct WildReportMarkerDetailRequest: Encodable, Equatable {
|
|||||||
let latitude: Double?
|
let latitude: Double?
|
||||||
let longitude: Double?
|
let longitude: Double?
|
||||||
|
|
||||||
|
var queryItems: [URLQueryItem] {
|
||||||
|
var items = [
|
||||||
|
URLQueryItem(name: "scenic_id", value: String(scenicId)),
|
||||||
|
URLQueryItem(name: "marker_type", value: markerType),
|
||||||
|
URLQueryItem(name: "id", value: String(id))
|
||||||
|
]
|
||||||
|
if let latitude {
|
||||||
|
items.append(URLQueryItem(name: "latitude", value: String(latitude)))
|
||||||
|
}
|
||||||
|
if let longitude {
|
||||||
|
items.append(URLQueryItem(name: "longitude", value: String(longitude)))
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case scenicId = "scenic_id"
|
case scenicId = "scenic_id"
|
||||||
case markerType = "marker_type"
|
case markerType = "marker_type"
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import Foundation
|
|||||||
import MapKit
|
import MapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 举报摄影师页面状态,用于演示加载、空态和异常态。
|
/// 举报摄影师页面状态,用于加载、空态和异常态展示。
|
||||||
enum WildReportPageState: String, CaseIterable, Hashable {
|
enum WildReportPageState: String, CaseIterable, Hashable {
|
||||||
case normal
|
case normal
|
||||||
case loading
|
case loading
|
||||||
@ -36,15 +36,6 @@ enum WildReportAttachmentKind: Hashable {
|
|||||||
struct WildReportType: Decodable, Hashable {
|
struct WildReportType: Decodable, Hashable {
|
||||||
let value: Int
|
let value: Int
|
||||||
let label: String
|
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 {
|
struct WildReportAttachment: Hashable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let kind: WildReportAttachmentKind
|
let kind: WildReportAttachmentKind
|
||||||
@ -1150,7 +1185,9 @@ struct WildReportRiskMapMarkerDTO: Decodable, Equatable, Hashable {
|
|||||||
struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
|
struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
|
||||||
let markerType: String
|
let markerType: String
|
||||||
let id: Int
|
let id: Int
|
||||||
|
let clueId: String
|
||||||
let title: String
|
let title: String
|
||||||
|
let statusTag: String
|
||||||
let tag: String
|
let tag: String
|
||||||
let name: String
|
let name: String
|
||||||
let avatar: String
|
let avatar: String
|
||||||
@ -1175,7 +1212,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
|
|||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case markerType = "marker_type"
|
case markerType = "marker_type"
|
||||||
case id
|
case id
|
||||||
|
case clueId = "clue_id"
|
||||||
case title
|
case title
|
||||||
|
case statusTag = "status_tag"
|
||||||
case tag
|
case tag
|
||||||
case name
|
case name
|
||||||
case avatar
|
case avatar
|
||||||
@ -1202,7 +1241,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
|
|||||||
init(
|
init(
|
||||||
markerType: String,
|
markerType: String,
|
||||||
id: Int,
|
id: Int,
|
||||||
|
clueId: String = "",
|
||||||
title: String = "",
|
title: String = "",
|
||||||
|
statusTag: String = "",
|
||||||
tag: String = "",
|
tag: String = "",
|
||||||
name: String = "",
|
name: String = "",
|
||||||
avatar: String = "",
|
avatar: String = "",
|
||||||
@ -1226,7 +1267,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
|
|||||||
) {
|
) {
|
||||||
self.markerType = markerType
|
self.markerType = markerType
|
||||||
self.id = id
|
self.id = id
|
||||||
|
self.clueId = clueId
|
||||||
self.title = title
|
self.title = title
|
||||||
|
self.statusTag = statusTag
|
||||||
self.tag = tag
|
self.tag = tag
|
||||||
self.name = name
|
self.name = name
|
||||||
self.avatar = avatar
|
self.avatar = avatar
|
||||||
@ -1253,7 +1296,9 @@ struct WildReportMarkerDetailResponse: Decodable, Equatable, Hashable {
|
|||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
markerType = try container.decodeIfPresent(String.self, forKey: .markerType) ?? ""
|
markerType = try container.decodeIfPresent(String.self, forKey: .markerType) ?? ""
|
||||||
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
|
id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0
|
||||||
|
clueId = try container.decodeIfPresent(String.self, forKey: .clueId) ?? ""
|
||||||
title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
|
title = try container.decodeIfPresent(String.self, forKey: .title) ?? ""
|
||||||
|
statusTag = try container.decodeIfPresent(String.self, forKey: .statusTag) ?? ""
|
||||||
tag = try container.decodeIfPresent(String.self, forKey: .tag) ?? ""
|
tag = try container.decodeIfPresent(String.self, forKey: .tag) ?? ""
|
||||||
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
|
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||||
avatar = try container.decodeIfPresent(String.self, forKey: .avatar) ?? ""
|
avatar = try container.decodeIfPresent(String.self, forKey: .avatar) ?? ""
|
||||||
|
|||||||
@ -1,606 +0,0 @@
|
|||||||
//
|
|
||||||
// WildPhotographerReportMockStore.swift
|
|
||||||
// suixinkan
|
|
||||||
//
|
|
||||||
|
|
||||||
import CoreLocation
|
|
||||||
import Foundation
|
|
||||||
import MapKit
|
|
||||||
|
|
||||||
/// 举报摄影师 Mock 数据仓库,统一管理演示种子数据和地图标记构建。
|
|
||||||
enum WildPhotographerReportMockStore {
|
|
||||||
static let fallbackLocation = "黄山风景区 · 光明顶景区"
|
|
||||||
|
|
||||||
static let initialImages = [
|
|
||||||
WildReportAttachment(kind: .image, title: "现场图片1")
|
|
||||||
]
|
|
||||||
|
|
||||||
static let initialVideos = [
|
|
||||||
WildReportAttachment(kind: .video, title: "现场视频1")
|
|
||||||
]
|
|
||||||
|
|
||||||
static let seedRiskPoints = [
|
|
||||||
WildReportRiskPoint(
|
|
||||||
id: 1,
|
|
||||||
title: "光明顶观景台附近",
|
|
||||||
location: "黄山风景区 · 光明顶景区",
|
|
||||||
distance: "120m",
|
|
||||||
riskLevel: "高",
|
|
||||||
evidenceCount: 6,
|
|
||||||
latestTime: "10分钟前",
|
|
||||||
description: "多名游客反馈有疑似未备案摄影师主动揽客,集中在观景台东侧步道附近。"
|
|
||||||
),
|
|
||||||
WildReportRiskPoint(
|
|
||||||
id: 2,
|
|
||||||
title: "迎客松游客出口",
|
|
||||||
location: "黄山风景区 · 玉屏景区",
|
|
||||||
distance: "860m",
|
|
||||||
riskLevel: "中",
|
|
||||||
evidenceCount: 3,
|
|
||||||
latestTime: "32分钟前",
|
|
||||||
description: "疑似人员以低价加急出片为话术引导游客线下付款,建议现场核查。"
|
|
||||||
),
|
|
||||||
WildReportRiskPoint(
|
|
||||||
id: 3,
|
|
||||||
title: "云谷索道排队区",
|
|
||||||
location: "黄山风景区 · 云谷景区",
|
|
||||||
distance: "1.4km",
|
|
||||||
riskLevel: "低",
|
|
||||||
evidenceCount: 2,
|
|
||||||
latestTime: "1小时前",
|
|
||||||
description: "排队区附近存在疑似私下拉客行为,已收到图片和位置线索。"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
static let seedReports = [
|
|
||||||
WildReportRecord(
|
|
||||||
id: "JB20260618001",
|
|
||||||
title: "摄影师举报",
|
|
||||||
reportType: WildReportType(value: 2, label: "疑似打野"),
|
|
||||||
status: .processing,
|
|
||||||
location: "那拉提景区 · 空中草原入口附近",
|
|
||||||
submitTime: "2026-06-18 10:24",
|
|
||||||
description: "疑似人员在观景台附近主动向游客售卖拍摄服务,并引导线下转账。",
|
|
||||||
wechatID: "wx_photographer_01",
|
|
||||||
phoneNumber: "13812345678",
|
|
||||||
imageCount: 3,
|
|
||||||
videoCount: 1,
|
|
||||||
handlerInfo: nil
|
|
||||||
),
|
|
||||||
WildReportRecord(
|
|
||||||
id: "JB20260617008",
|
|
||||||
title: "摄影师举报",
|
|
||||||
reportType: WildReportType(value: 2, label: "疑似打野"),
|
|
||||||
status: .processed,
|
|
||||||
location: "那拉提景区 · 河谷草原游客中心",
|
|
||||||
submitTime: "2026-06-17 16:42",
|
|
||||||
description: "现场发现疑似未备案摄影师持续向游客推销旅拍套餐。",
|
|
||||||
wechatID: "nalati_photo",
|
|
||||||
phoneNumber: "",
|
|
||||||
imageCount: 2,
|
|
||||||
videoCount: 1,
|
|
||||||
handlerInfo: processedHandlerInfo(
|
|
||||||
handlerName: "赵强",
|
|
||||||
handlerPhone: "15938267120",
|
|
||||||
processingAt: "2026-06-17 17:10",
|
|
||||||
processedAt: "2026-06-17 18:26",
|
|
||||||
processOpinion: "已到场核查并完成劝离,处理结果已同步景区公安。",
|
|
||||||
imageSeedPrefix: "handler-jb008"
|
|
||||||
)
|
|
||||||
),
|
|
||||||
WildReportRecord(
|
|
||||||
id: "JB20260616003",
|
|
||||||
title: "摄影师举报",
|
|
||||||
reportType: WildReportType(value: 3, label: "未戴工牌"),
|
|
||||||
status: .pending,
|
|
||||||
location: "那拉提景区 · 盘龙古道停车区",
|
|
||||||
submitTime: "2026-06-16 09:15",
|
|
||||||
description: "停车区附近有摄影师未佩戴工牌开展拍摄服务,已收到图片和位置线索。",
|
|
||||||
wechatID: "",
|
|
||||||
phoneNumber: "13987654321",
|
|
||||||
imageCount: 1,
|
|
||||||
videoCount: 0,
|
|
||||||
handlerInfo: nil
|
|
||||||
),
|
|
||||||
WildReportRecord(
|
|
||||||
id: "JB20260615002",
|
|
||||||
title: "摄影师举报",
|
|
||||||
reportType: WildReportType(value: 4, label: "其他"),
|
|
||||||
status: .processed,
|
|
||||||
location: "那拉提景区 · 雪莲谷步道",
|
|
||||||
submitTime: "2026-06-15 14:30",
|
|
||||||
description: "举报线索经核查未发现异常摄影师服务行为,已归档。",
|
|
||||||
wechatID: "grassland_cam",
|
|
||||||
phoneNumber: "13600001111",
|
|
||||||
imageCount: 1,
|
|
||||||
videoCount: 0,
|
|
||||||
handlerInfo: processedHandlerInfo(
|
|
||||||
handlerName: "李娜",
|
|
||||||
handlerPhone: "18674598210",
|
|
||||||
processingAt: "2026-06-15 15:05",
|
|
||||||
processedAt: "2026-06-15 16:55",
|
|
||||||
processOpinion: "现场核查未发现未备案摄影师,已归档处理。",
|
|
||||||
imageSeedPrefix: "handler-jb002",
|
|
||||||
includeVideo: false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
static let seedSupplementaryEvidences: [String: [WildReportSupplementaryEvidence]] = [
|
|
||||||
"JB20260618001": [
|
|
||||||
WildReportSupplementaryEvidence(
|
|
||||||
id: "SE001",
|
|
||||||
submitTime: "2026-06-18 14:20",
|
|
||||||
text: "补充现场视频,对方仍在揽客。",
|
|
||||||
imageCount: 1,
|
|
||||||
videoCount: 1
|
|
||||||
)
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
static let reportDateFormatter: DateFormatter = {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "yyyyMMdd"
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let submitTimeFormatter: DateFormatter = {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd HH:mm"
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let nalatiCenter = CLLocationCoordinate2D(latitude: 43.382, longitude: 84.032)
|
|
||||||
|
|
||||||
private static let clueID1 = "2026年6月22日-0001"
|
|
||||||
private static let clueID2 = "2026年6月22日-0002"
|
|
||||||
private static let clueID3 = "2026年6月22日-0003"
|
|
||||||
private static let initialProcessingClueIDs: Set<String> = [clueID2]
|
|
||||||
|
|
||||||
/// 构建已处理举报的处理人信息 Mock 数据。
|
|
||||||
static func processedHandlerInfo(
|
|
||||||
handlerName: String,
|
|
||||||
handlerPhone: String,
|
|
||||||
processingAt: String,
|
|
||||||
processedAt: String,
|
|
||||||
processOpinion: String,
|
|
||||||
imageSeedPrefix: String,
|
|
||||||
includeVideo: Bool = true
|
|
||||||
) -> WildReportHandlerInfo {
|
|
||||||
var media: [WildReportCompletionMediaItem] = [
|
|
||||||
WildReportCompletionMediaItem(
|
|
||||||
id: "\(imageSeedPrefix)-img-1",
|
|
||||||
kind: .image,
|
|
||||||
coverURL: "https://picsum.photos/seed/\(imageSeedPrefix)-1/640/480",
|
|
||||||
duration: nil
|
|
||||||
),
|
|
||||||
WildReportCompletionMediaItem(
|
|
||||||
id: "\(imageSeedPrefix)-img-2",
|
|
||||||
kind: .image,
|
|
||||||
coverURL: "https://picsum.photos/seed/\(imageSeedPrefix)-2/640/480",
|
|
||||||
duration: nil
|
|
||||||
)
|
|
||||||
]
|
|
||||||
if includeVideo {
|
|
||||||
media.append(
|
|
||||||
WildReportCompletionMediaItem(
|
|
||||||
id: "\(imageSeedPrefix)-vid-1",
|
|
||||||
kind: .video,
|
|
||||||
coverURL: "https://picsum.photos/seed/\(imageSeedPrefix)-video/640/480",
|
|
||||||
duration: "00:18"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return WildReportHandlerInfo(
|
|
||||||
handlerName: handlerName,
|
|
||||||
handlerPhone: handlerPhone,
|
|
||||||
processingAt: processingAt,
|
|
||||||
processedAt: processedAt,
|
|
||||||
processOpinion: processOpinion,
|
|
||||||
completionMedia: media
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从已有举报编号中提取最大序号,供新编号递增。
|
|
||||||
static func maxSequence(from reports: [WildReportRecord]) -> Int {
|
|
||||||
reports.compactMap { record in
|
|
||||||
guard record.id.count >= 3 else { return nil }
|
|
||||||
return Int(record.id.suffix(3))
|
|
||||||
}.max() ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析联系方式,区分微信号和手机号。
|
|
||||||
static func parseContact(_ contact: String?) -> (wechat: String, phone: String) {
|
|
||||||
guard let contact else { return ("", "") }
|
|
||||||
let trimmed = contact.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return ("", "") }
|
|
||||||
|
|
||||||
let digits = trimmed.filter(\.isNumber)
|
|
||||||
if digits.count >= 7, trimmed.filter({ $0.isNumber || "+-() ".contains($0) }).count == trimmed.count {
|
|
||||||
return ("", trimmed)
|
|
||||||
}
|
|
||||||
return (trimmed, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 根据线索状态返回地图标记类型。
|
|
||||||
static func markerKind(for clue: WildReportRadarActiveClue) -> WildReportMapMarkerKind {
|
|
||||||
switch clue.type {
|
|
||||||
case .blue:
|
|
||||||
return .selfLocation
|
|
||||||
case .green:
|
|
||||||
return .peerPhotographer
|
|
||||||
case .yellow:
|
|
||||||
return .processingClue
|
|
||||||
case .red:
|
|
||||||
if clue.processingStarted && !clue.completed {
|
|
||||||
return .processingClue
|
|
||||||
}
|
|
||||||
return .wildPhotographer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 构建风险地图全部标记点。
|
|
||||||
static func buildMarkers(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) -> [WildReportMapMarker] {
|
|
||||||
_ = record
|
|
||||||
_ = riskPoints
|
|
||||||
|
|
||||||
let blueCoordinate = CLLocationCoordinate2D(latitude: 43.3779, longitude: 84.0217)
|
|
||||||
let redSeeds: [(id: String, name: String, coordinate: CLLocationCoordinate2D, clue: WildReportRadarActiveClue)] = [
|
|
||||||
redSeed(
|
|
||||||
id: clueID1,
|
|
||||||
name: "空中草原观景道",
|
|
||||||
coordinate: CLLocationCoordinate2D(latitude: 43.4068, longitude: 84.0582),
|
|
||||||
updatedAt: "5分钟前",
|
|
||||||
distanceText: "320米",
|
|
||||||
reporterName: "张三",
|
|
||||||
reporterPhone: "13333333333",
|
|
||||||
contents: [
|
|
||||||
"多人结伴揽客,疑似无资质摄影团队,携带专业器材在观景道附近活动。",
|
|
||||||
"补充证据:对方仍在向游客推销拍照套餐,并拒绝出示相关资质。"
|
|
||||||
],
|
|
||||||
imageSeeds: ["nalati-radar-1", "nalati-radar-2", "nalati-radar-3"]
|
|
||||||
),
|
|
||||||
redSeed(
|
|
||||||
id: clueID2,
|
|
||||||
name: "塔吾萨尼步道",
|
|
||||||
coordinate: CLLocationCoordinate2D(latitude: 43.3652, longitude: 84.0385),
|
|
||||||
updatedAt: "18分钟前",
|
|
||||||
distanceText: "780米",
|
|
||||||
reporterName: "李四",
|
|
||||||
reporterPhone: "18674598210",
|
|
||||||
contents: [
|
|
||||||
"疑似二次转移,仍在塔吾萨尼步道附近揽客拍照。",
|
|
||||||
"补充:对方穿黑色外套,携带三脚架,在步道入口反复搭讪游客。"
|
|
||||||
],
|
|
||||||
imageSeeds: ["nalati-radar-4", "nalati-radar-5"]
|
|
||||||
),
|
|
||||||
redSeed(
|
|
||||||
id: clueID3,
|
|
||||||
name: "河谷草原西南侧",
|
|
||||||
coordinate: CLLocationCoordinate2D(latitude: 43.3546, longitude: 84.0136),
|
|
||||||
updatedAt: "昨天17:45",
|
|
||||||
distanceText: "910米",
|
|
||||||
reporterName: "王五",
|
|
||||||
reporterPhone: "15938267120",
|
|
||||||
contents: ["游客反馈有人在步道旁揽客拍照,行为较为可疑。"],
|
|
||||||
imageSeeds: ["nalati-radar-8"]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
let nearestText = nearestAbnormalText(
|
|
||||||
blueCoordinate: blueCoordinate,
|
|
||||||
redCoordinates: redSeeds.map(\.coordinate)
|
|
||||||
)
|
|
||||||
|
|
||||||
var markers: [WildReportMapMarker] = [
|
|
||||||
WildReportMapMarker(
|
|
||||||
id: "ME",
|
|
||||||
title: "河谷草原入口",
|
|
||||||
coordinate: blueCoordinate,
|
|
||||||
kind: .selfLocation,
|
|
||||||
detail: .activeClue(
|
|
||||||
WildReportRadarActiveClue(
|
|
||||||
id: "ME",
|
|
||||||
type: .blue,
|
|
||||||
displayTitle: "当前位置:河谷草原入口",
|
|
||||||
statusText: "当前位置",
|
|
||||||
actionText: "刷新定位",
|
|
||||||
nearestAbnormalText: nearestText,
|
|
||||||
distance: "0 米",
|
|
||||||
direction: "蓝色基准点",
|
|
||||||
updatedAt: "实时定位",
|
|
||||||
name: "河谷草原入口",
|
|
||||||
storeName: nil,
|
|
||||||
avatar: nil,
|
|
||||||
summary: "蓝色标记为当前位置,可对比周边摄影师风险点位距离。",
|
|
||||||
detail: "当前定位在河谷草原入口附近,建议优先关注 800 米内的异常点位。",
|
|
||||||
distanceText: nil,
|
|
||||||
reporterName: nil,
|
|
||||||
reporterPhone: nil,
|
|
||||||
maskedReporterPhone: nil,
|
|
||||||
reportContents: [],
|
|
||||||
images: [],
|
|
||||||
processingStarted: false,
|
|
||||||
completed: false,
|
|
||||||
record: nil,
|
|
||||||
processingTimeline: [],
|
|
||||||
completionPhoto: nil
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
for seed in redSeeds {
|
|
||||||
let processingStarted = initialProcessingClueIDs.contains(seed.id)
|
|
||||||
let clue = enrichClue(seed.clue, processingStarted: processingStarted)
|
|
||||||
markers.append(
|
|
||||||
WildReportMapMarker(
|
|
||||||
id: seed.id,
|
|
||||||
title: seed.name,
|
|
||||||
coordinate: seed.coordinate,
|
|
||||||
kind: markerKind(for: clue),
|
|
||||||
detail: .activeClue(clue)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
markers.append(contentsOf: greenMarkers())
|
|
||||||
return markers
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 根据全部标记计算地图可视区域。
|
|
||||||
static func region(for markers: [WildReportMapMarker]) -> MKCoordinateRegion {
|
|
||||||
guard let first = markers.first else {
|
|
||||||
return MKCoordinateRegion(
|
|
||||||
center: nalatiCenter,
|
|
||||||
span: MKCoordinateSpan(latitudeDelta: 0.025, longitudeDelta: 0.025)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
var minLat = first.coordinate.latitude
|
|
||||||
var maxLat = first.coordinate.latitude
|
|
||||||
var minLon = first.coordinate.longitude
|
|
||||||
var maxLon = first.coordinate.longitude
|
|
||||||
|
|
||||||
for marker in markers {
|
|
||||||
minLat = min(minLat, marker.coordinate.latitude)
|
|
||||||
maxLat = max(maxLat, marker.coordinate.latitude)
|
|
||||||
minLon = min(minLon, marker.coordinate.longitude)
|
|
||||||
maxLon = max(maxLon, marker.coordinate.longitude)
|
|
||||||
}
|
|
||||||
|
|
||||||
let center = CLLocationCoordinate2D(
|
|
||||||
latitude: (minLat + maxLat) / 2,
|
|
||||||
longitude: (minLon + maxLon) / 2
|
|
||||||
)
|
|
||||||
let span = MKCoordinateSpan(
|
|
||||||
latitudeDelta: max(0.022, (maxLat - minLat) * 1.8),
|
|
||||||
longitudeDelta: max(0.022, (maxLon - minLon) * 1.8)
|
|
||||||
)
|
|
||||||
return MKCoordinateRegion(center: center, span: span)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func redSeed(
|
|
||||||
id: String,
|
|
||||||
name: String,
|
|
||||||
coordinate: CLLocationCoordinate2D,
|
|
||||||
updatedAt: String,
|
|
||||||
distanceText: String,
|
|
||||||
reporterName: String,
|
|
||||||
reporterPhone: String,
|
|
||||||
contents: [String],
|
|
||||||
imageSeeds: [String]
|
|
||||||
) -> (id: String, name: String, coordinate: CLLocationCoordinate2D, clue: WildReportRadarActiveClue) {
|
|
||||||
let reportContents = contents.enumerated().map { index, content in
|
|
||||||
WildReportRadarReportContent(time: index == 0 ? "14:32:18" : "14:45:06", content: content)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
coordinate,
|
|
||||||
WildReportRadarActiveClue(
|
|
||||||
id: id,
|
|
||||||
type: .red,
|
|
||||||
displayTitle: "线索 ID:\(id)",
|
|
||||||
statusText: "疑似打野",
|
|
||||||
actionText: "导航到此",
|
|
||||||
nearestAbnormalText: nil,
|
|
||||||
distance: nil,
|
|
||||||
direction: nil,
|
|
||||||
updatedAt: updatedAt,
|
|
||||||
name: name,
|
|
||||||
storeName: nil,
|
|
||||||
avatar: nil,
|
|
||||||
summary: nil,
|
|
||||||
detail: nil,
|
|
||||||
distanceText: distanceText,
|
|
||||||
reporterName: reporterName,
|
|
||||||
reporterPhone: reporterPhone,
|
|
||||||
maskedReporterPhone: maskPhone(reporterPhone),
|
|
||||||
reportContents: reportContents,
|
|
||||||
images: imageSeeds.map { "https://picsum.photos/seed/\($0)/640/480" },
|
|
||||||
processingStarted: false,
|
|
||||||
completed: false,
|
|
||||||
record: nil,
|
|
||||||
processingTimeline: [],
|
|
||||||
completionPhoto: nil
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func greenMarkers() -> [WildReportMapMarker] {
|
|
||||||
let seeds: [(id: String, coordinate: CLLocationCoordinate2D, name: String, store: String, detail: String)] = [
|
|
||||||
("PG20240618001", CLLocationCoordinate2D(latitude: 43.4042, longitude: 84.0186), "阿依达娜", "空中草原旅拍店", "当前在空中草原区域服务游客拍摄。"),
|
|
||||||
("PG20240618002", CLLocationCoordinate2D(latitude: 43.3985, longitude: 84.0408), "波拉提", "天界台摄影服务点", "当前在天界台附近接待预约游客。"),
|
|
||||||
("PG20240618003", CLLocationCoordinate2D(latitude: 43.3828, longitude: 84.0794), "米热丽", "塔吾萨尼影像馆", "当前在塔吾萨尼步道附近提供旅拍服务。")
|
|
||||||
]
|
|
||||||
|
|
||||||
return seeds.map { seed in
|
|
||||||
let clue = WildReportRadarActiveClue(
|
|
||||||
id: seed.id,
|
|
||||||
type: .green,
|
|
||||||
displayTitle: "认证摄影师",
|
|
||||||
statusText: "认证摄影师",
|
|
||||||
actionText: "导航到此",
|
|
||||||
nearestAbnormalText: nil,
|
|
||||||
distance: "430 米",
|
|
||||||
direction: "景区内",
|
|
||||||
updatedAt: "在线",
|
|
||||||
name: seed.name,
|
|
||||||
storeName: seed.store,
|
|
||||||
avatar: "person.crop.circle.fill",
|
|
||||||
summary: "绿色标记为已认证摄影师,可与红色摄影师风险点位区分。",
|
|
||||||
detail: seed.detail,
|
|
||||||
distanceText: nil,
|
|
||||||
reporterName: nil,
|
|
||||||
reporterPhone: nil,
|
|
||||||
maskedReporterPhone: nil,
|
|
||||||
reportContents: [],
|
|
||||||
images: [],
|
|
||||||
processingStarted: false,
|
|
||||||
completed: false,
|
|
||||||
record: nil,
|
|
||||||
processingTimeline: [],
|
|
||||||
completionPhoto: nil
|
|
||||||
)
|
|
||||||
return WildReportMapMarker(
|
|
||||||
id: seed.id,
|
|
||||||
title: seed.name,
|
|
||||||
coordinate: seed.coordinate,
|
|
||||||
kind: .peerPhotographer,
|
|
||||||
detail: .activeClue(clue)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func processRecord(for clueID: String) -> WildReportRadarProcessRecord? {
|
|
||||||
switch clueID {
|
|
||||||
case clueID1:
|
|
||||||
return WildReportRadarProcessRecord(
|
|
||||||
handlerName: "王磊",
|
|
||||||
phone: "13812346721",
|
|
||||||
maskedPhone: maskPhone("13812346721"),
|
|
||||||
statusText: "待处理",
|
|
||||||
processResult: "待现场核查,请前往空中草原观景道确认摄影师线索。",
|
|
||||||
processedAt: nil,
|
|
||||||
processingAt: nil
|
|
||||||
)
|
|
||||||
case clueID2:
|
|
||||||
return WildReportRadarProcessRecord(
|
|
||||||
handlerName: "李娜",
|
|
||||||
phone: "18674598210",
|
|
||||||
maskedPhone: maskPhone("18674598210"),
|
|
||||||
statusText: "处理中",
|
|
||||||
processResult: "待现场核查,请前往塔吾萨尼步道确认摄影师线索。",
|
|
||||||
processedAt: nil,
|
|
||||||
processingAt: "2024-05-18 10:35"
|
|
||||||
)
|
|
||||||
case clueID3:
|
|
||||||
return WildReportRadarProcessRecord(
|
|
||||||
handlerName: "赵强",
|
|
||||||
phone: "15938267120",
|
|
||||||
maskedPhone: maskPhone("15938267120"),
|
|
||||||
statusText: "已处理",
|
|
||||||
processResult: "已到场核查并完成劝离,处理结果已同步景区公安。",
|
|
||||||
processedAt: "2024-05-17 18:26",
|
|
||||||
processingAt: "2024-05-17 17:45"
|
|
||||||
)
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func buildProcessingTimeline(
|
|
||||||
record: WildReportRadarProcessRecord,
|
|
||||||
completed: Bool
|
|
||||||
) -> [WildReportRadarProcessingTimelineItem] {
|
|
||||||
let startedAt = record.processingAt ?? "2024-05-18 10:35"
|
|
||||||
return [
|
|
||||||
WildReportRadarProcessingTimelineItem(title: "处理时间", time: startedAt, status: "done"),
|
|
||||||
WildReportRadarProcessingTimelineItem(
|
|
||||||
title: "处理完成时间",
|
|
||||||
time: completed ? (record.processedAt ?? "刚刚") : "待完成",
|
|
||||||
status: completed ? "done" : "current"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func enrichClue(
|
|
||||||
_ base: WildReportRadarActiveClue,
|
|
||||||
processingStarted: Bool,
|
|
||||||
completed: Bool = false,
|
|
||||||
completionPhoto: String? = nil
|
|
||||||
) -> WildReportRadarActiveClue {
|
|
||||||
let record = processRecord(for: base.id)
|
|
||||||
let isProcessing = processingStarted && !completed
|
|
||||||
let timeline = (isProcessing || completed) && record != nil
|
|
||||||
? buildProcessingTimeline(record: record!, completed: completed)
|
|
||||||
: []
|
|
||||||
|
|
||||||
return WildReportRadarActiveClue(
|
|
||||||
id: base.id,
|
|
||||||
type: base.type,
|
|
||||||
displayTitle: base.displayTitle,
|
|
||||||
statusText: completed ? "已处理" : (isProcessing ? "正在处理中" : base.statusText),
|
|
||||||
actionText: base.actionText,
|
|
||||||
nearestAbnormalText: base.nearestAbnormalText,
|
|
||||||
distance: base.distance,
|
|
||||||
direction: base.direction,
|
|
||||||
updatedAt: completed ? (record?.processedAt ?? "刚刚") : base.updatedAt,
|
|
||||||
name: base.name,
|
|
||||||
storeName: base.storeName,
|
|
||||||
avatar: base.avatar,
|
|
||||||
summary: base.summary,
|
|
||||||
detail: base.detail,
|
|
||||||
distanceText: base.distanceText,
|
|
||||||
reporterName: base.reporterName,
|
|
||||||
reporterPhone: base.reporterPhone,
|
|
||||||
maskedReporterPhone: base.maskedReporterPhone,
|
|
||||||
reportContents: base.reportContents,
|
|
||||||
images: base.images,
|
|
||||||
processingStarted: processingStarted || completed,
|
|
||||||
completed: completed,
|
|
||||||
record: (isProcessing || completed) ? record : nil,
|
|
||||||
processingTimeline: timeline,
|
|
||||||
completionPhoto: completionPhoto
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func maskPhone(_ phone: String) -> String {
|
|
||||||
guard phone.count == 11, phone.allSatisfy(\.isNumber) else { return phone }
|
|
||||||
return "\(phone.prefix(3))****\(phone.suffix(4))"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func nearestAbnormalText(
|
|
||||||
blueCoordinate: CLLocationCoordinate2D,
|
|
||||||
redCoordinates: [CLLocationCoordinate2D]
|
|
||||||
) -> String {
|
|
||||||
guard !redCoordinates.isEmpty else { return "距离最近的异常点位0.00KM" }
|
|
||||||
let minKm = redCoordinates.map {
|
|
||||||
calcDistanceKm(
|
|
||||||
lat1: blueCoordinate.latitude,
|
|
||||||
lon1: blueCoordinate.longitude,
|
|
||||||
lat2: $0.latitude,
|
|
||||||
lon2: $0.longitude
|
|
||||||
)
|
|
||||||
}.min() ?? 0
|
|
||||||
return String(format: "距离最近的异常点位%.2fKM", minKm)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func calcDistanceKm(
|
|
||||||
lat1: Double,
|
|
||||||
lon1: Double,
|
|
||||||
lat2: Double,
|
|
||||||
lon2: Double
|
|
||||||
) -> Double {
|
|
||||||
let earthRadiusKm = 6371.0
|
|
||||||
let toRad = { (deg: Double) in deg * .pi / 180 }
|
|
||||||
let dLat = toRad(lat2 - lat1)
|
|
||||||
let dLon = toRad(lon2 - lon1)
|
|
||||||
let a = sin(dLat / 2) * sin(dLat / 2)
|
|
||||||
+ cos(toRad(lat1)) * cos(toRad(lat2)) * sin(dLon / 2) * sin(dLon / 2)
|
|
||||||
return earthRadiusKm * 2 * atan2(sqrt(a), sqrt(1 - a))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -22,126 +22,83 @@ protocol WildReportAttachmentUploading {
|
|||||||
|
|
||||||
extension OSSUploadService: WildReportAttachmentUploading {}
|
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 {
|
final class WildPhotographerReportHomeViewModel {
|
||||||
private(set) var pageState: WildReportPageState = .normal
|
private(set) var pageState: WildReportPageState = .normal
|
||||||
private(set) var reports: [WildReportRecord] = []
|
private(set) var noticeItems: [String] = []
|
||||||
private(set) var riskPoints: [WildReportRiskPoint] = []
|
private(set) var ruleGroups: [WildReportRuleGroup] = []
|
||||||
private(set) var supplementaryEvidenceMap: [String: [WildReportSupplementaryEvidence]] = [:]
|
private(set) var isLoadingCopy = false
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
|
var onShowMessage: ((String) -> Void)?
|
||||||
private var nextSequence = 0
|
|
||||||
private var nextSupplementSequence = 0
|
|
||||||
|
|
||||||
init() {
|
|
||||||
resetDemoData(notify: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
var emptyTitle: String { "暂无举报服务" }
|
var emptyTitle: String { "暂无举报服务" }
|
||||||
var emptyMessage: String { "当前景区暂未开放举报摄影师演示入口。" }
|
var emptyMessage: String { "当前景区暂未开放举报摄影师入口。" }
|
||||||
|
var shouldShowNoticeModule: Bool { !noticeItems.isEmpty || !ruleGroups.isEmpty }
|
||||||
|
var shouldShowRuleButton: Bool { !ruleGroups.isEmpty }
|
||||||
|
|
||||||
/// 恢复演示数据并回到正常状态。
|
/// 加载首页举报须知与规则文案。
|
||||||
func restoreDemoData() {
|
func loadReportCopy(api: any WildPhotographerReportServing) async {
|
||||||
resetDemoData(notify: false)
|
guard !isLoadingCopy else { return }
|
||||||
pageState = .normal
|
isLoadingCopy = true
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
defer {
|
||||||
|
isLoadingCopy = false
|
||||||
/// 模拟错误后重新加载。
|
|
||||||
func retryLoadAfterError() {
|
|
||||||
pageState = .loading
|
|
||||||
notifyStateChange()
|
|
||||||
Task {
|
|
||||||
try? await Task.sleep(nanoseconds: 450_000_000)
|
|
||||||
pageState = .normal
|
|
||||||
notifyStateChange()
|
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) {
|
func setPageState(_ state: WildReportPageState) {
|
||||||
pageState = state
|
pageState = state
|
||||||
notifyStateChange()
|
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() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
@ -153,8 +110,8 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
private(set) var images: [WildReportAttachment]
|
private(set) var images: [WildReportAttachment]
|
||||||
private(set) var videos: [WildReportAttachment]
|
private(set) var videos: [WildReportAttachment]
|
||||||
private(set) var paymentImages: [WildReportAttachment] = []
|
private(set) var paymentImages: [WildReportAttachment] = []
|
||||||
private(set) var reportTypes: [WildReportType] = WildReportType.fallbackOptions
|
private(set) var reportTypes: [WildReportType] = []
|
||||||
private(set) var selectedReportType: WildReportType = .defaultSelected
|
private(set) var selectedReportType: WildReportType?
|
||||||
private(set) var description = ""
|
private(set) var description = ""
|
||||||
private(set) var contact = ""
|
private(set) var contact = ""
|
||||||
private(set) var reportLocation = ""
|
private(set) var reportLocation = ""
|
||||||
@ -209,7 +166,7 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
refreshCurrentLocation()
|
refreshCurrentLocation()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从服务端加载举报类型,失败时回退到本地兜底选项。
|
/// 从服务端加载举报类型。
|
||||||
func loadReportTypes(api: any WildPhotographerReportServing) async {
|
func loadReportTypes(api: any WildPhotographerReportServing) async {
|
||||||
guard !isLoadingReportTypes else { return }
|
guard !isLoadingReportTypes else { return }
|
||||||
isLoadingReportTypes = true
|
isLoadingReportTypes = true
|
||||||
@ -222,22 +179,19 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
do {
|
do {
|
||||||
let response = try await api.reportTypes()
|
let response = try await api.reportTypes()
|
||||||
let loadedTypes = uniqueReportTypes(response.list)
|
let loadedTypes = uniqueReportTypes(response.list)
|
||||||
reportTypes = loadedTypes.isEmpty ? WildReportType.fallbackOptions : loadedTypes
|
reportTypes = loadedTypes
|
||||||
if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) {
|
if let currentSelectedType = selectedReportType,
|
||||||
|
let updatedType = reportTypes.first(where: { $0.value == currentSelectedType.value }) {
|
||||||
selectedReportType = updatedType
|
selectedReportType = updatedType
|
||||||
} else {
|
} else {
|
||||||
selectedReportType = reportTypes.first ?? .defaultSelected
|
selectedReportType = reportTypes.first
|
||||||
}
|
}
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
reportTypes = WildReportType.fallbackOptions
|
reportTypes = []
|
||||||
if let updatedType = reportTypes.first(where: { $0.value == selectedReportType.value }) {
|
selectedReportType = nil
|
||||||
selectedReportType = updatedType
|
onShowMessage?(error.localizedDescription)
|
||||||
} else {
|
|
||||||
selectedReportType = .defaultSelected
|
|
||||||
}
|
|
||||||
onShowMessage?("举报类型获取失败,已使用默认类型")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -270,11 +224,6 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加演示用现场图片。
|
|
||||||
func addImage() {
|
|
||||||
addImages([WildReportAttachment(kind: .image, title: "现场图片\(images.count + 1)")])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加本地现场视频。
|
/// 添加本地现场视频。
|
||||||
func addVideos(_ attachments: [WildReportAttachment]) {
|
func addVideos(_ attachments: [WildReportAttachment]) {
|
||||||
let videoRemaining = max(0, 3 - videos.count)
|
let videoRemaining = max(0, 3 - videos.count)
|
||||||
@ -288,11 +237,6 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加演示用现场视频。
|
|
||||||
func addVideo() {
|
|
||||||
addVideos([WildReportAttachment(kind: .video, title: "现场视频\(videos.count + 1)")])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加本地支付截图。
|
/// 添加本地支付截图。
|
||||||
func addPaymentImages(_ attachments: [WildReportAttachment]) {
|
func addPaymentImages(_ attachments: [WildReportAttachment]) {
|
||||||
guard paymentImages.count < 3 else {
|
guard paymentImages.count < 3 else {
|
||||||
@ -303,11 +247,6 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加演示用支付截图。
|
|
||||||
func addPaymentImage() {
|
|
||||||
addPaymentImages([WildReportAttachment(kind: .payment, title: "支付截图\(paymentImages.count + 1)")])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除指定附件。
|
/// 删除指定附件。
|
||||||
func removeAttachment(_ attachment: WildReportAttachment) {
|
func removeAttachment(_ attachment: WildReportAttachment) {
|
||||||
images.removeAll { $0.id == attachment.id }
|
images.removeAll { $0.id == attachment.id }
|
||||||
@ -329,11 +268,7 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
let snapshot = try await locationProvider.requestSnapshot()
|
let snapshot = try await locationProvider.requestSnapshot()
|
||||||
applyResolvedLocation(snapshot)
|
applyResolvedLocation(snapshot)
|
||||||
} catch {
|
} catch {
|
||||||
applyResolvedLocation(HomeLocationSnapshot(
|
applyLocationFailure(error.localizedDescription)
|
||||||
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
|
|
||||||
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
|
|
||||||
address: WildPhotographerReportMockStore.fallbackLocation
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -389,14 +324,7 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
paymentScreenshots: paymentRequests
|
paymentScreenshots: paymentRequests
|
||||||
))
|
))
|
||||||
|
|
||||||
let record = homeViewModel.submitReport(
|
let record = buildSubmittedRecord()
|
||||||
reportType: selectedReportType,
|
|
||||||
description: description,
|
|
||||||
location: reportLocation,
|
|
||||||
contact: contact.isEmpty ? nil : contact,
|
|
||||||
imageCount: images.count,
|
|
||||||
videoCount: videos.count
|
|
||||||
)
|
|
||||||
let result = WildReportSubmitResult(
|
let result = WildReportSubmitResult(
|
||||||
record: record,
|
record: record,
|
||||||
imageCount: images.count,
|
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() {
|
func resetForm() {
|
||||||
images = []
|
images = []
|
||||||
videos = []
|
videos = []
|
||||||
paymentImages = []
|
paymentImages = []
|
||||||
reportTypes = WildReportType.fallbackOptions
|
reportTypes = []
|
||||||
selectedReportType = .defaultSelected
|
selectedReportType = nil
|
||||||
description = ""
|
description = ""
|
||||||
contact = ""
|
contact = ""
|
||||||
reportLocation = ""
|
reportLocation = ""
|
||||||
@ -456,14 +361,6 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyResolvedLocation(_ location: String) {
|
|
||||||
applyResolvedLocation(HomeLocationSnapshot(
|
|
||||||
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
|
|
||||||
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
|
|
||||||
address: location
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyResolvedLocation(_ snapshot: HomeLocationSnapshot) {
|
func applyResolvedLocation(_ snapshot: HomeLocationSnapshot) {
|
||||||
let address = snapshot.address.isEmpty
|
let address = snapshot.address.isEmpty
|
||||||
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
|
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
|
||||||
@ -479,6 +376,15 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyLocationFailure(_ message: String) {
|
||||||
|
reportLocation = ""
|
||||||
|
locationSnapshot = nil
|
||||||
|
locationConfirmed = false
|
||||||
|
isUpdatingLocation = false
|
||||||
|
notifyStateChange()
|
||||||
|
onShowMessage?(message)
|
||||||
|
}
|
||||||
|
|
||||||
private func setValidationMessage(_ message: String) {
|
private func setValidationMessage(_ message: String) {
|
||||||
validationMessage = message
|
validationMessage = message
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
@ -503,6 +409,10 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
setValidationMessage("请先选择景区后再提交举报。")
|
setValidationMessage("请先选择景区后再提交举报。")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
guard selectedReportType != nil else {
|
||||||
|
setValidationMessage("请先选择举报类型。")
|
||||||
|
return false
|
||||||
|
}
|
||||||
guard !images.isEmpty || !videos.isEmpty else {
|
guard !images.isEmpty || !videos.isEmpty else {
|
||||||
setValidationMessage("请至少上传一项现场证据。")
|
setValidationMessage("请至少上传一项现场证据。")
|
||||||
return false
|
return false
|
||||||
@ -527,6 +437,10 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
setValidationMessage("请先更新举报位置后再提交举报。")
|
setValidationMessage("请先更新举报位置后再提交举报。")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
guard locationSnapshot != nil else {
|
||||||
|
setValidationMessage("缺少定位坐标,请重新更新举报位置。")
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -572,15 +486,11 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
let locationParts = WildReportLocationParser.split(reportLocation)
|
let locationParts = WildReportLocationParser.split(reportLocation)
|
||||||
let trimmedScenicName = scenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedScenicName = scenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let locationName = !trimmedScenicName.isEmpty ? trimmedScenicName : locationParts.scenic
|
let locationName = !trimmedScenicName.isEmpty ? trimmedScenicName : locationParts.scenic
|
||||||
let snapshot = locationSnapshot ?? HomeLocationSnapshot(
|
let snapshot = locationSnapshot ?? HomeLocationSnapshot(latitude: 0, longitude: 0, address: reportLocation)
|
||||||
latitude: WildPhotographerReportMockStore.nalatiCenter.latitude,
|
|
||||||
longitude: WildPhotographerReportMockStore.nalatiCenter.longitude,
|
|
||||||
address: reportLocation
|
|
||||||
)
|
|
||||||
let trimmedContact = contact.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmedContact = contact.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return WildReportSubmitRequest(
|
return WildReportSubmitRequest(
|
||||||
scenicId: scenicId,
|
scenicId: scenicId,
|
||||||
reportType: selectedReportType.value,
|
reportType: selectedReportType?.value ?? 0,
|
||||||
desc: description.trimmingCharacters(in: .whitespacesAndNewlines),
|
desc: description.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
storeUserId: nil,
|
storeUserId: nil,
|
||||||
contactPhone: trimmedContact.isEmpty ? nil : trimmedContact,
|
contactPhone: trimmedContact.isEmpty ? nil : trimmedContact,
|
||||||
@ -592,6 +502,25 @@ final class WildPhotographerReportSubmitViewModel {
|
|||||||
paymentScreenshots: paymentScreenshots
|
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。
|
/// 举报成功页 ViewModel。
|
||||||
@ -642,6 +571,17 @@ final class WildPhotographerReportDetailViewModel {
|
|||||||
let detail = WildReportLocationParser.split(record.location).detail
|
let detail = WildReportLocationParser.split(record.location).detail
|
||||||
return detail.contains("附近") ? detail : "\(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 displayStatusText: String { detail?.displayStatusText ?? record.status.rawValue }
|
||||||
var descriptionText: String { record.description }
|
var descriptionText: String { record.description }
|
||||||
@ -651,7 +591,7 @@ final class WildPhotographerReportDetailViewModel {
|
|||||||
if let detail {
|
if let detail {
|
||||||
return detail.supplementRecords
|
return detail.supplementRecords
|
||||||
}
|
}
|
||||||
return homeViewModel?.supplementaryEvidences(for: record.id) ?? []
|
return []
|
||||||
}
|
}
|
||||||
var reporterMediaItems: [WildReportReporterMediaItem] {
|
var reporterMediaItems: [WildReportReporterMediaItem] {
|
||||||
if let detail {
|
if let detail {
|
||||||
@ -718,7 +658,7 @@ final class WildPhotographerReportDetailViewModel {
|
|||||||
|
|
||||||
/// 展示媒体预览提示。
|
/// 展示媒体预览提示。
|
||||||
func showMediaPreview(kind: String) {
|
func showMediaPreview(kind: String) {
|
||||||
showActionMessage("演示环境暂不支持预览\(kind),可在提交页查看上传内容。")
|
showActionMessage("暂不支持预览\(kind)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 展示位置预览提示。
|
/// 展示位置预览提示。
|
||||||
@ -738,30 +678,14 @@ final class WildPhotographerReportDetailViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func buildProgressSteps(for record: WildReportRecord) -> [WildReportProgressStep] {
|
private static func buildProgressSteps(for record: WildReportRecord) -> [WildReportProgressStep] {
|
||||||
let baseDate = submitDate(from: record.submitTime) ?? Date()
|
[
|
||||||
let expectedFeedback = formatDate(baseDate.addingTimeInterval(8 * 3_600))
|
WildReportProgressStep(
|
||||||
|
id: "current-status",
|
||||||
switch record.status {
|
title: record.status.rawValue,
|
||||||
case .pending, .processing:
|
timeText: record.submitTime,
|
||||||
return [
|
state: record.status == .pending || record.status == .processing ? .current : .completed
|
||||||
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
|
|
||||||
)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func buildProgressSteps(
|
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() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
@ -855,13 +756,6 @@ final class WildPhotographerReportListViewModel {
|
|||||||
await reload(api: api, scenicId: scenicId)
|
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 {
|
func loadMoreIfNeeded(api: any WildPhotographerReportServing, scenicId: Int?) async {
|
||||||
guard hasMore, !isLoading, !isLoadingMore else { return }
|
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() {
|
private func notifyStateChange() {
|
||||||
onStateChange?()
|
onStateChange?()
|
||||||
}
|
}
|
||||||
@ -990,11 +875,6 @@ final class WildReportSupplementEvidenceViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加演示用补充图片。
|
|
||||||
func addImage() {
|
|
||||||
addImages([WildReportAttachment(kind: .image, title: "补充图片\(images.count + 1)")])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 添加本地补充视频。
|
/// 添加本地补充视频。
|
||||||
func addVideos(_ attachments: [WildReportAttachment]) {
|
func addVideos(_ attachments: [WildReportAttachment]) {
|
||||||
let videoRemaining = max(0, 3 - videos.count)
|
let videoRemaining = max(0, 3 - videos.count)
|
||||||
@ -1008,11 +888,6 @@ final class WildReportSupplementEvidenceViewModel {
|
|||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加演示用补充视频。
|
|
||||||
func addVideo() {
|
|
||||||
addVideos([WildReportAttachment(kind: .video, title: "补充视频\(videos.count + 1)")])
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 删除指定附件。
|
/// 删除指定附件。
|
||||||
func removeAttachment(_ attachment: WildReportAttachment) {
|
func removeAttachment(_ attachment: WildReportAttachment) {
|
||||||
images.removeAll { $0.id == attachment.id }
|
images.removeAll { $0.id == attachment.id }
|
||||||
@ -1055,12 +930,6 @@ final class WildReportSupplementEvidenceViewModel {
|
|||||||
evidences: evidenceRequests.isEmpty ? nil : evidenceRequests
|
evidences: evidenceRequests.isEmpty ? nil : evidenceRequests
|
||||||
))
|
))
|
||||||
|
|
||||||
homeViewModel.appendSupplementaryEvidence(
|
|
||||||
reportID: reportID,
|
|
||||||
text: trimmedText,
|
|
||||||
imageCount: images.count,
|
|
||||||
videoCount: videos.count
|
|
||||||
)
|
|
||||||
didSubmit = true
|
didSubmit = true
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
onSubmitSuccess?()
|
onSubmitSuccess?()
|
||||||
@ -1141,14 +1010,14 @@ final class WildReportSupplementEvidenceViewModel {
|
|||||||
|
|
||||||
/// 附近风险地图 ViewModel。
|
/// 附近风险地图 ViewModel。
|
||||||
final class WildReportRiskMapViewModel {
|
final class WildReportRiskMapViewModel {
|
||||||
private let fallbackMarkers: [WildReportMapMarker]
|
private static let riskMapLocationAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
|
|
||||||
private var loadedDetailMarkerIDs: Set<String> = []
|
private var loadedDetailMarkerIDs: Set<String> = []
|
||||||
|
|
||||||
private(set) var region: MKCoordinateRegion
|
private(set) var region: MKCoordinateRegion
|
||||||
private(set) var markers: [WildReportMapMarker]
|
private(set) var markers: [WildReportMapMarker]
|
||||||
private(set) var scenicAreaName: String
|
private(set) var scenicAreaName: String
|
||||||
private(set) var selectedMarkerID: String?
|
private(set) var selectedMarkerID: String?
|
||||||
private(set) var toastMessage: String?
|
|
||||||
private(set) var isLoading = false
|
private(set) var isLoading = false
|
||||||
private(set) var isLoadingDetail = false
|
private(set) var isLoadingDetail = false
|
||||||
private(set) var errorMessage: String?
|
private(set) var errorMessage: String?
|
||||||
@ -1157,16 +1026,13 @@ final class WildReportRiskMapViewModel {
|
|||||||
private(set) var currentCoordinate: CLLocationCoordinate2D?
|
private(set) var currentCoordinate: CLLocationCoordinate2D?
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
var onShowMessage: ((String) -> Void)?
|
|
||||||
|
|
||||||
init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) {
|
init() {
|
||||||
let markers = WildPhotographerReportMockStore.buildMarkers(record: record, riskPoints: riskPoints)
|
self.markers = []
|
||||||
self.fallbackMarkers = markers
|
self.region = Self.region(for: [])
|
||||||
self.markers = markers
|
|
||||||
self.region = WildPhotographerReportMockStore.region(for: markers)
|
|
||||||
let scenicName = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let scenicName = AppStore.shared.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
self.scenicAreaName = scenicName.isEmpty ? "那拉提景区" : scenicName
|
self.scenicAreaName = scenicName.isEmpty ? "当前景区" : scenicName
|
||||||
self.selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id
|
self.selectedMarkerID = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var selectedMarker: WildReportMapMarker? {
|
var selectedMarker: WildReportMapMarker? {
|
||||||
@ -1222,7 +1088,7 @@ final class WildReportRiskMapViewModel {
|
|||||||
mergeDetail(detail, intoMarkerID: marker.id)
|
mergeDetail(detail, intoMarkerID: marker.id)
|
||||||
loadedDetailMarkerIDs.insert(marker.id)
|
loadedDetailMarkerIDs.insert(marker.id)
|
||||||
} catch {
|
} catch {
|
||||||
showToast(error.localizedDescription)
|
errorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
isLoadingDetail = false
|
isLoadingDetail = false
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
@ -1246,6 +1112,19 @@ final class WildReportRiskMapViewModel {
|
|||||||
return marker
|
return marker
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 将地图快速移动到已加载的当前位置标记。
|
||||||
|
@discardableResult
|
||||||
|
func focusCurrentLocation() -> Bool {
|
||||||
|
guard let marker = markers.first(where: { $0.kind == .selfLocation }) else { return false }
|
||||||
|
selectedMarkerID = marker.id
|
||||||
|
region = MKCoordinateRegion(
|
||||||
|
center: marker.coordinate,
|
||||||
|
span: MKCoordinateSpan(latitudeDelta: 0.018, longitudeDelta: 0.018)
|
||||||
|
)
|
||||||
|
notifyStateChange()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/// 将地图居中到当前位置标记。
|
/// 将地图居中到当前位置标记。
|
||||||
func refreshLocation(
|
func refreshLocation(
|
||||||
api: any WildPhotographerReportServing,
|
api: any WildPhotographerReportServing,
|
||||||
@ -1264,21 +1143,12 @@ final class WildReportRiskMapViewModel {
|
|||||||
|
|
||||||
/// 生成线索分享提示。
|
/// 生成线索分享提示。
|
||||||
func shareSelectedClue() {
|
func shareSelectedClue() {
|
||||||
guard let clue = selectedClue else { return }
|
_ = selectedClue
|
||||||
let title = clue.type == .red ? "线索 ID:\(clue.id)" : clue.displayTitle
|
|
||||||
showToast("已生成\(title)的分享信息,可发送给他人查看。")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成图片预览提示。
|
/// 生成图片预览提示。
|
||||||
func showImagePreview(_ url: String) {
|
func showImagePreview(_ url: String) {
|
||||||
_ = url
|
_ = url
|
||||||
showToast("演示环境暂不支持预览图片")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showToast(_ message: String) {
|
|
||||||
toastMessage = message
|
|
||||||
onShowMessage?(message)
|
|
||||||
notifyStateChange()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadRiskMap(
|
private func loadRiskMap(
|
||||||
@ -1291,20 +1161,39 @@ final class WildReportRiskMapViewModel {
|
|||||||
let resolvedScenicId = scenicId ?? AppStore.shared.currentScenicId
|
let resolvedScenicId = scenicId ?? AppStore.shared.currentScenicId
|
||||||
guard resolvedScenicId >= 1 else {
|
guard resolvedScenicId >= 1 else {
|
||||||
errorMessage = "请先选择景区后再查看风险地图"
|
errorMessage = "请先选择景区后再查看风险地图"
|
||||||
showToast(errorMessage ?? "")
|
notifyStateChange()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let coordinate = await requestRiskMapCoordinate(locationProvider: locationProvider)
|
||||||
|
currentCoordinate = coordinate
|
||||||
|
await requestRiskMap(
|
||||||
|
api: api,
|
||||||
|
scenicId: resolvedScenicId,
|
||||||
|
scenicName: scenicName,
|
||||||
|
coordinate: coordinate,
|
||||||
|
selectCurrentLocation: selectCurrentLocation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestRiskMapCoordinate(locationProvider: any LocationProviding) async -> CLLocationCoordinate2D? {
|
||||||
|
try? await locationProvider.requestCoordinate(desiredAccuracy: Self.riskMapLocationAccuracy)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestRiskMap(
|
||||||
|
api: any WildPhotographerReportServing,
|
||||||
|
scenicId: Int,
|
||||||
|
scenicName: String?,
|
||||||
|
coordinate: CLLocationCoordinate2D?,
|
||||||
|
selectCurrentLocation: Bool
|
||||||
|
) async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
|
|
||||||
let coordinate = try? await locationProvider.requestCoordinate()
|
|
||||||
currentCoordinate = coordinate
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let response = try await api.riskMap(WildReportRiskMapRequest(
|
let response = try await api.riskMap(WildReportRiskMapRequest(
|
||||||
scenicId: resolvedScenicId,
|
scenicId: scenicId,
|
||||||
latitude: coordinate?.latitude,
|
latitude: coordinate?.latitude,
|
||||||
longitude: coordinate?.longitude
|
longitude: coordinate?.longitude
|
||||||
))
|
))
|
||||||
@ -1315,26 +1204,14 @@ final class WildReportRiskMapViewModel {
|
|||||||
greenMarkerTip = response.greenMarkerTip
|
greenMarkerTip = response.greenMarkerTip
|
||||||
legendItems = response.legend
|
legendItems = response.legend
|
||||||
markers = buildMarkers(response: response, currentCoordinate: coordinate)
|
markers = buildMarkers(response: response, currentCoordinate: coordinate)
|
||||||
if markers.isEmpty {
|
region = Self.region(for: markers)
|
||||||
markers = fallbackMarkers
|
|
||||||
}
|
|
||||||
region = WildPhotographerReportMockStore.region(for: markers)
|
|
||||||
if selectCurrentLocation, let current = markers.first(where: { $0.kind == .selfLocation }) {
|
if selectCurrentLocation, let current = markers.first(where: { $0.kind == .selfLocation }) {
|
||||||
selectedMarkerID = current.id
|
selectedMarkerID = current.id
|
||||||
} else if selectedMarker == nil {
|
} else if selectedMarker == nil {
|
||||||
selectedMarkerID = markers.first?.id
|
selectedMarkerID = markers.first?.id
|
||||||
}
|
}
|
||||||
if coordinate != nil {
|
|
||||||
showToast("已更新附近风险点位")
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
if markers.isEmpty {
|
|
||||||
markers = fallbackMarkers
|
|
||||||
region = WildPhotographerReportMockStore.region(for: markers)
|
|
||||||
selectedMarkerID = markers.first(where: { $0.kind == .selfLocation })?.id ?? markers.first?.id
|
|
||||||
}
|
|
||||||
showToast(error.localizedDescription)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
@ -1473,7 +1350,8 @@ final class WildReportRiskMapViewModel {
|
|||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
let markerType = WildReportRiskMarkerType(rawValue: detail.markerType.nonEmpty ?? oldClue.type.rawValue)
|
let markerType = WildReportRiskMarkerType(rawValue: detail.markerType.nonEmpty ?? oldClue.type.rawValue)
|
||||||
let statusText = detail.handleStatusText.nonEmpty
|
let statusText = detail.statusTag.nonEmpty
|
||||||
|
?? detail.handleStatusText.nonEmpty
|
||||||
?? detail.handleStatus?.displayText
|
?? detail.handleStatus?.displayText
|
||||||
?? oldClue.statusText
|
?? oldClue.statusText
|
||||||
let reportContents = !detail.reportContents.isEmpty
|
let reportContents = !detail.reportContents.isEmpty
|
||||||
@ -1481,7 +1359,7 @@ final class WildReportRiskMapViewModel {
|
|||||||
: (detail.desc.nonEmpty.map { [WildReportRadarReportContent(time: detail.createdAt, content: $0)] } ?? oldClue.reportContents)
|
: (detail.desc.nonEmpty.map { [WildReportRadarReportContent(time: detail.createdAt, content: $0)] } ?? oldClue.reportContents)
|
||||||
let imageURLs = detail.evidences.filter(\.isImage).map(\.fileURL)
|
let imageURLs = detail.evidences.filter(\.isImage).map(\.fileURL)
|
||||||
let clue = WildReportRadarActiveClue(
|
let clue = WildReportRadarActiveClue(
|
||||||
id: String(detail.id > 0 ? detail.id : Int(oldClue.id) ?? 0),
|
id: detail.clueId.nonEmpty ?? String(detail.id > 0 ? detail.id : Int(oldClue.id) ?? 0),
|
||||||
type: markerType.radarType,
|
type: markerType.radarType,
|
||||||
displayTitle: detail.title.nonEmpty ?? oldClue.displayTitle,
|
displayTitle: detail.title.nonEmpty ?? oldClue.displayTitle,
|
||||||
statusText: statusText,
|
statusText: statusText,
|
||||||
@ -1538,7 +1416,7 @@ final class WildReportRiskMapViewModel {
|
|||||||
case .yellow:
|
case .yellow:
|
||||||
return "处理中线索"
|
return "处理中线索"
|
||||||
case .red, .unknown:
|
case .red, .unknown:
|
||||||
return dto.reportTypeText.nonEmpty ?? "疑似打野"
|
return dto.reportTypeText.nonEmpty ?? "异常线索"
|
||||||
case .blue:
|
case .blue:
|
||||||
return "当前位置"
|
return "当前位置"
|
||||||
}
|
}
|
||||||
@ -1563,6 +1441,32 @@ final class WildReportRiskMapViewModel {
|
|||||||
return String(format: "%.1f公里", meters / 1000)
|
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 {
|
private static func maskPhone(_ phone: String) -> String {
|
||||||
guard phone.count == 11, phone.allSatisfy(\.isNumber) else { return phone }
|
guard phone.count == 11, phone.allSatisfy(\.isNumber) else { return phone }
|
||||||
return "\(phone.prefix(3))****\(phone.suffix(4))"
|
return "\(phone.prefix(3))****\(phone.suffix(4))"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# 举报摄影师接口文档
|
# 举报摄影师接口文档
|
||||||
|
|
||||||
本文档记录 iOS `WildPhotographerReport` 模块当前使用的后端接口,以及仍处于 Mock 阶段的业务点。
|
本文档记录 iOS `WildPhotographerReport` 模块当前使用的后端接口。
|
||||||
|
|
||||||
## 基础约定
|
## 基础约定
|
||||||
|
|
||||||
@ -9,7 +9,59 @@
|
|||||||
- 网络入口:`NetworkServices.shared.wildPhotographerReportAPI`
|
- 网络入口:`NetworkServices.shared.wildPhotographerReportAPI`
|
||||||
- 后端成功码:`100000`
|
- 后端成功码:`100000`
|
||||||
- 响应包裹:工程统一 `APIEnvelope<T>`
|
- 响应包裹:工程统一 `APIEnvelope<T>`
|
||||||
- 当前状态:“举报类型”、“提交举报”、“我的举报列表”、“举报详情”和“补充证据提交”已接接口;风险地图仍使用 Mock 数据闭环。
|
- 当前状态:“首页文案”、“举报类型”、“提交举报”、“我的举报列表”、“举报详情”、“补充证据提交”和“附近风险地图”已接接口;模块内生产 Mock 数据已移除。
|
||||||
|
|
||||||
|
## 首页文案
|
||||||
|
|
||||||
|
### 用途
|
||||||
|
|
||||||
|
“举报摄影师”首页进入时拉取举报须知和举报规则文案。
|
||||||
|
|
||||||
|
### 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/yf-handset-app/photog/report/copy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"notice": [
|
||||||
|
"需实名登录",
|
||||||
|
"请尽量上传清晰证据",
|
||||||
|
"恶意举报将被追责"
|
||||||
|
],
|
||||||
|
"rule_groups": [
|
||||||
|
{
|
||||||
|
"title": "可举报行为",
|
||||||
|
"items": [
|
||||||
|
"疑似打野摄影师主动揽客",
|
||||||
|
"未佩戴工牌开展摄影服务",
|
||||||
|
"引导游客线下付款或私下交易"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": "2026-07-09 10:15:17"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### UI 行为
|
||||||
|
|
||||||
|
- `notice` 用于首页“举报须知”条目展示。
|
||||||
|
- `rule_groups` 非空时显示“举报规则”按钮,点击后在举报规则页按分组展示 `title` 和 `items`。
|
||||||
|
- `notice` 与 `rule_groups` 都为空时,隐藏整个“举报须知”模块。
|
||||||
|
- 接口失败时不展示本地写死兜底文案,并 toast 错误。
|
||||||
|
|
||||||
|
### 当前调用位置
|
||||||
|
|
||||||
|
- `WildPhotographerReportHomeViewController.viewDidLoad()`
|
||||||
|
- `WildPhotographerReportHomeViewModel.loadReportCopy(api:)`
|
||||||
|
- `WildPhotographerReportAPI.reportCopy()`
|
||||||
|
|
||||||
## 1. 获取举报类型
|
## 1. 获取举报类型
|
||||||
|
|
||||||
@ -77,7 +129,7 @@ struct WildReportTypeListResponse: Decodable, Hashable {
|
|||||||
- 每行最多展示 3 个类型,超过 3 个自动换行。
|
- 每行最多展示 3 个类型,超过 3 个自动换行。
|
||||||
- 按 `label` 展示文字,不展示图标。
|
- 按 `label` 展示文字,不展示图标。
|
||||||
- 默认选中 `value == 2` 的类型;如果接口返回中不存在该类型,则选中列表第一项。
|
- 默认选中 `value == 2` 的类型;如果接口返回中不存在该类型,则选中列表第一项。
|
||||||
- 接口失败时回退本地兜底列表,并提示“举报类型获取失败,已使用默认类型”。
|
- 接口失败时不展示本地默认类型,并 toast 错误。
|
||||||
|
|
||||||
### 当前调用位置
|
### 当前调用位置
|
||||||
|
|
||||||
@ -139,7 +191,7 @@ POST /api/yf-handset-app/photog/report/submit
|
|||||||
- `store_user_id`:被举报摄影师 ID;当前页面无来源,本轮暂不传。
|
- `store_user_id`:被举报摄影师 ID;当前页面无来源,本轮暂不传。
|
||||||
- `contact_phone`:当前联系方式输入值,非空才传。
|
- `contact_phone`:当前联系方式输入值,非空才传。
|
||||||
- `location_name` / `location_address`:从当前定位展示文案拆分;无法拆分时使用景区名和完整地址兜底。
|
- `location_name` / `location_address`:从当前定位展示文案拆分;无法拆分时使用景区名和完整地址兜底。
|
||||||
- `lat` / `lng`:当前定位快照坐标;定位失败时使用 Mock 地址对应的兜底坐标。
|
- `lat` / `lng`:当前定位快照坐标;定位失败时不提交举报,提示用户重新获取位置。
|
||||||
- `evidences`:现场证据,必填,至少 1 项、最多 9 项。
|
- `evidences`:现场证据,必填,至少 1 项、最多 9 项。
|
||||||
- `payment_screenshots`:线下微信、支付宝截图,选填,最多 3 张。
|
- `payment_screenshots`:线下微信、支付宝截图,选填,最多 3 张。
|
||||||
- `file_type`:举报提交接口约定为 `1 = 图片`,`2 = 视频`。
|
- `file_type`:举报提交接口约定为 `1 = 图片`,`2 = 视频`。
|
||||||
@ -307,7 +359,7 @@ GET /api/yf-handset-app/photog/report/detail?id=32
|
|||||||
- `location_name` / `location_address`:详情页位置展示。
|
- `location_name` / `location_address`:详情页位置展示。
|
||||||
- `handle_status` / `handle_status_text`:处理状态;iOS 支持 0 待处理、1 处理中、2 已处理、3 驳回。
|
- `handle_status` / `handle_status_text`:处理状态;iOS 支持 0 待处理、1 处理中、2 已处理、3 驳回。
|
||||||
- `handle_remark`:处理备注,有值时在详情信息卡展示。
|
- `handle_remark`:处理备注,有值时在详情信息卡展示。
|
||||||
- `timeline`:处理进度时间线;为空时使用本地状态推导兜底。
|
- `timeline`:处理进度时间线;为空时只展示列表记录中的当前处理状态,不生成本地模拟流程。
|
||||||
|
|
||||||
### iOS 模型
|
### iOS 模型
|
||||||
|
|
||||||
@ -368,11 +420,8 @@ POST /api/yf-handset-app/photog/report/supplement
|
|||||||
- `WildPhotographerReportAPI.supplementReport(_:)`
|
- `WildPhotographerReportAPI.supplementReport(_:)`
|
||||||
- `WildReportSupplementEvidenceViewController.submitTapped()`
|
- `WildReportSupplementEvidenceViewController.submitTapped()`
|
||||||
|
|
||||||
## Mock 阶段业务
|
## 生产 Mock 清理
|
||||||
|
|
||||||
以下业务当前仍由 `WildPhotographerReportMockStore` 和 ViewModel 本地逻辑完成,暂未接真实接口:
|
- 已删除本地种子举报、种子风险点、种子补充证据及对应生产代码文件。
|
||||||
|
- 举报类型、首页文案、列表、详情、补充证据和风险地图均不再使用本地写死数据兜底。
|
||||||
- 附近风险地图
|
- 提交接口当前无业务 `data` 返回;提交成功页只展示本次用户提交快照,不伪造服务端举报编号,也不写入“我的举报”本地列表。
|
||||||
- 分享、媒体预览、地图导航演示提示
|
|
||||||
|
|
||||||
后续接入真实接口时,应优先在 `WildPhotographerReportServing` 中补充语义化方法,并保持 ViewModel 不依赖 UIKit 视图类型。
|
|
||||||
|
|||||||
109
suixinkan/UI/Common/MediaPreviewItem.swift
Normal file
109
suixinkan/UI/Common/MediaPreviewItem.swift
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
//
|
||||||
|
// MediaPreviewItem.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 媒体预览资源项,描述可全屏预览的图片或视频。
|
||||||
|
public struct MediaPreviewItem: Hashable {
|
||||||
|
|
||||||
|
/// 媒体类型。
|
||||||
|
public enum MediaType: Equatable {
|
||||||
|
case image
|
||||||
|
case video
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 预览资源来源。
|
||||||
|
public enum Source {
|
||||||
|
case remoteImage(URL)
|
||||||
|
case localImageFile(URL)
|
||||||
|
case image(UIImage)
|
||||||
|
case remoteVideo(URL)
|
||||||
|
case localVideoFile(URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 资源唯一标识,用于 Diffable Data Source。
|
||||||
|
public let id: UUID
|
||||||
|
|
||||||
|
/// 预览资源来源。
|
||||||
|
public let source: Source
|
||||||
|
|
||||||
|
/// 创建媒体预览资源项。
|
||||||
|
public init(id: UUID = UUID(), source: Source) {
|
||||||
|
self.id = id
|
||||||
|
self.source = source
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 媒体类型。
|
||||||
|
public var mediaType: MediaType {
|
||||||
|
switch source {
|
||||||
|
case .remoteImage, .localImageFile, .image:
|
||||||
|
return .image
|
||||||
|
case .remoteVideo, .localVideoFile:
|
||||||
|
return .video
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否为图片资源。
|
||||||
|
public var isImage: Bool {
|
||||||
|
mediaType == .image
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否为视频资源。
|
||||||
|
public var isVideo: Bool {
|
||||||
|
mediaType == .video
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通过远程图片地址创建资源项,空字符串或非法地址返回 nil。
|
||||||
|
public static func remoteImage(_ urlString: String) -> MediaPreviewItem? {
|
||||||
|
makeRemoteURL(from: urlString).map { MediaPreviewItem(source: .remoteImage($0)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通过远程视频地址创建资源项,空字符串或非法地址返回 nil。
|
||||||
|
public static func remoteVideo(_ urlString: String) -> MediaPreviewItem? {
|
||||||
|
makeRemoteURL(from: urlString).map { MediaPreviewItem(source: .remoteVideo($0)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通过本地图片文件地址创建资源项。
|
||||||
|
public static func localImage(url: URL) -> MediaPreviewItem {
|
||||||
|
MediaPreviewItem(source: .localImageFile(url))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通过本地图片对象创建资源项。
|
||||||
|
public static func image(_ image: UIImage) -> MediaPreviewItem {
|
||||||
|
MediaPreviewItem(source: .image(image))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通过本地视频文件地址创建资源项。
|
||||||
|
public static func localVideo(url: URL) -> MediaPreviewItem {
|
||||||
|
MediaPreviewItem(source: .localVideoFile(url))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将起始索引限制在可用范围内。
|
||||||
|
public static func clampedStartIndex(_ startIndex: Int, itemCount: Int) -> Int {
|
||||||
|
guard itemCount > 0 else { return 0 }
|
||||||
|
return min(max(0, startIndex), itemCount - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从媒体数组中读取安全的起始资源项。
|
||||||
|
public static func currentItem(in items: [MediaPreviewItem], startIndex: Int) -> MediaPreviewItem? {
|
||||||
|
guard !items.isEmpty else { return nil }
|
||||||
|
return items[clampedStartIndex(startIndex, itemCount: items.count)]
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func == (lhs: MediaPreviewItem, rhs: MediaPreviewItem) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
|
||||||
|
public func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeRemoteURL(from text: String) -> URL? {
|
||||||
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
|
||||||
|
guard let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme) else { return nil }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
574
suixinkan/UI/Common/MediaPreviewViewController.swift
Normal file
574
suixinkan/UI/Common/MediaPreviewViewController.swift
Normal file
@ -0,0 +1,574 @@
|
|||||||
|
//
|
||||||
|
// MediaPreviewViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Kingfisher
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 通用全屏媒体预览页,支持图片、视频混排横向预览。
|
||||||
|
class MediaPreviewViewController: UIViewController {
|
||||||
|
private enum Section {
|
||||||
|
case main
|
||||||
|
}
|
||||||
|
|
||||||
|
private let items: [MediaPreviewItem]
|
||||||
|
private let startIndex: Int
|
||||||
|
private var currentIndex: Int
|
||||||
|
private var dataSource: UICollectionViewDiffableDataSource<Section, MediaPreviewItem>!
|
||||||
|
|
||||||
|
private lazy var collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeCollectionLayout())
|
||||||
|
private let closeButton = UIButton(type: .system)
|
||||||
|
private let counterLabel = UILabel()
|
||||||
|
|
||||||
|
/// 创建通用媒体预览页。
|
||||||
|
init(items: [MediaPreviewItem], startIndex: Int = 0) {
|
||||||
|
self.items = items
|
||||||
|
self.startIndex = MediaPreviewItem.clampedStartIndex(startIndex, itemCount: items.count)
|
||||||
|
self.currentIndex = self.startIndex
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
modalPresentationStyle = .fullScreen
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从指定控制器全屏弹出媒体预览页。
|
||||||
|
static func present(from viewController: UIViewController, items: [MediaPreviewItem], startIndex: Int = 0) {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
let preview = MediaPreviewViewController(items: items, startIndex: startIndex)
|
||||||
|
viewController.present(preview, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
setupUI()
|
||||||
|
setupDataSource()
|
||||||
|
applySnapshot()
|
||||||
|
registerNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
guard collectionView.bounds.width > 0 else { return }
|
||||||
|
collectionView.collectionViewLayout.invalidateLayout()
|
||||||
|
collectionView.setContentOffset(
|
||||||
|
CGPoint(x: CGFloat(currentIndex) * collectionView.bounds.width, y: 0),
|
||||||
|
animated: false
|
||||||
|
)
|
||||||
|
updateVisibleCellsPlayback()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
updateVisibleCellsPlayback()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
|
super.viewWillDisappear(animated)
|
||||||
|
pauseVisibleVideos()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
view.backgroundColor = .black
|
||||||
|
collectionView.backgroundColor = .black
|
||||||
|
collectionView.isPagingEnabled = true
|
||||||
|
collectionView.showsHorizontalScrollIndicator = false
|
||||||
|
collectionView.alwaysBounceHorizontal = items.count > 1
|
||||||
|
collectionView.delegate = self
|
||||||
|
collectionView.register(MediaPreviewImageCell.self, forCellWithReuseIdentifier: MediaPreviewImageCell.reuseIdentifier)
|
||||||
|
collectionView.register(MediaPreviewVideoCell.self, forCellWithReuseIdentifier: MediaPreviewVideoCell.reuseIdentifier)
|
||||||
|
|
||||||
|
closeButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
|
||||||
|
closeButton.tintColor = .white
|
||||||
|
closeButton.backgroundColor = UIColor.black.withAlphaComponent(0.25)
|
||||||
|
closeButton.layer.cornerRadius = 18
|
||||||
|
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
counterLabel.textColor = .white
|
||||||
|
counterLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
counterLabel.textAlignment = .center
|
||||||
|
counterLabel.backgroundColor = UIColor.black.withAlphaComponent(0.35)
|
||||||
|
counterLabel.layer.cornerRadius = 14
|
||||||
|
counterLabel.clipsToBounds = true
|
||||||
|
updateCounter()
|
||||||
|
|
||||||
|
view.addSubview(collectionView)
|
||||||
|
view.addSubview(closeButton)
|
||||||
|
view.addSubview(counterLabel)
|
||||||
|
|
||||||
|
collectionView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
closeButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(12)
|
||||||
|
make.trailing.equalToSuperview().inset(16)
|
||||||
|
make.size.equalTo(36)
|
||||||
|
}
|
||||||
|
counterLabel.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.centerY.equalTo(closeButton)
|
||||||
|
make.height.equalTo(28)
|
||||||
|
make.width.greaterThanOrEqualTo(64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupDataSource() {
|
||||||
|
dataSource = UICollectionViewDiffableDataSource<Section, MediaPreviewItem>(
|
||||||
|
collectionView: collectionView
|
||||||
|
) { [weak self] collectionView, indexPath, item in
|
||||||
|
switch item.source {
|
||||||
|
case .remoteImage, .localImageFile, .image:
|
||||||
|
guard let cell = collectionView.dequeueReusableCell(
|
||||||
|
withReuseIdentifier: MediaPreviewImageCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as? MediaPreviewImageCell else {
|
||||||
|
return UICollectionViewCell()
|
||||||
|
}
|
||||||
|
cell.configure(source: item.source)
|
||||||
|
return cell
|
||||||
|
case .remoteVideo, .localVideoFile:
|
||||||
|
guard let cell = collectionView.dequeueReusableCell(
|
||||||
|
withReuseIdentifier: MediaPreviewVideoCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as? MediaPreviewVideoCell else {
|
||||||
|
return UICollectionViewCell()
|
||||||
|
}
|
||||||
|
cell.configure(source: item.source)
|
||||||
|
cell.setActive(indexPath.item == self?.currentIndex)
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySnapshot() {
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Section, MediaPreviewItem>()
|
||||||
|
snapshot.appendSections([.main])
|
||||||
|
snapshot.appendItems(items)
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCollectionLayout() -> UICollectionViewLayout {
|
||||||
|
let layout = UICollectionViewFlowLayout()
|
||||||
|
layout.scrollDirection = .horizontal
|
||||||
|
layout.minimumLineSpacing = 0
|
||||||
|
layout.minimumInteritemSpacing = 0
|
||||||
|
return layout
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registerNotifications() {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(appDidEnterBackground),
|
||||||
|
name: UIApplication.didEnterBackgroundNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateCurrentIndex() {
|
||||||
|
guard collectionView.bounds.width > 0 else { return }
|
||||||
|
let page = Int(round(collectionView.contentOffset.x / collectionView.bounds.width))
|
||||||
|
currentIndex = min(max(0, page), max(0, items.count - 1))
|
||||||
|
updateCounter()
|
||||||
|
updateVisibleCellsPlayback()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateCounter() {
|
||||||
|
counterLabel.text = items.isEmpty ? "0/0" : "\(currentIndex + 1)/\(items.count)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateVisibleCellsPlayback() {
|
||||||
|
for cell in collectionView.visibleCells {
|
||||||
|
guard let indexPath = collectionView.indexPath(for: cell),
|
||||||
|
let videoCell = cell as? MediaPreviewVideoCell
|
||||||
|
else { continue }
|
||||||
|
videoCell.setActive(indexPath.item == currentIndex && view.window != nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pauseVisibleVideos() {
|
||||||
|
collectionView.visibleCells
|
||||||
|
.compactMap { $0 as? MediaPreviewVideoCell }
|
||||||
|
.forEach { $0.setActive(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func closeTapped() {
|
||||||
|
pauseVisibleVideos()
|
||||||
|
dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func appDidEnterBackground() {
|
||||||
|
pauseVisibleVideos()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MediaPreviewViewController: UICollectionViewDelegateFlowLayout {
|
||||||
|
func collectionView(
|
||||||
|
_ collectionView: UICollectionView,
|
||||||
|
layout collectionViewLayout: UICollectionViewLayout,
|
||||||
|
sizeForItemAt indexPath: IndexPath
|
||||||
|
) -> CGSize {
|
||||||
|
collectionView.bounds.size
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||||
|
updateCurrentIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||||
|
updateCurrentIndex()
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
guard scrollView.isDragging || scrollView.isDecelerating else { return }
|
||||||
|
pauseVisibleVideos()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图片预览 Cell,支持远程、本地与内存图片缩放。
|
||||||
|
private final class MediaPreviewImageCell: UICollectionViewCell, UIScrollViewDelegate {
|
||||||
|
static let reuseIdentifier = "MediaPreviewImageCell"
|
||||||
|
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let imageView = UIImageView()
|
||||||
|
private let messageLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
imageView.kf.cancelDownloadTask()
|
||||||
|
imageView.image = nil
|
||||||
|
messageLabel.isHidden = true
|
||||||
|
scrollView.zoomScale = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
imageView.frame = scrollView.bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 配置图片资源来源。
|
||||||
|
func configure(source: MediaPreviewItem.Source) {
|
||||||
|
messageLabel.isHidden = true
|
||||||
|
scrollView.zoomScale = 1
|
||||||
|
switch source {
|
||||||
|
case .remoteImage(let url):
|
||||||
|
imageView.kf.setImage(with: url) { [weak self] result in
|
||||||
|
if case .failure = result {
|
||||||
|
self?.showMessage("图片加载失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .localImageFile(let url):
|
||||||
|
if let image = UIImage(contentsOfFile: url.path) {
|
||||||
|
imageView.image = image
|
||||||
|
} else {
|
||||||
|
showMessage("图片加载失败")
|
||||||
|
}
|
||||||
|
case .image(let image):
|
||||||
|
imageView.image = image
|
||||||
|
case .remoteVideo, .localVideoFile:
|
||||||
|
showMessage("不支持的图片资源")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
|
||||||
|
imageView
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
backgroundColor = .black
|
||||||
|
scrollView.delegate = self
|
||||||
|
scrollView.minimumZoomScale = 1
|
||||||
|
scrollView.maximumZoomScale = 3
|
||||||
|
scrollView.showsHorizontalScrollIndicator = false
|
||||||
|
scrollView.showsVerticalScrollIndicator = false
|
||||||
|
scrollView.contentInsetAdjustmentBehavior = .never
|
||||||
|
|
||||||
|
imageView.contentMode = .scaleAspectFit
|
||||||
|
imageView.backgroundColor = .black
|
||||||
|
|
||||||
|
messageLabel.textColor = UIColor.white.withAlphaComponent(0.8)
|
||||||
|
messageLabel.font = .systemFont(ofSize: 15)
|
||||||
|
messageLabel.textAlignment = .center
|
||||||
|
messageLabel.isHidden = true
|
||||||
|
|
||||||
|
contentView.addSubview(scrollView)
|
||||||
|
scrollView.addSubview(imageView)
|
||||||
|
contentView.addSubview(messageLabel)
|
||||||
|
|
||||||
|
scrollView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
messageLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.leading.trailing.equalToSuperview().inset(32)
|
||||||
|
}
|
||||||
|
|
||||||
|
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:)))
|
||||||
|
doubleTap.numberOfTapsRequired = 2
|
||||||
|
scrollView.addGestureRecognizer(doubleTap)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showMessage(_ text: String) {
|
||||||
|
imageView.image = nil
|
||||||
|
messageLabel.text = text
|
||||||
|
messageLabel.isHidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) {
|
||||||
|
if scrollView.zoomScale > 1 {
|
||||||
|
scrollView.setZoomScale(1, animated: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let point = gesture.location(in: imageView)
|
||||||
|
let targetScale = scrollView.maximumZoomScale
|
||||||
|
let width = scrollView.bounds.width / targetScale
|
||||||
|
let height = scrollView.bounds.height / targetScale
|
||||||
|
let rect = CGRect(x: point.x - width / 2, y: point.y - height / 2, width: width, height: height)
|
||||||
|
scrollView.zoom(to: rect, animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 视频预览 Cell,负责单个 AVPlayer 的播放与暂停。
|
||||||
|
private final class MediaPreviewVideoCell: UICollectionViewCell, UIGestureRecognizerDelegate {
|
||||||
|
static let reuseIdentifier = "MediaPreviewVideoCell"
|
||||||
|
|
||||||
|
private let playerView = MediaPreviewPlayerView()
|
||||||
|
private let playButton = MediaPreviewPlaybackButton()
|
||||||
|
private let messageLabel = UILabel()
|
||||||
|
private var player: AVPlayer?
|
||||||
|
private var didReachEnd = false
|
||||||
|
private var isActive = false
|
||||||
|
private var isUserPaused = false
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func prepareForReuse() {
|
||||||
|
super.prepareForReuse()
|
||||||
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
player?.pause()
|
||||||
|
player = nil
|
||||||
|
playerView.playerLayer.player = nil
|
||||||
|
didReachEnd = false
|
||||||
|
isActive = false
|
||||||
|
isUserPaused = false
|
||||||
|
messageLabel.isHidden = true
|
||||||
|
playButton.isHidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 配置视频资源来源。
|
||||||
|
func configure(source: MediaPreviewItem.Source) {
|
||||||
|
messageLabel.isHidden = true
|
||||||
|
didReachEnd = false
|
||||||
|
isUserPaused = false
|
||||||
|
let url: URL?
|
||||||
|
switch source {
|
||||||
|
case .remoteVideo(let remoteURL), .localVideoFile(let remoteURL):
|
||||||
|
url = remoteURL
|
||||||
|
case .remoteImage, .localImageFile, .image:
|
||||||
|
url = nil
|
||||||
|
}
|
||||||
|
guard let url else {
|
||||||
|
showMessage("视频加载失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let player = AVPlayer(url: url)
|
||||||
|
player.actionAtItemEnd = .pause
|
||||||
|
self.player = player
|
||||||
|
playerView.playerLayer.player = player
|
||||||
|
if let item = player.currentItem {
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(videoDidEnd),
|
||||||
|
name: .AVPlayerItemDidPlayToEndTime,
|
||||||
|
object: item
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据是否为当前页切换视频播放状态。
|
||||||
|
func setActive(_ active: Bool) {
|
||||||
|
isActive = active
|
||||||
|
guard let player else { return }
|
||||||
|
guard active else {
|
||||||
|
player.pause()
|
||||||
|
playButton.isHidden = !didReachEnd
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if didReachEnd {
|
||||||
|
showPlaybackButton(systemName: "pause.fill")
|
||||||
|
} else if isUserPaused {
|
||||||
|
showPlaybackButton(systemName: "play.fill")
|
||||||
|
player.pause()
|
||||||
|
} else {
|
||||||
|
playButton.isHidden = true
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
backgroundColor = .black
|
||||||
|
playerView.playerLayer.videoGravity = .resizeAspect
|
||||||
|
|
||||||
|
playButton.setSymbol(named: "pause.fill")
|
||||||
|
playButton.addTarget(self, action: #selector(playTapped), for: .touchUpInside)
|
||||||
|
|
||||||
|
messageLabel.textColor = UIColor.white.withAlphaComponent(0.8)
|
||||||
|
messageLabel.font = .systemFont(ofSize: 15)
|
||||||
|
messageLabel.textAlignment = .center
|
||||||
|
messageLabel.isHidden = true
|
||||||
|
|
||||||
|
contentView.addSubview(playerView)
|
||||||
|
contentView.addSubview(playButton)
|
||||||
|
contentView.addSubview(messageLabel)
|
||||||
|
|
||||||
|
playerView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
playButton.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.size.equalTo(96)
|
||||||
|
}
|
||||||
|
messageLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.leading.trailing.equalToSuperview().inset(32)
|
||||||
|
}
|
||||||
|
|
||||||
|
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(videoTapped))
|
||||||
|
tapGesture.delegate = self
|
||||||
|
contentView.addGestureRecognizer(tapGesture)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showMessage(_ text: String) {
|
||||||
|
messageLabel.text = text
|
||||||
|
messageLabel.isHidden = false
|
||||||
|
playButton.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showPlaybackButton(systemName: String) {
|
||||||
|
playButton.setSymbol(named: systemName)
|
||||||
|
playButton.isHidden = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPlaybackFromCurrentPosition() {
|
||||||
|
guard let player else { return }
|
||||||
|
if didReachEnd {
|
||||||
|
player.seek(to: .zero)
|
||||||
|
didReachEnd = false
|
||||||
|
}
|
||||||
|
isUserPaused = false
|
||||||
|
playButton.isHidden = true
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||||
|
guard touch.view?.isDescendant(of: playButton) != true else { return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func playTapped() {
|
||||||
|
startPlaybackFromCurrentPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func videoTapped() {
|
||||||
|
guard isActive, let player else { return }
|
||||||
|
if didReachEnd || isUserPaused {
|
||||||
|
startPlaybackFromCurrentPosition()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isUserPaused = true
|
||||||
|
player.pause()
|
||||||
|
showPlaybackButton(systemName: "play.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func videoDidEnd() {
|
||||||
|
didReachEnd = true
|
||||||
|
isUserPaused = false
|
||||||
|
showPlaybackButton(systemName: "pause.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 承载 AVPlayerLayer 的视频预览视图。
|
||||||
|
private final class MediaPreviewPlayerView: UIView {
|
||||||
|
override class var layerClass: AnyClass { AVPlayerLayer.self }
|
||||||
|
var playerLayer: AVPlayerLayer { layer as! AVPlayerLayer }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 视频预览播放状态按钮,提供大号图标与柔和渐变背景。
|
||||||
|
private final class MediaPreviewPlaybackButton: UIButton {
|
||||||
|
private let gradientLayer = CAGradientLayer()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
gradientLayer.frame = bounds
|
||||||
|
gradientLayer.cornerRadius = bounds.width / 2
|
||||||
|
layer.cornerRadius = bounds.width / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置按钮当前展示的 SF Symbol。
|
||||||
|
func setSymbol(named systemName: String) {
|
||||||
|
let configuration = UIImage.SymbolConfiguration(pointSize: 42, weight: .semibold)
|
||||||
|
setImage(UIImage(systemName: systemName, withConfiguration: configuration), for: .normal)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
tintColor = .white
|
||||||
|
backgroundColor = UIColor.white.withAlphaComponent(0.08)
|
||||||
|
adjustsImageWhenHighlighted = false
|
||||||
|
layer.masksToBounds = false
|
||||||
|
layer.shadowColor = UIColor.black.cgColor
|
||||||
|
layer.shadowOpacity = 0.28
|
||||||
|
layer.shadowRadius = 18
|
||||||
|
layer.shadowOffset = CGSize(width: 0, height: 10)
|
||||||
|
|
||||||
|
gradientLayer.colors = [
|
||||||
|
UIColor.white.withAlphaComponent(0.32).cgColor,
|
||||||
|
UIColor.black.withAlphaComponent(0.38).cgColor
|
||||||
|
]
|
||||||
|
gradientLayer.startPoint = CGPoint(x: 0.18, y: 0.12)
|
||||||
|
gradientLayer.endPoint = CGPoint(x: 0.88, y: 0.92)
|
||||||
|
layer.insertSublayer(gradientLayer, at: 0)
|
||||||
|
|
||||||
|
setSymbol(named: "pause.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,100 +3,19 @@
|
|||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
|
|
||||||
import SnapKit
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 图片全屏预览,对齐 Android `MediaPreviewDialog`。
|
/// 图片全屏预览兼容入口,内部委托给通用媒体预览页。
|
||||||
final class ImagePreviewViewController: UIViewController {
|
final class ImagePreviewViewController: MediaPreviewViewController {
|
||||||
|
|
||||||
private let imageURLs: [String]
|
|
||||||
private let startIndex: Int
|
|
||||||
private let scrollView = UIScrollView()
|
|
||||||
private let pageControl = UIPageControl()
|
|
||||||
|
|
||||||
|
/// 通过图片地址数组创建全屏图片预览页。
|
||||||
init(imageURLs: [String], startIndex: Int) {
|
init(imageURLs: [String], startIndex: Int) {
|
||||||
self.imageURLs = imageURLs
|
let items = imageURLs.compactMap { MediaPreviewItem.remoteImage($0) }
|
||||||
self.startIndex = min(max(0, startIndex), max(0, imageURLs.count - 1))
|
super.init(items: items, startIndex: startIndex)
|
||||||
super.init(nibName: nil, bundle: nil)
|
|
||||||
modalPresentationStyle = .fullScreen
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
929
suixinkan/UI/MaterialManagement/MaterialFormViewController.swift
Normal file
929
suixinkan/UI/MaterialManagement/MaterialFormViewController.swift
Normal file
@ -0,0 +1,929 @@
|
|||||||
|
//
|
||||||
|
// MaterialFormViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Kingfisher
|
||||||
|
import PhotosUI
|
||||||
|
import SnapKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 素材上传 / 编辑页,对齐 Android `UploadMaterialScreen` 与 `EditMaterialScreen`。
|
||||||
|
/// 素材上传与编辑页,负责字段录入、媒体选择和提交交互。
|
||||||
|
final class MaterialFormViewController: BaseViewController {
|
||||||
|
private enum PickTarget {
|
||||||
|
case material
|
||||||
|
case cover
|
||||||
|
}
|
||||||
|
|
||||||
|
private let viewModel: MaterialFormViewModel
|
||||||
|
private let api: any MaterialManagementServing
|
||||||
|
private let uploader: any MaterialOSSUploading
|
||||||
|
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
|
private let formCard = UIView()
|
||||||
|
private let formStack = UIStackView()
|
||||||
|
private let nameField = UITextField()
|
||||||
|
private let nameSection = MaterialFormFieldView(title: "素材名称")
|
||||||
|
private let spotButton = MaterialSelectButton()
|
||||||
|
private let spotSection = MaterialFormFieldView(title: "关联打卡点")
|
||||||
|
private let mediaTypeButton = MaterialSelectButton()
|
||||||
|
private let mediaTypeSection = MaterialFormFieldView(title: "素材类型")
|
||||||
|
private let mediaGridView = MaterialUploadGridView()
|
||||||
|
private let materialSection = MaterialFormFieldView(title: "上传素材")
|
||||||
|
private let coverView = MaterialCoverUploadView()
|
||||||
|
private let coverSection = MaterialFormFieldView(title: "上传封面")
|
||||||
|
private let tagView = MaterialTagEditorView()
|
||||||
|
private let tagSection = MaterialFormFieldView(title: "标签")
|
||||||
|
private let bottomBar = UIView()
|
||||||
|
private let cancelButton = UIButton(type: .system)
|
||||||
|
private let confirmButton = UIButton(type: .system)
|
||||||
|
private let progressOverlay = MaterialUploadProgressOverlay()
|
||||||
|
|
||||||
|
private var pickTarget: PickTarget = .material
|
||||||
|
var onSubmitSuccess: (() -> Void)?
|
||||||
|
|
||||||
|
/// 初始化素材表单页。
|
||||||
|
init(
|
||||||
|
mode: MaterialFormViewModel.Mode,
|
||||||
|
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI,
|
||||||
|
uploader: any MaterialOSSUploading = NetworkServices.shared.ossUploadService
|
||||||
|
) {
|
||||||
|
viewModel = MaterialFormViewModel(mode: mode)
|
||||||
|
self.api = api
|
||||||
|
self.uploader = uploader
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
switch viewModel.mode {
|
||||||
|
case .create: title = "上传素材"
|
||||||
|
case .edit: title = "编辑素材"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||||
|
formCard.backgroundColor = .white
|
||||||
|
formCard.layer.cornerRadius = 12
|
||||||
|
formCard.clipsToBounds = true
|
||||||
|
formStack.axis = .vertical
|
||||||
|
formStack.spacing = 16
|
||||||
|
|
||||||
|
nameField.placeholder = "请输入素材名称"
|
||||||
|
nameField.font = .systemFont(ofSize: 14)
|
||||||
|
nameField.textColor = .black
|
||||||
|
nameField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
|
||||||
|
nameField.layer.borderWidth = 1
|
||||||
|
nameField.layer.cornerRadius = 8
|
||||||
|
nameField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44))
|
||||||
|
nameField.leftViewMode = .always
|
||||||
|
nameField.clearButtonMode = .whileEditing
|
||||||
|
nameSection.setContent(nameField)
|
||||||
|
|
||||||
|
spotSection.setContent(spotButton)
|
||||||
|
mediaTypeSection.setContent(mediaTypeButton)
|
||||||
|
materialSection.setContent(mediaGridView)
|
||||||
|
coverSection.setContent(coverView)
|
||||||
|
tagSection.setContent(tagView)
|
||||||
|
[nameSection, spotSection, mediaTypeSection, materialSection, coverSection, tagSection].forEach(formStack.addArrangedSubview)
|
||||||
|
|
||||||
|
bottomBar.backgroundColor = .white
|
||||||
|
configureBottomButton(cancelButton, title: "取消", backgroundColor: UIColor(hex: 0xEFF6FF), titleColor: AppColor.primary)
|
||||||
|
configureBottomButton(confirmButton, title: "确认", backgroundColor: AppColor.primary, titleColor: .white)
|
||||||
|
|
||||||
|
view.addSubview(scrollView)
|
||||||
|
scrollView.addSubview(formCard)
|
||||||
|
formCard.addSubview(formStack)
|
||||||
|
view.addSubview(bottomBar)
|
||||||
|
bottomBar.addSubview(cancelButton)
|
||||||
|
bottomBar.addSubview(confirmButton)
|
||||||
|
view.addSubview(progressOverlay)
|
||||||
|
progressOverlay.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
bottomBar.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
cancelButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(16)
|
||||||
|
make.leading.equalToSuperview().offset(15)
|
||||||
|
make.width.equalTo(100)
|
||||||
|
make.height.equalTo(48)
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
|
||||||
|
}
|
||||||
|
confirmButton.snp.makeConstraints { make in
|
||||||
|
make.top.height.equalTo(cancelButton)
|
||||||
|
make.leading.equalTo(cancelButton.snp.trailing).offset(12)
|
||||||
|
make.trailing.equalToSuperview().inset(15)
|
||||||
|
}
|
||||||
|
scrollView.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
make.bottom.equalTo(bottomBar.snp.top)
|
||||||
|
}
|
||||||
|
formCard.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(16)
|
||||||
|
make.width.equalTo(scrollView).offset(-32)
|
||||||
|
}
|
||||||
|
formStack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
nameField.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(44)
|
||||||
|
}
|
||||||
|
[spotButton, mediaTypeButton].forEach { button in
|
||||||
|
button.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(44)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
progressOverlay.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
|
}
|
||||||
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
|
Task { @MainActor in self?.showToast(message) }
|
||||||
|
}
|
||||||
|
viewModel.onSubmitSuccess = { [weak self] in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.onSubmitSuccess?()
|
||||||
|
self?.navigationController?.popViewController(animated: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nameField.addTarget(self, action: #selector(nameChanged), for: .editingChanged)
|
||||||
|
spotButton.addTarget(self, action: #selector(spotTapped), for: .touchUpInside)
|
||||||
|
mediaTypeButton.addTarget(self, action: #selector(mediaTypeTapped), for: .touchUpInside)
|
||||||
|
mediaGridView.onAdd = { [weak self] in self?.presentPickerSource(target: .material) }
|
||||||
|
mediaGridView.onDelete = { [weak self] index in self?.viewModel.deleteMaterial(at: index) }
|
||||||
|
coverView.onAdd = { [weak self] in self?.presentPickerSource(target: .cover) }
|
||||||
|
coverView.onDelete = { [weak self] in self?.viewModel.deleteCoverImage() }
|
||||||
|
tagView.onTextChanged = { [weak self] text in self?.viewModel.updateTagInput(text) }
|
||||||
|
tagView.onAdd = { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.viewModel.addTag(api: self.api) }
|
||||||
|
}
|
||||||
|
tagView.onDelete = { [weak self] tag in self?.viewModel.deleteTag(tag) }
|
||||||
|
cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||||
|
confirmButton.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||||
|
applyViewModel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
Task {
|
||||||
|
showLoading()
|
||||||
|
await viewModel.loadInitial(api: api)
|
||||||
|
hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyViewModel() {
|
||||||
|
if nameField.text != viewModel.materialName {
|
||||||
|
nameField.text = viewModel.materialName
|
||||||
|
}
|
||||||
|
spotButton.setTitle(viewModel.selectedScenicSpot?.name, placeholder: "请选择打卡点")
|
||||||
|
mediaTypeButton.setTitle(viewModel.mediaType.title, placeholder: "")
|
||||||
|
mediaGridView.apply(items: viewModel.uploadedMediaList, mediaType: viewModel.mediaType)
|
||||||
|
coverView.apply(item: viewModel.coverImage)
|
||||||
|
tagView.apply(tags: viewModel.tagList, input: viewModel.tagInput)
|
||||||
|
confirmButton.alpha = viewModel.isSubmitting ? 0.6 : 1
|
||||||
|
confirmButton.isEnabled = !viewModel.isSubmitting
|
||||||
|
if let dialog = viewModel.uploadDialogState {
|
||||||
|
progressOverlay.apply(state: dialog)
|
||||||
|
progressOverlay.isHidden = false
|
||||||
|
} else {
|
||||||
|
progressOverlay.isHidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureBottomButton(_ button: UIButton, title: String, backgroundColor: UIColor, titleColor: UIColor) {
|
||||||
|
button.setTitle(title, for: .normal)
|
||||||
|
button.setTitleColor(titleColor, for: .normal)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
|
button.backgroundColor = backgroundColor
|
||||||
|
button.layer.cornerRadius = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func nameChanged() {
|
||||||
|
viewModel.updateMaterialName(nameField.text ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func spotTapped() {
|
||||||
|
let sheet = UIAlertController(title: "关联打卡点", message: nil, preferredStyle: .actionSheet)
|
||||||
|
viewModel.scenicSpotList.forEach { spot in
|
||||||
|
sheet.addAction(UIAlertAction(title: spot.name, style: .default) { [weak self] _ in
|
||||||
|
self?.viewModel.selectScenicSpot(spot)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
present(sheet, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func mediaTypeTapped() {
|
||||||
|
let sheet = UIAlertController(title: "素材类型", message: nil, preferredStyle: .actionSheet)
|
||||||
|
MaterialMediaType.allCases.forEach { type in
|
||||||
|
sheet.addAction(UIAlertAction(title: type.title, style: .default) { [weak self] _ in
|
||||||
|
self?.viewModel.selectMediaType(type)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
present(sheet, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func cancelTapped() {
|
||||||
|
navigationController?.popViewController(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func confirmTapped() {
|
||||||
|
Task {
|
||||||
|
showLoading()
|
||||||
|
await viewModel.submit(api: api)
|
||||||
|
hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentPickerSource(target: PickTarget) {
|
||||||
|
pickTarget = target
|
||||||
|
let sheet = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||||
|
if UIImagePickerController.isSourceTypeAvailable(.camera) {
|
||||||
|
let cameraTitle = viewModel.mediaType == .video && target == .material ? "拍摄视频" : "拍照"
|
||||||
|
sheet.addAction(UIAlertAction(title: cameraTitle, style: .default) { [weak self] _ in
|
||||||
|
self?.presentCamera()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sheet.addAction(UIAlertAction(title: "从相册选择", style: .default) { [weak self] _ in
|
||||||
|
self?.presentPhotoPicker()
|
||||||
|
})
|
||||||
|
sheet.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
present(sheet, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentPhotoPicker() {
|
||||||
|
var configuration = PHPickerConfiguration(photoLibrary: .shared())
|
||||||
|
if pickTarget == .cover {
|
||||||
|
configuration.filter = .images
|
||||||
|
configuration.selectionLimit = 1
|
||||||
|
} else {
|
||||||
|
configuration.filter = viewModel.mediaType == .image ? .images : .videos
|
||||||
|
configuration.selectionLimit = viewModel.mediaType == .video
|
||||||
|
? 1
|
||||||
|
: max(1, viewModel.mediaType.maxCount - viewModel.uploadedMediaList.count)
|
||||||
|
}
|
||||||
|
let picker = PHPickerViewController(configuration: configuration)
|
||||||
|
picker.delegate = self
|
||||||
|
present(picker, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentCamera() {
|
||||||
|
let picker = UIImagePickerController()
|
||||||
|
picker.sourceType = .camera
|
||||||
|
picker.delegate = self
|
||||||
|
if pickTarget == .material, viewModel.mediaType == .video {
|
||||||
|
picker.mediaTypes = [UTType.movie.identifier]
|
||||||
|
picker.cameraCaptureMode = .video
|
||||||
|
} else {
|
||||||
|
picker.mediaTypes = [UTType.image.identifier]
|
||||||
|
picker.cameraCaptureMode = .photo
|
||||||
|
}
|
||||||
|
present(picker, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uploadPickedItems(_ items: [MaterialUploadedMediaItem]) {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
Task {
|
||||||
|
switch pickTarget {
|
||||||
|
case .material:
|
||||||
|
await viewModel.addLocalMedia(items, uploader: uploader)
|
||||||
|
case .cover:
|
||||||
|
await viewModel.setCoverImage(items[0], uploader: uploader)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeImageItem(image: UIImage, fileName: String) -> MaterialUploadedMediaItem? {
|
||||||
|
guard let data = image.jpegData(compressionQuality: 0.9) else { return nil }
|
||||||
|
return MaterialUploadedMediaItem(
|
||||||
|
data: data,
|
||||||
|
fileName: fileName,
|
||||||
|
width: Int(image.size.width * image.scale),
|
||||||
|
height: Int(image.size.height * image.scale)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeVideoItem(data: Data, fileName: String, sourceURL: URL) -> MaterialUploadedMediaItem {
|
||||||
|
let size = videoDisplaySize(url: sourceURL)
|
||||||
|
return MaterialUploadedMediaItem(
|
||||||
|
data: data,
|
||||||
|
thumbnailData: videoThumbnailData(url: sourceURL),
|
||||||
|
fileName: fileName,
|
||||||
|
width: Int(size.width),
|
||||||
|
height: Int(size.height)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func videoDisplaySize(url: URL) -> CGSize {
|
||||||
|
let asset = AVAsset(url: url)
|
||||||
|
guard let track = asset.tracks(withMediaType: .video).first else { return .zero }
|
||||||
|
let transformed = track.naturalSize.applying(track.preferredTransform)
|
||||||
|
return CGSize(width: abs(transformed.width), height: abs(transformed.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func videoThumbnailData(url: URL) -> Data? {
|
||||||
|
let generator = AVAssetImageGenerator(asset: AVAsset(url: url))
|
||||||
|
generator.appliesPreferredTrackTransform = true
|
||||||
|
guard let imageRef = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return nil }
|
||||||
|
return UIImage(cgImage: imageRef).jpegData(compressionQuality: 0.75)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MaterialFormViewController: PHPickerViewControllerDelegate {
|
||||||
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
guard !results.isEmpty else { return }
|
||||||
|
|
||||||
|
var loadedItems = [MaterialUploadedMediaItem?](repeating: nil, count: results.count)
|
||||||
|
let group = DispatchGroup()
|
||||||
|
|
||||||
|
for (index, result) in results.enumerated() {
|
||||||
|
let provider = result.itemProvider
|
||||||
|
if pickTarget == .material, viewModel.mediaType == .video {
|
||||||
|
group.enter()
|
||||||
|
provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { [weak self] url, _ in
|
||||||
|
defer { group.leave() }
|
||||||
|
guard let self, let url else { return }
|
||||||
|
let data = (try? Data(contentsOf: url)) ?? Data()
|
||||||
|
guard !data.isEmpty else { return }
|
||||||
|
let fileName = provider.suggestedName.map { "\($0).mp4" } ?? "material_video_\(Int(Date().timeIntervalSince1970)).mp4"
|
||||||
|
loadedItems[index] = self.makeVideoItem(data: data, fileName: fileName, sourceURL: url)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
group.enter()
|
||||||
|
provider.loadObject(ofClass: UIImage.self) { [weak self] object, _ in
|
||||||
|
defer { group.leave() }
|
||||||
|
guard let self, let image = object as? UIImage else { return }
|
||||||
|
let fileName = "material_image_\(Int(Date().timeIntervalSince1970))_\(index).jpg"
|
||||||
|
loadedItems[index] = self.makeImageItem(image: image, fileName: fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group.notify(queue: .main) { [weak self] in
|
||||||
|
self?.uploadPickedItems(loadedItems.compactMap { $0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MaterialFormViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||||
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
||||||
|
picker.dismiss(animated: true)
|
||||||
|
if pickTarget == .material, viewModel.mediaType == .video,
|
||||||
|
let url = info[.mediaURL] as? URL,
|
||||||
|
let data = try? Data(contentsOf: url) {
|
||||||
|
uploadPickedItems([makeVideoItem(data: data, fileName: url.lastPathComponent, sourceURL: url)])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let image = info[.originalImage] as? UIImage,
|
||||||
|
let item = makeImageItem(image: image, fileName: "material_image_\(Int(Date().timeIntervalSince1970)).jpg") {
|
||||||
|
uploadPickedItems([item])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材表单字段容器。
|
||||||
|
/// 素材表单中的标题字段容器。
|
||||||
|
private final class MaterialFormFieldView: UIView {
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let contentHolder = UIView()
|
||||||
|
|
||||||
|
init(title: String) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
titleLabel.text = title
|
||||||
|
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
|
titleLabel.textColor = .black
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(contentHolder)
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
|
}
|
||||||
|
contentHolder.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setContent(_ view: UIView) {
|
||||||
|
contentHolder.subviews.forEach { $0.removeFromSuperview() }
|
||||||
|
contentHolder.addSubview(view)
|
||||||
|
view.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材下拉选择按钮。
|
||||||
|
/// 带标题和值的表单选择控件。
|
||||||
|
private final class MaterialSelectButton: UIControl {
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let chevronView = UIImageView(image: UIImage(systemName: "chevron.down"))
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
|
||||||
|
layer.borderWidth = 1
|
||||||
|
layer.cornerRadius = 8
|
||||||
|
titleLabel.font = .systemFont(ofSize: 14)
|
||||||
|
chevronView.tintColor = .black
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(chevronView)
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(12)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.trailing.lessThanOrEqualTo(chevronView.snp.leading).offset(-8)
|
||||||
|
}
|
||||||
|
chevronView.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().inset(12)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTitle(_ title: String?, placeholder: String) {
|
||||||
|
let text = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
titleLabel.text = text.isEmpty ? placeholder : text
|
||||||
|
titleLabel.textColor = text.isEmpty ? UIColor(hex: 0x4B5563) : .black
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材上传网格。
|
||||||
|
/// 素材媒体上传网格视图。
|
||||||
|
private final class MaterialUploadGridView: UIView {
|
||||||
|
private let stack = UIStackView()
|
||||||
|
var onAdd: (() -> Void)?
|
||||||
|
var onDelete: ((Int) -> Void)?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 8
|
||||||
|
addSubview(stack)
|
||||||
|
stack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(items: [MaterialUploadedMediaItem], mediaType: MaterialMediaType) {
|
||||||
|
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
let showAdd = items.count < mediaType.maxCount
|
||||||
|
let cells = items.map(Optional.some) + (showAdd ? [nil] : [])
|
||||||
|
let rows = Int(ceil(Double(cells.count) / 3.0))
|
||||||
|
guard rows > 0 else { return }
|
||||||
|
for row in 0 ..< rows {
|
||||||
|
let rowStack = UIStackView()
|
||||||
|
rowStack.axis = .horizontal
|
||||||
|
rowStack.spacing = 8
|
||||||
|
rowStack.distribution = .fillEqually
|
||||||
|
stack.addArrangedSubview(rowStack)
|
||||||
|
for column in 0 ..< 3 {
|
||||||
|
let index = row * 3 + column
|
||||||
|
if index < cells.count {
|
||||||
|
rowStack.addArrangedSubview(makeCell(item: cells[index], index: index, mediaType: mediaType))
|
||||||
|
} else {
|
||||||
|
rowStack.addArrangedSubview(UIView())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCell(item: MaterialUploadedMediaItem?, index: Int, mediaType: MaterialMediaType) -> UIView {
|
||||||
|
let cell = MaterialUploadThumbView(mediaType: mediaType)
|
||||||
|
cell.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(cell.snp.width).dividedBy(1.3)
|
||||||
|
}
|
||||||
|
if let item {
|
||||||
|
cell.apply(item: item)
|
||||||
|
cell.onDelete = { [weak self] in self?.onDelete?(index) }
|
||||||
|
} else {
|
||||||
|
cell.applyAdd()
|
||||||
|
cell.onAdd = { [weak self] in self?.onAdd?() }
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材上传缩略图格子。
|
||||||
|
/// 已选素材媒体缩略图视图。
|
||||||
|
private final class MaterialUploadThumbView: UIControl {
|
||||||
|
private let imageView = UIImageView()
|
||||||
|
private let addCircle = UIView()
|
||||||
|
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
|
||||||
|
private let textLabel = UILabel()
|
||||||
|
private let deleteButton = UIButton(type: .system)
|
||||||
|
private let playIcon = UIImageView(image: UIImage(systemName: "play.fill"))
|
||||||
|
private let mediaType: MaterialMediaType
|
||||||
|
|
||||||
|
var onAdd: (() -> Void)?
|
||||||
|
var onDelete: (() -> Void)?
|
||||||
|
|
||||||
|
init(mediaType: MaterialMediaType) {
|
||||||
|
self.mediaType = mediaType
|
||||||
|
super.init(frame: .zero)
|
||||||
|
layer.cornerRadius = 8
|
||||||
|
clipsToBounds = true
|
||||||
|
backgroundColor = .white
|
||||||
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
imageView.clipsToBounds = true
|
||||||
|
addCircle.backgroundColor = AppColor.primary
|
||||||
|
addCircle.layer.cornerRadius = 14
|
||||||
|
addIcon.tintColor = .white
|
||||||
|
textLabel.font = .systemFont(ofSize: 12)
|
||||||
|
textLabel.textColor = UIColor(hex: 0x4B5563)
|
||||||
|
textLabel.textAlignment = .center
|
||||||
|
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||||
|
deleteButton.tintColor = .white
|
||||||
|
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
||||||
|
deleteButton.layer.cornerRadius = 10
|
||||||
|
playIcon.tintColor = .white
|
||||||
|
playIcon.isHidden = true
|
||||||
|
addSubview(imageView)
|
||||||
|
addSubview(addCircle)
|
||||||
|
addCircle.addSubview(addIcon)
|
||||||
|
addSubview(textLabel)
|
||||||
|
addSubview(deleteButton)
|
||||||
|
addSubview(playIcon)
|
||||||
|
imageView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
addCircle.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.centerY.equalToSuperview().offset(-10)
|
||||||
|
make.size.equalTo(28)
|
||||||
|
}
|
||||||
|
addIcon.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.size.equalTo(20)
|
||||||
|
}
|
||||||
|
textLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(addCircle.snp.bottom).offset(4)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(4)
|
||||||
|
}
|
||||||
|
deleteButton.snp.makeConstraints { make in
|
||||||
|
make.top.trailing.equalToSuperview().inset(4)
|
||||||
|
make.size.equalTo(20)
|
||||||
|
}
|
||||||
|
playIcon.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.size.equalTo(32)
|
||||||
|
}
|
||||||
|
addTarget(self, action: #selector(addTapped), for: .touchUpInside)
|
||||||
|
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAdd() {
|
||||||
|
imageView.image = nil
|
||||||
|
imageView.kf.cancelDownloadTask()
|
||||||
|
backgroundColor = .white
|
||||||
|
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
|
||||||
|
layer.borderWidth = 1
|
||||||
|
addCircle.isHidden = false
|
||||||
|
textLabel.isHidden = false
|
||||||
|
textLabel.text = mediaType == .image ? "点击上传图片" : "点击上传视频"
|
||||||
|
textLabel.numberOfLines = 1
|
||||||
|
deleteButton.isHidden = true
|
||||||
|
playIcon.isHidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(item: MaterialUploadedMediaItem) {
|
||||||
|
layer.borderWidth = 0
|
||||||
|
backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||||
|
addCircle.isHidden = true
|
||||||
|
deleteButton.isHidden = item.ossUrl.isEmpty || item.isUploading
|
||||||
|
playIcon.isHidden = mediaType != .video || item.ossUrl.isEmpty || item.isUploading
|
||||||
|
if item.isUploading {
|
||||||
|
imageView.image = nil
|
||||||
|
textLabel.isHidden = false
|
||||||
|
textLabel.text = "上传中...\n\(item.uploadProgress)%"
|
||||||
|
textLabel.numberOfLines = 2
|
||||||
|
} else {
|
||||||
|
textLabel.isHidden = true
|
||||||
|
if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
|
||||||
|
imageView.kf.setImage(with: url)
|
||||||
|
} else {
|
||||||
|
imageView.image = UIImage(data: item.thumbnailData ?? item.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func addTapped() {
|
||||||
|
guard !addCircle.isHidden else { return }
|
||||||
|
onAdd?()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func deleteTapped() {
|
||||||
|
onDelete?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材封面上传视图。
|
||||||
|
/// 素材封面上传视图。
|
||||||
|
private final class MaterialCoverUploadView: UIControl {
|
||||||
|
private let imageView = UIImageView()
|
||||||
|
private let addCircle = UIView()
|
||||||
|
private let addIcon = UIImageView(image: UIImage(systemName: "plus"))
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let hintLabel = UILabel()
|
||||||
|
private let deleteButton = UIButton(type: .system)
|
||||||
|
var onAdd: (() -> Void)?
|
||||||
|
var onDelete: (() -> Void)?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
layer.cornerRadius = 8
|
||||||
|
layer.borderColor = UIColor(hex: 0xE5E7EB).cgColor
|
||||||
|
layer.borderWidth = 1
|
||||||
|
clipsToBounds = true
|
||||||
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
imageView.clipsToBounds = true
|
||||||
|
addCircle.backgroundColor = AppColor.primary
|
||||||
|
addCircle.layer.cornerRadius = 16
|
||||||
|
addIcon.tintColor = .white
|
||||||
|
titleLabel.text = "点击上传图片"
|
||||||
|
titleLabel.font = .systemFont(ofSize: 14)
|
||||||
|
titleLabel.textColor = UIColor(hex: 0x4B5563)
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
hintLabel.text = "建议尺寸: 1920x1080px, 大小不超过5MB"
|
||||||
|
hintLabel.font = .systemFont(ofSize: 12)
|
||||||
|
hintLabel.textColor = UIColor(hex: 0xB6BECA)
|
||||||
|
hintLabel.textAlignment = .center
|
||||||
|
deleteButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||||
|
deleteButton.tintColor = .white
|
||||||
|
deleteButton.backgroundColor = UIColor.black.withAlphaComponent(0.6)
|
||||||
|
deleteButton.layer.cornerRadius = 12
|
||||||
|
addSubview(imageView)
|
||||||
|
addSubview(addCircle)
|
||||||
|
addCircle.addSubview(addIcon)
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(hintLabel)
|
||||||
|
addSubview(deleteButton)
|
||||||
|
snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(snp.width).dividedBy(2.59)
|
||||||
|
}
|
||||||
|
imageView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
addCircle.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.centerY.equalToSuperview().offset(-22)
|
||||||
|
make.size.equalTo(32)
|
||||||
|
}
|
||||||
|
addIcon.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.size.equalTo(24)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(addCircle.snp.bottom).offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
hintLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(8)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
deleteButton.snp.makeConstraints { make in
|
||||||
|
make.top.trailing.equalToSuperview().inset(8)
|
||||||
|
make.size.equalTo(24)
|
||||||
|
}
|
||||||
|
addTarget(self, action: #selector(addTapped), for: .touchUpInside)
|
||||||
|
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(item: MaterialUploadedMediaItem?) {
|
||||||
|
guard let item else {
|
||||||
|
imageView.image = nil
|
||||||
|
imageView.kf.cancelDownloadTask()
|
||||||
|
addCircle.isHidden = false
|
||||||
|
titleLabel.isHidden = false
|
||||||
|
titleLabel.text = "点击上传图片"
|
||||||
|
titleLabel.numberOfLines = 1
|
||||||
|
hintLabel.isHidden = false
|
||||||
|
deleteButton.isHidden = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if item.isUploading {
|
||||||
|
imageView.image = nil
|
||||||
|
addCircle.isHidden = true
|
||||||
|
titleLabel.isHidden = false
|
||||||
|
titleLabel.text = "上传中...\n\(item.uploadProgress)%"
|
||||||
|
titleLabel.numberOfLines = 2
|
||||||
|
hintLabel.isHidden = true
|
||||||
|
deleteButton.isHidden = true
|
||||||
|
} else {
|
||||||
|
if let urlText = item.previewURL, let url = URL(string: urlText), !urlText.isEmpty {
|
||||||
|
imageView.kf.setImage(with: url)
|
||||||
|
} else {
|
||||||
|
imageView.image = UIImage(data: item.data)
|
||||||
|
}
|
||||||
|
addCircle.isHidden = true
|
||||||
|
titleLabel.isHidden = true
|
||||||
|
hintLabel.isHidden = true
|
||||||
|
deleteButton.isHidden = item.ossUrl.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func addTapped() {
|
||||||
|
guard addCircle.isHidden == false else { return }
|
||||||
|
onAdd?()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func deleteTapped() {
|
||||||
|
onDelete?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材标签编辑视图。
|
||||||
|
/// 素材标签编辑视图。
|
||||||
|
private final class MaterialTagEditorView: UIView {
|
||||||
|
private let stack = UIStackView()
|
||||||
|
private let tagsStack = UIStackView()
|
||||||
|
private let inputRow = UIStackView()
|
||||||
|
private let textField = UITextField()
|
||||||
|
private let addButton = UIButton(type: .system)
|
||||||
|
private var tags: [MaterialTagItem] = []
|
||||||
|
|
||||||
|
var onTextChanged: ((String) -> Void)?
|
||||||
|
var onAdd: (() -> Void)?
|
||||||
|
var onDelete: ((MaterialTagItem) -> Void)?
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
stack.axis = .vertical
|
||||||
|
stack.spacing = 8
|
||||||
|
tagsStack.axis = .horizontal
|
||||||
|
tagsStack.spacing = 8
|
||||||
|
tagsStack.alignment = .leading
|
||||||
|
inputRow.axis = .horizontal
|
||||||
|
inputRow.spacing = 8
|
||||||
|
textField.placeholder = "请输入标签"
|
||||||
|
textField.font = .systemFont(ofSize: 14)
|
||||||
|
textField.layer.borderColor = UIColor(hex: 0xEEEEEE).cgColor
|
||||||
|
textField.layer.borderWidth = 1
|
||||||
|
textField.layer.cornerRadius = 8
|
||||||
|
textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44))
|
||||||
|
textField.leftViewMode = .always
|
||||||
|
addButton.setTitle("添加", for: .normal)
|
||||||
|
addButton.setTitleColor(.white, for: .normal)
|
||||||
|
addButton.backgroundColor = AppColor.primary
|
||||||
|
addButton.layer.cornerRadius = 8
|
||||||
|
addSubview(stack)
|
||||||
|
stack.addArrangedSubview(tagsStack)
|
||||||
|
stack.addArrangedSubview(inputRow)
|
||||||
|
inputRow.addArrangedSubview(textField)
|
||||||
|
inputRow.addArrangedSubview(addButton)
|
||||||
|
stack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
textField.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(44)
|
||||||
|
}
|
||||||
|
addButton.snp.makeConstraints { make in
|
||||||
|
make.width.equalTo(72)
|
||||||
|
make.height.equalTo(44)
|
||||||
|
}
|
||||||
|
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
|
||||||
|
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(tags: [MaterialTagItem], input: String) {
|
||||||
|
self.tags = tags
|
||||||
|
if textField.text != input {
|
||||||
|
textField.text = input
|
||||||
|
}
|
||||||
|
tagsStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
tags.forEach { tag in
|
||||||
|
let button = UIButton(type: .system)
|
||||||
|
button.setTitle("\(tag.name) ×", for: .normal)
|
||||||
|
button.setTitleColor(AppColor.primary, for: .normal)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 12)
|
||||||
|
button.backgroundColor = UIColor(hex: 0xEFF6FF)
|
||||||
|
button.layer.cornerRadius = 4
|
||||||
|
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8)
|
||||||
|
button.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(28)
|
||||||
|
}
|
||||||
|
button.addAction(UIAction { [weak self] _ in
|
||||||
|
self?.onDelete?(tag)
|
||||||
|
}, for: .touchUpInside)
|
||||||
|
tagsStack.addArrangedSubview(button)
|
||||||
|
}
|
||||||
|
tagsStack.isHidden = tags.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func textChanged() {
|
||||||
|
onTextChanged?(textField.text ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func addTapped() {
|
||||||
|
onAdd?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材上传进度蒙层。
|
||||||
|
/// 素材上传进度遮罩视图。
|
||||||
|
private final class MaterialUploadProgressOverlay: UIView {
|
||||||
|
private let card = UIView()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let progressView = UIProgressView(progressViewStyle: .default)
|
||||||
|
private let percentLabel = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
backgroundColor = UIColor.black.withAlphaComponent(0.2)
|
||||||
|
card.backgroundColor = .white
|
||||||
|
card.layer.cornerRadius = 12
|
||||||
|
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
progressView.progressTintColor = AppColor.primary
|
||||||
|
progressView.trackTintColor = UIColor(hex: 0xF3F4F6)
|
||||||
|
percentLabel.font = .systemFont(ofSize: 14)
|
||||||
|
percentLabel.textColor = AppColor.textSecondary
|
||||||
|
percentLabel.textAlignment = .center
|
||||||
|
addSubview(card)
|
||||||
|
card.addSubview(titleLabel)
|
||||||
|
card.addSubview(progressView)
|
||||||
|
card.addSubview(percentLabel)
|
||||||
|
card.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.leading.trailing.equalToSuperview().inset(44)
|
||||||
|
make.height.equalTo(card.snp.width).dividedBy(1.7)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview().inset(24)
|
||||||
|
}
|
||||||
|
progressView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(32)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(24)
|
||||||
|
make.height.equalTo(6)
|
||||||
|
}
|
||||||
|
percentLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(progressView.snp.bottom).offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(state: MaterialUploadDialogState) {
|
||||||
|
titleLabel.text = state.title
|
||||||
|
let progress = Float(max(0, min(100, state.progress))) / 100
|
||||||
|
progressView.setProgress(progress, animated: true)
|
||||||
|
percentLabel.text = "\(max(0, min(100, state.progress)))%"
|
||||||
|
}
|
||||||
|
}
|
||||||
654
suixinkan/UI/MaterialManagement/MaterialListViewController.swift
Normal file
654
suixinkan/UI/MaterialManagement/MaterialListViewController.swift
Normal file
@ -0,0 +1,654 @@
|
|||||||
|
//
|
||||||
|
// MaterialListViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import Kingfisher
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 素材管理列表页,对齐 Android `MaterialListScreen`。
|
||||||
|
/// 素材管理列表页,展示筛选、统计、素材卡片与添加入口。
|
||||||
|
final class MaterialListViewController: BaseViewController {
|
||||||
|
private let viewModel: MaterialListViewModel
|
||||||
|
private let api: any MaterialManagementServing
|
||||||
|
|
||||||
|
private let searchContainer = UIView()
|
||||||
|
private let searchBox = UIView()
|
||||||
|
private let searchIconView = UIImageView(image: UIImage(systemName: "magnifyingglass"))
|
||||||
|
private let searchTextField = UITextField()
|
||||||
|
private let filterContainer = UIView()
|
||||||
|
private let filterButton = UIButton(type: .system)
|
||||||
|
private let filterTitleLabel = UILabel()
|
||||||
|
private let filterChevronView = UIImageView(image: UIImage(systemName: "chevron.down"))
|
||||||
|
private let filterDropdownView = UIView()
|
||||||
|
private let filterDropdownStack = UIStackView()
|
||||||
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
|
private let emptyLabel = UILabel()
|
||||||
|
private let bottomBar = UIView()
|
||||||
|
private let addButton = UIButton(type: .system)
|
||||||
|
private var dataSource: UITableViewDiffableDataSource<Int, MaterialListRow>!
|
||||||
|
private var filterOptionButtons: [(MaterialFilterStatus, UIButton)] = []
|
||||||
|
private var needsRefreshOnAppear = false
|
||||||
|
|
||||||
|
/// 初始化素材管理列表页。
|
||||||
|
init(
|
||||||
|
viewModel: MaterialListViewModel = MaterialListViewModel(),
|
||||||
|
api: any MaterialManagementServing = NetworkServices.shared.materialManagementAPI
|
||||||
|
) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.api = api
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = "素材管理"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = AppColor.pageBackground
|
||||||
|
|
||||||
|
searchContainer.backgroundColor = .white
|
||||||
|
searchBox.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||||
|
searchBox.layer.cornerRadius = 4
|
||||||
|
searchIconView.tintColor = AppColor.textSecondary
|
||||||
|
searchIconView.contentMode = .scaleAspectFit
|
||||||
|
searchTextField.placeholder = "搜索素材名称"
|
||||||
|
searchTextField.font = .systemFont(ofSize: 13)
|
||||||
|
searchTextField.textColor = AppColor.textPrimary
|
||||||
|
searchTextField.clearButtonMode = .whileEditing
|
||||||
|
searchTextField.returnKeyType = .search
|
||||||
|
|
||||||
|
filterContainer.backgroundColor = .white
|
||||||
|
filterButton.backgroundColor = UIColor(hex: 0xF4F4F4)
|
||||||
|
filterButton.layer.cornerRadius = 4
|
||||||
|
filterTitleLabel.text = "筛选"
|
||||||
|
filterTitleLabel.font = .systemFont(ofSize: 14)
|
||||||
|
filterTitleLabel.textColor = UIColor(hex: 0x4B5563)
|
||||||
|
filterChevronView.tintColor = UIColor(hex: 0x4B5563)
|
||||||
|
filterChevronView.contentMode = .scaleAspectFit
|
||||||
|
filterDropdownView.backgroundColor = .white
|
||||||
|
filterDropdownView.layer.cornerRadius = 4
|
||||||
|
filterDropdownView.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
filterDropdownView.layer.shadowOpacity = 0.12
|
||||||
|
filterDropdownView.layer.shadowRadius = 10
|
||||||
|
filterDropdownView.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||||
|
filterDropdownView.isHidden = true
|
||||||
|
filterDropdownStack.axis = .vertical
|
||||||
|
|
||||||
|
tableView.backgroundColor = AppColor.pageBackground
|
||||||
|
tableView.separatorStyle = .none
|
||||||
|
tableView.estimatedRowHeight = 148
|
||||||
|
tableView.rowHeight = UITableView.automaticDimension
|
||||||
|
tableView.delegate = self
|
||||||
|
tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 16, right: 0)
|
||||||
|
tableView.refreshControl = UIRefreshControl()
|
||||||
|
tableView.register(MaterialStatsCell.self, forCellReuseIdentifier: MaterialStatsCell.reuseIdentifier)
|
||||||
|
tableView.register(MaterialListCell.self, forCellReuseIdentifier: MaterialListCell.reuseIdentifier)
|
||||||
|
|
||||||
|
dataSource = UITableViewDiffableDataSource<Int, MaterialListRow>(tableView: tableView) {
|
||||||
|
[weak self] tableView, indexPath, row in
|
||||||
|
switch row {
|
||||||
|
case .stats(let info):
|
||||||
|
let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: MaterialStatsCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! MaterialStatsCell
|
||||||
|
cell.apply(info: info)
|
||||||
|
return cell
|
||||||
|
case .item(let item):
|
||||||
|
let cell = tableView.dequeueReusableCell(
|
||||||
|
withIdentifier: MaterialListCell.reuseIdentifier,
|
||||||
|
for: indexPath
|
||||||
|
) as! MaterialListCell
|
||||||
|
cell.apply(item: item)
|
||||||
|
cell.onToggle = { [weak self] checked in
|
||||||
|
guard let self else { return }
|
||||||
|
Task { await self.viewModel.toggleListing(materialId: item.id, checked: checked, api: self.api) }
|
||||||
|
}
|
||||||
|
return cell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyLabel.text = "暂无素材"
|
||||||
|
emptyLabel.font = .systemFont(ofSize: 16)
|
||||||
|
emptyLabel.textColor = AppColor.textSecondary
|
||||||
|
emptyLabel.textAlignment = .center
|
||||||
|
emptyLabel.isHidden = true
|
||||||
|
|
||||||
|
bottomBar.backgroundColor = .white
|
||||||
|
addButton.setTitle("添加", for: .normal)
|
||||||
|
addButton.setTitleColor(.white, for: .normal)
|
||||||
|
addButton.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
|
addButton.backgroundColor = AppColor.primary
|
||||||
|
addButton.layer.cornerRadius = 8
|
||||||
|
|
||||||
|
view.addSubview(searchContainer)
|
||||||
|
searchContainer.addSubview(searchBox)
|
||||||
|
searchBox.addSubview(searchIconView)
|
||||||
|
searchBox.addSubview(searchTextField)
|
||||||
|
view.addSubview(filterContainer)
|
||||||
|
filterContainer.addSubview(filterButton)
|
||||||
|
filterButton.addSubview(filterTitleLabel)
|
||||||
|
filterButton.addSubview(filterChevronView)
|
||||||
|
view.addSubview(tableView)
|
||||||
|
view.addSubview(emptyLabel)
|
||||||
|
view.addSubview(bottomBar)
|
||||||
|
bottomBar.addSubview(addButton)
|
||||||
|
view.addSubview(filterDropdownView)
|
||||||
|
filterDropdownView.addSubview(filterDropdownStack)
|
||||||
|
configureFilterDropdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
searchContainer.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.height.equalTo(68)
|
||||||
|
}
|
||||||
|
searchBox.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.height.equalTo(36)
|
||||||
|
}
|
||||||
|
searchIconView.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(12)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(15)
|
||||||
|
}
|
||||||
|
searchTextField.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(searchIconView.snp.trailing).offset(12)
|
||||||
|
make.trailing.equalToSuperview().inset(12)
|
||||||
|
make.top.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
filterContainer.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(searchContainer.snp.bottom).offset(1)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.height.equalTo(68)
|
||||||
|
}
|
||||||
|
filterButton.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.height.equalTo(36)
|
||||||
|
}
|
||||||
|
filterTitleLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalToSuperview().offset(16)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
filterChevronView.snp.makeConstraints { make in
|
||||||
|
make.trailing.equalToSuperview().inset(14)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(18)
|
||||||
|
}
|
||||||
|
bottomBar.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
}
|
||||||
|
addButton.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(16)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(15)
|
||||||
|
make.height.equalTo(48)
|
||||||
|
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
|
||||||
|
}
|
||||||
|
tableView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(filterContainer.snp.bottom).offset(12)
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.bottom.equalTo(bottomBar.snp.top)
|
||||||
|
}
|
||||||
|
filterDropdownView.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(filterButton.snp.bottom)
|
||||||
|
make.leading.trailing.equalTo(filterButton)
|
||||||
|
}
|
||||||
|
filterDropdownStack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
}
|
||||||
|
emptyLabel.snp.makeConstraints { make in
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
make.centerY.equalTo(tableView)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
|
}
|
||||||
|
viewModel.onShowMessage = { [weak self] message in
|
||||||
|
Task { @MainActor in self?.showToast(message) }
|
||||||
|
}
|
||||||
|
searchTextField.addTarget(self, action: #selector(searchTextChanged), for: .editingChanged)
|
||||||
|
searchTextField.delegate = self
|
||||||
|
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
|
||||||
|
addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside)
|
||||||
|
tableView.refreshControl?.addTarget(self, action: #selector(refreshPulled), for: .valueChanged)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
Task { await viewModel.loadInitial(api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
|
super.viewWillAppear(animated)
|
||||||
|
if needsRefreshOnAppear {
|
||||||
|
needsRefreshOnAppear = false
|
||||||
|
Task { await viewModel.refresh(api: api) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func applyViewModel() {
|
||||||
|
filterTitleLabel.text = viewModel.filterStatus == .all ? "筛选" : viewModel.filterStatus.title
|
||||||
|
filterOptionButtons.forEach { status, button in
|
||||||
|
let selected = status == viewModel.filterStatus
|
||||||
|
button.setTitleColor(selected ? AppColor.primary : AppColor.textPrimary, for: .normal)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .medium : .regular)
|
||||||
|
}
|
||||||
|
if searchTextField.text != viewModel.searchText {
|
||||||
|
searchTextField.text = viewModel.searchText
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = NSDiffableDataSourceSnapshot<Int, MaterialListRow>()
|
||||||
|
snapshot.appendSections([0])
|
||||||
|
snapshot.appendItems([.stats(viewModel.orderInfo)] + viewModel.items.map(MaterialListRow.item))
|
||||||
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
emptyLabel.isHidden = !viewModel.items.isEmpty || viewModel.isLoading || viewModel.isRefreshing
|
||||||
|
viewModel.isLoading && viewModel.items.isEmpty ? showLoading() : hideLoading()
|
||||||
|
if !viewModel.isRefreshing {
|
||||||
|
tableView.refreshControl?.endRefreshing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func searchTextChanged() {
|
||||||
|
let text = searchTextField.text ?? ""
|
||||||
|
Task { await viewModel.updateSearchText(text, api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func filterTapped() {
|
||||||
|
filterDropdownView.isHidden.toggle()
|
||||||
|
view.bringSubviewToFront(filterDropdownView)
|
||||||
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
self.filterChevronView.transform = self.filterDropdownView.isHidden
|
||||||
|
? .identity
|
||||||
|
: CGAffineTransform(rotationAngle: .pi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func addTapped() {
|
||||||
|
let controller = MaterialFormViewController(mode: .create)
|
||||||
|
controller.onSubmitSuccess = { [weak self] in
|
||||||
|
self?.needsRefreshOnAppear = true
|
||||||
|
}
|
||||||
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func refreshPulled() {
|
||||||
|
hideFilterDropdown()
|
||||||
|
Task { await viewModel.refresh(api: api) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureFilterDropdown() {
|
||||||
|
MaterialFilterStatus.allCases.forEach { status in
|
||||||
|
let button = UIButton(type: .system)
|
||||||
|
button.contentHorizontalAlignment = .leading
|
||||||
|
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
|
||||||
|
button.setTitle(status.title, for: .normal)
|
||||||
|
button.backgroundColor = .white
|
||||||
|
button.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(48)
|
||||||
|
}
|
||||||
|
button.addAction(UIAction { [weak self] _ in
|
||||||
|
guard let self else { return }
|
||||||
|
self.hideFilterDropdown()
|
||||||
|
Task { await self.viewModel.selectFilter(status, api: self.api) }
|
||||||
|
}, for: .touchUpInside)
|
||||||
|
filterDropdownStack.addArrangedSubview(button)
|
||||||
|
filterOptionButtons.append((status, button))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hideFilterDropdown() {
|
||||||
|
guard !filterDropdownView.isHidden else { return }
|
||||||
|
filterDropdownView.isHidden = true
|
||||||
|
UIView.animate(withDuration: 0.2) {
|
||||||
|
self.filterChevronView.transform = .identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MaterialListViewController: UITableViewDelegate {
|
||||||
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
|
hideFilterDropdown()
|
||||||
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
guard case .item(let item) = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
|
let controller = MaterialDetailViewController(materialId: item.id)
|
||||||
|
controller.onDeleted = { [weak self] deletedId in
|
||||||
|
self?.viewModel.removeMaterial(id: deletedId)
|
||||||
|
}
|
||||||
|
controller.onEdited = { [weak self] in
|
||||||
|
self?.needsRefreshOnAppear = true
|
||||||
|
}
|
||||||
|
navigationController?.pushViewController(controller, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
|
||||||
|
guard case .item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||||
|
Task { await viewModel.loadMoreIfNeeded(lastVisibleIndex: indexPath.row - 1, api: api) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MaterialListViewController: UITextFieldDelegate {
|
||||||
|
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
|
||||||
|
textField.resignFirstResponder()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材列表 Diffable 数据源行类型。
|
||||||
|
private enum MaterialListRow: Hashable {
|
||||||
|
case stats(MaterialOrderInfo)
|
||||||
|
case item(MaterialItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材统计卡片行。
|
||||||
|
/// 素材列表顶部统计单元格。
|
||||||
|
private final class MaterialStatsCell: UITableViewCell {
|
||||||
|
static let reuseIdentifier = "MaterialStatsCell"
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let stack = UIStackView()
|
||||||
|
private let totalView = MaterialStatisticView(title: "订单总数量", color: UIColor(hex: 0x7F00FF), background: UIColor(hex: 0xEBD8FF))
|
||||||
|
private let priceView = MaterialStatisticView(title: "客单价", color: UIColor(hex: 0x22C55E), background: UIColor(hex: 0xF0FDF4))
|
||||||
|
private let refundView = MaterialStatisticView(title: "退款金额", color: UIColor(hex: 0xFF7B00), background: UIColor(hex: 0xFFF0E2))
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
selectionStyle = .none
|
||||||
|
backgroundColor = AppColor.pageBackground
|
||||||
|
contentView.backgroundColor = AppColor.pageBackground
|
||||||
|
cardView.backgroundColor = .white
|
||||||
|
cardView.layer.cornerRadius = 12
|
||||||
|
stack.axis = .horizontal
|
||||||
|
stack.spacing = 12
|
||||||
|
stack.distribution = .fillEqually
|
||||||
|
contentView.addSubview(cardView)
|
||||||
|
cardView.addSubview(stack)
|
||||||
|
[totalView, priceView, refundView].forEach(stack.addArrangedSubview)
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.top.bottom.equalToSuperview().inset(8)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
stack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(info: MaterialOrderInfo) {
|
||||||
|
totalView.setValue(MaterialDisplayFormatter.formatNumber(info.totalNum))
|
||||||
|
priceView.setValue("+\(info.avgOrderAmount)")
|
||||||
|
refundView.setValue(info.refundTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材统计卡片。
|
||||||
|
/// 单个素材统计指标视图。
|
||||||
|
private final class MaterialStatisticView: UIView {
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let valueLabel = UILabel()
|
||||||
|
|
||||||
|
init(title: String, color: UIColor, background: UIColor) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
self.backgroundColor = background
|
||||||
|
layer.cornerRadius = 12
|
||||||
|
titleLabel.text = title
|
||||||
|
titleLabel.font = .systemFont(ofSize: 14)
|
||||||
|
titleLabel.textColor = .black
|
||||||
|
titleLabel.textAlignment = .center
|
||||||
|
valueLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||||
|
valueLabel.textColor = color
|
||||||
|
valueLabel.textAlignment = .center
|
||||||
|
valueLabel.adjustsFontSizeToFitWidth = true
|
||||||
|
valueLabel.minimumScaleFactor = 0.7
|
||||||
|
addSubview(titleLabel)
|
||||||
|
addSubview(valueLabel)
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview().inset(8)
|
||||||
|
}
|
||||||
|
valueLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(10)
|
||||||
|
make.leading.trailing.bottom.equalToSuperview().inset(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setValue(_ value: String) {
|
||||||
|
valueLabel.text = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材列表卡片 cell,对齐 Android `MaterialItemView`。
|
||||||
|
/// 素材列表卡片单元格。
|
||||||
|
private final class MaterialListCell: UITableViewCell {
|
||||||
|
static let reuseIdentifier = "MaterialListCell"
|
||||||
|
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let coverImageView = UIImageView()
|
||||||
|
private let placeholderLabel = UILabel()
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let timeLabel = UILabel()
|
||||||
|
private let statusBadge = MaterialStatusBadgeView()
|
||||||
|
private let listingSwitch = UISwitch()
|
||||||
|
private let statsStack = UIStackView()
|
||||||
|
private let downloadStatView = MaterialStatView(systemIconName: "arrow.down.circle")
|
||||||
|
private let likeStatView = MaterialStatView(iconName: "sample_ic_like_count")
|
||||||
|
private let collectStatView = MaterialStatView(iconName: "sample_ic_collect_count")
|
||||||
|
private var isApplying = false
|
||||||
|
|
||||||
|
var onToggle: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||||
|
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func apply(item: MaterialItem) {
|
||||||
|
isApplying = true
|
||||||
|
titleLabel.text = item.name
|
||||||
|
timeLabel.text = item.createdAt
|
||||||
|
statusBadge.apply(status: item.status)
|
||||||
|
listingSwitch.setOn(item.listingStatus == 1, animated: false)
|
||||||
|
downloadStatView.setCount(item.downloadCount)
|
||||||
|
likeStatView.setCount(item.likesCount)
|
||||||
|
collectStatView.setCount(item.collectCount)
|
||||||
|
|
||||||
|
if let url = URL(string: item.coverUrl), !item.coverUrl.isEmpty {
|
||||||
|
placeholderLabel.isHidden = true
|
||||||
|
coverImageView.kf.setImage(with: url)
|
||||||
|
} else {
|
||||||
|
coverImageView.image = nil
|
||||||
|
placeholderLabel.isHidden = false
|
||||||
|
}
|
||||||
|
isApplying = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
selectionStyle = .none
|
||||||
|
backgroundColor = AppColor.pageBackground
|
||||||
|
contentView.backgroundColor = AppColor.pageBackground
|
||||||
|
|
||||||
|
cardView.backgroundColor = .white
|
||||||
|
cardView.layer.cornerRadius = 12
|
||||||
|
cardView.clipsToBounds = true
|
||||||
|
|
||||||
|
coverImageView.backgroundColor = UIColor(hex: 0xF5F5F5)
|
||||||
|
coverImageView.contentMode = .scaleAspectFill
|
||||||
|
coverImageView.clipsToBounds = true
|
||||||
|
coverImageView.layer.cornerRadius = 8
|
||||||
|
|
||||||
|
placeholderLabel.text = "暂无图片"
|
||||||
|
placeholderLabel.font = .systemFont(ofSize: 12)
|
||||||
|
placeholderLabel.textColor = AppColor.textSecondary
|
||||||
|
placeholderLabel.textAlignment = .center
|
||||||
|
|
||||||
|
titleLabel.font = .systemFont(ofSize: 16, weight: .bold)
|
||||||
|
titleLabel.textColor = .black
|
||||||
|
titleLabel.numberOfLines = 1
|
||||||
|
|
||||||
|
timeLabel.font = .systemFont(ofSize: 12)
|
||||||
|
timeLabel.textColor = UIColor(hex: 0x4B5563)
|
||||||
|
timeLabel.numberOfLines = 1
|
||||||
|
|
||||||
|
listingSwitch.onTintColor = AppColor.primary
|
||||||
|
listingSwitch.addTarget(self, action: #selector(switchChanged), for: .valueChanged)
|
||||||
|
|
||||||
|
statsStack.axis = .horizontal
|
||||||
|
statsStack.spacing = 16
|
||||||
|
statsStack.alignment = .center
|
||||||
|
|
||||||
|
contentView.addSubview(cardView)
|
||||||
|
cardView.addSubview(coverImageView)
|
||||||
|
coverImageView.addSubview(placeholderLabel)
|
||||||
|
cardView.addSubview(titleLabel)
|
||||||
|
cardView.addSubview(timeLabel)
|
||||||
|
cardView.addSubview(statusBadge)
|
||||||
|
cardView.addSubview(listingSwitch)
|
||||||
|
cardView.addSubview(statsStack)
|
||||||
|
[downloadStatView, likeStatView, collectStatView].forEach(statsStack.addArrangedSubview)
|
||||||
|
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.top.bottom.equalToSuperview().inset(8)
|
||||||
|
make.leading.trailing.equalToSuperview().inset(16)
|
||||||
|
}
|
||||||
|
coverImageView.snp.makeConstraints { make in
|
||||||
|
make.leading.top.bottom.equalToSuperview().inset(12)
|
||||||
|
make.width.equalTo(109)
|
||||||
|
make.height.equalTo(100)
|
||||||
|
}
|
||||||
|
placeholderLabel.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.leading.trailing.equalToSuperview().inset(8)
|
||||||
|
}
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(16)
|
||||||
|
make.leading.equalTo(coverImageView.snp.trailing).offset(12)
|
||||||
|
make.trailing.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
timeLabel.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||||
|
make.leading.trailing.equalTo(titleLabel)
|
||||||
|
}
|
||||||
|
statusBadge.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(timeLabel.snp.bottom).offset(8)
|
||||||
|
make.leading.equalTo(titleLabel)
|
||||||
|
make.height.equalTo(24)
|
||||||
|
}
|
||||||
|
listingSwitch.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalTo(statusBadge)
|
||||||
|
make.trailing.equalToSuperview().inset(12)
|
||||||
|
make.leading.greaterThanOrEqualTo(statusBadge.snp.trailing).offset(8)
|
||||||
|
}
|
||||||
|
statsStack.snp.makeConstraints { make in
|
||||||
|
make.top.equalTo(statusBadge.snp.bottom).offset(8)
|
||||||
|
make.leading.equalTo(titleLabel)
|
||||||
|
make.trailing.lessThanOrEqualToSuperview().inset(12)
|
||||||
|
make.bottom.lessThanOrEqualToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func switchChanged() {
|
||||||
|
guard !isApplying else { return }
|
||||||
|
onToggle?(listingSwitch.isOn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材状态 badge。
|
||||||
|
/// 素材审核状态标签视图。
|
||||||
|
final class MaterialStatusBadgeView: UIView {
|
||||||
|
private let label = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
layer.cornerRadius = 4
|
||||||
|
addSubview(label)
|
||||||
|
label.font = .systemFont(ofSize: 12, weight: .medium)
|
||||||
|
label.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview().inset(6)
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置审核状态。
|
||||||
|
func apply(status: Int) {
|
||||||
|
let color: UIColor
|
||||||
|
switch status {
|
||||||
|
case 1: color = UIColor(hex: 0x22C55E)
|
||||||
|
case 0: color = UIColor(hex: 0xFF7B00)
|
||||||
|
case 2: color = UIColor(hex: 0xEF4444)
|
||||||
|
default: color = AppColor.textSecondary
|
||||||
|
}
|
||||||
|
label.text = MaterialDisplayFormatter.reviewStatusText(status)
|
||||||
|
label.textColor = color
|
||||||
|
backgroundColor = color.withAlphaComponent(0.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材统计图标与数字。
|
||||||
|
/// 素材卡片中的图标计数视图。
|
||||||
|
private final class MaterialStatView: UIView {
|
||||||
|
private let iconView = UIImageView()
|
||||||
|
private let countLabel = UILabel()
|
||||||
|
|
||||||
|
init(iconName: String? = nil, systemIconName: String? = nil) {
|
||||||
|
super.init(frame: .zero)
|
||||||
|
if let iconName {
|
||||||
|
iconView.image = UIImage(named: iconName)
|
||||||
|
} else if let systemIconName {
|
||||||
|
iconView.image = UIImage(systemName: systemIconName)
|
||||||
|
}
|
||||||
|
iconView.tintColor = UIColor(hex: 0x6B7280)
|
||||||
|
iconView.contentMode = .scaleAspectFit
|
||||||
|
countLabel.font = .systemFont(ofSize: 14)
|
||||||
|
countLabel.textColor = UIColor(hex: 0x6B7280)
|
||||||
|
addSubview(iconView)
|
||||||
|
addSubview(countLabel)
|
||||||
|
iconView.snp.makeConstraints { make in
|
||||||
|
make.leading.centerY.equalToSuperview()
|
||||||
|
make.size.equalTo(16)
|
||||||
|
}
|
||||||
|
countLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.equalTo(iconView.snp.trailing).offset(4)
|
||||||
|
make.trailing.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCount(_ count: Int) {
|
||||||
|
countLabel.text = MaterialDisplayFormatter.formatCount(count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -30,6 +30,7 @@ final class ProfileViewController: BaseViewController {
|
|||||||
private let realNameRow = ProfileInfoRowView(title: "认证状态")
|
private let realNameRow = ProfileInfoRowView(title: "认证状态")
|
||||||
private let statusRow = ProfileInfoRowView(title: "账号状态")
|
private let statusRow = ProfileInfoRowView(title: "账号状态")
|
||||||
private let scenicRow = ProfileInfoRowView(title: "当前景区")
|
private let scenicRow = ProfileInfoRowView(title: "当前景区")
|
||||||
|
private let settingRow = ProfileInfoRowView(title: "设置中心")
|
||||||
private let logoutButton = UIButton(type: .system)
|
private let logoutButton = UIButton(type: .system)
|
||||||
private var hasLoadedProfileOnce = false
|
private var hasLoadedProfileOnce = false
|
||||||
|
|
||||||
@ -85,6 +86,7 @@ final class ProfileViewController: BaseViewController {
|
|||||||
passwordRow.addTarget(self, action: #selector(changePasswordTapped), for: .touchUpInside)
|
passwordRow.addTarget(self, action: #selector(changePasswordTapped), for: .touchUpInside)
|
||||||
withdrawalRow.addTarget(self, action: #selector(withdrawalTapped), for: .touchUpInside)
|
withdrawalRow.addTarget(self, action: #selector(withdrawalTapped), for: .touchUpInside)
|
||||||
realNameRow.addTarget(self, action: #selector(realNameTapped), for: .touchUpInside)
|
realNameRow.addTarget(self, action: #selector(realNameTapped), for: .touchUpInside)
|
||||||
|
settingRow.addTarget(self, action: #selector(settingTapped), for: .touchUpInside)
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
@ -177,14 +179,15 @@ final class ProfileViewController: BaseViewController {
|
|||||||
|
|
||||||
[
|
[
|
||||||
nameRow, accountRow, phoneRow, withdrawalRow,
|
nameRow, accountRow, phoneRow, withdrawalRow,
|
||||||
passwordRow, realNameRow, statusRow, scenicRow
|
passwordRow, realNameRow, statusRow, scenicRow,
|
||||||
|
settingRow
|
||||||
].forEach { infoCard.addArrangedSubview($0) }
|
].forEach { infoCard.addArrangedSubview($0) }
|
||||||
|
|
||||||
nameRow.isUserInteractionEnabled = false
|
nameRow.isUserInteractionEnabled = false
|
||||||
phoneRow.isUserInteractionEnabled = false
|
phoneRow.isUserInteractionEnabled = false
|
||||||
statusRow.isUserInteractionEnabled = false
|
statusRow.isUserInteractionEnabled = false
|
||||||
scenicRow.isUserInteractionEnabled = false
|
scenicRow.isUserInteractionEnabled = false
|
||||||
scenicRow.showsDivider = false
|
settingRow.showsDivider = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupLogoutButton() {
|
private func setupLogoutButton() {
|
||||||
@ -224,6 +227,7 @@ final class ProfileViewController: BaseViewController {
|
|||||||
value: viewModel.currentScenicName,
|
value: viewModel.currentScenicName,
|
||||||
badge: .scenic
|
badge: .scenic
|
||||||
)
|
)
|
||||||
|
settingRow.configure(value: nil, accessory: .chevron)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureWithdrawalRow() {
|
private func configureWithdrawalRow() {
|
||||||
@ -342,6 +346,10 @@ final class ProfileViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func settingTapped() {
|
||||||
|
navigationController?.pushViewController(SettingViewController(), animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func logoutTapped() {
|
@objc private func logoutTapped() {
|
||||||
let alert = UIAlertController(title: "确定退出登录?", message: "退出后将需要重新登录", preferredStyle: .alert)
|
let alert = UIAlertController(title: "确定退出登录?", message: "退出后将需要重新登录", preferredStyle: .alert)
|
||||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
|
|||||||
67
suixinkan/UI/Setting/AgreementWebViewController.swift
Normal file
67
suixinkan/UI/Setting/AgreementWebViewController.swift
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
//
|
||||||
|
// AgreementWebViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
/// 协议 H5 页面,使用应用内 WebView 展示关于我们、用户协议和隐私政策。
|
||||||
|
final class AgreementWebViewController: BaseViewController {
|
||||||
|
private let pageTitle: String
|
||||||
|
private let url: URL
|
||||||
|
|
||||||
|
private let webView = WKWebView(frame: .zero)
|
||||||
|
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
|
|
||||||
|
init(title: String, url: URL) {
|
||||||
|
pageTitle = title
|
||||||
|
self.url = url
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = pageTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = AppColor.cardBackground
|
||||||
|
webView.navigationDelegate = self
|
||||||
|
view.addSubview(webView)
|
||||||
|
view.addSubview(loadingIndicator)
|
||||||
|
loadingIndicator.hidesWhenStopped = true
|
||||||
|
loadingIndicator.startAnimating()
|
||||||
|
webView.load(URLRequest(url: url))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
webView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
}
|
||||||
|
loadingIndicator.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AgreementWebViewController: WKNavigationDelegate {
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
loadingIndicator.stopAnimating()
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||||
|
loadingIndicator.stopAnimating()
|
||||||
|
showToast(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||||
|
loadingIndicator.stopAnimating()
|
||||||
|
showToast(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
230
suixinkan/UI/Setting/SettingViewController.swift
Normal file
230
suixinkan/UI/Setting/SettingViewController.swift
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
//
|
||||||
|
// SettingViewController.swift
|
||||||
|
// suixinkan
|
||||||
|
//
|
||||||
|
|
||||||
|
import SnapKit
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// 设置中心页面,对齐 Android `SettingScreen` 的当前可见设置项。
|
||||||
|
final class SettingViewController: BaseViewController {
|
||||||
|
private let viewModel: SettingViewModel
|
||||||
|
private let settingAPI: SettingAPI
|
||||||
|
|
||||||
|
private let contentView = UIView()
|
||||||
|
private let cardView = UIView()
|
||||||
|
private let rowsStack = UIStackView()
|
||||||
|
private let versionRow = SettingMenuRow(title: "系统版本", showsChevron: false)
|
||||||
|
private let copyrightLabel = UILabel()
|
||||||
|
|
||||||
|
init(
|
||||||
|
viewModel: SettingViewModel = SettingViewModel(),
|
||||||
|
settingAPI: SettingAPI = NetworkServices.shared.settingAPI
|
||||||
|
) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.settingAPI = settingAPI
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupNavigationBar() {
|
||||||
|
title = "设置"
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupUI() {
|
||||||
|
view.backgroundColor = AppColor.pageBackground
|
||||||
|
|
||||||
|
view.addSubview(contentView)
|
||||||
|
contentView.addSubview(cardView)
|
||||||
|
cardView.addSubview(rowsStack)
|
||||||
|
contentView.addSubview(copyrightLabel)
|
||||||
|
|
||||||
|
cardView.backgroundColor = .white
|
||||||
|
cardView.layer.cornerRadius = 12
|
||||||
|
cardView.clipsToBounds = true
|
||||||
|
|
||||||
|
rowsStack.axis = .vertical
|
||||||
|
|
||||||
|
copyrightLabel.text = "Copyright © 2025 All Rights Reserved\n苏ICP备2025157647号"
|
||||||
|
copyrightLabel.font = .systemFont(ofSize: 14)
|
||||||
|
copyrightLabel.textColor = UIColor(hex: 0xB6BECA)
|
||||||
|
copyrightLabel.textAlignment = .center
|
||||||
|
copyrightLabel.numberOfLines = 0
|
||||||
|
|
||||||
|
let rows: [SettingMenuRow] = [
|
||||||
|
SettingMenuRow(title: "关于我们"),
|
||||||
|
versionRow,
|
||||||
|
SettingMenuRow(title: "App下载", value: "复制链接", valueColor: AppColor.primary, showsChevron: false),
|
||||||
|
SettingMenuRow(title: "用户协议"),
|
||||||
|
SettingMenuRow(title: "隐私政策", showsDivider: false),
|
||||||
|
]
|
||||||
|
rows.forEach(rowsStack.addArrangedSubview)
|
||||||
|
|
||||||
|
rows[0].addTarget(self, action: #selector(aboutTapped), for: .touchUpInside)
|
||||||
|
rows[2].addTarget(self, action: #selector(copyDownloadTapped), for: .touchUpInside)
|
||||||
|
rows[3].addTarget(self, action: #selector(userAgreementTapped), for: .touchUpInside)
|
||||||
|
rows[4].addTarget(self, action: #selector(privacyTapped), for: .touchUpInside)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setupConstraints() {
|
||||||
|
contentView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalTo(view.safeAreaLayoutGuide).inset(16)
|
||||||
|
}
|
||||||
|
cardView.snp.makeConstraints { make in
|
||||||
|
make.top.leading.trailing.equalToSuperview()
|
||||||
|
}
|
||||||
|
rowsStack.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 16))
|
||||||
|
}
|
||||||
|
copyrightLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.equalToSuperview()
|
||||||
|
make.bottom.equalToSuperview().offset(-16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func bindActions() {
|
||||||
|
viewModel.onStateChange = { [weak self] in
|
||||||
|
Task { @MainActor in self?.applyViewModel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
applyViewModel()
|
||||||
|
Task { await viewModel.checkLatestVersion(api: settingAPI) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyViewModel() {
|
||||||
|
versionRow.configure(value: viewModel.appVersion, showsUpgrade: viewModel.hasNewVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func aboutTapped() {
|
||||||
|
openAgreement(.aboutUs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func copyDownloadTapped() {
|
||||||
|
UIPasteboard.general.string = viewModel.appDownloadURL.absoluteString
|
||||||
|
showToast("已复制到剪贴板!")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func userAgreementTapped() {
|
||||||
|
openAgreement(.userAgreement)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func privacyTapped() {
|
||||||
|
openAgreement(.privacyPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openAgreement(_ kind: SettingAgreementKind) {
|
||||||
|
let destination = viewModel.agreementDestination(for: kind)
|
||||||
|
navigationController?.pushViewController(
|
||||||
|
AgreementWebViewController(title: destination.title, url: destination.url),
|
||||||
|
animated: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置中心菜单行,展示标题、右侧文本、更新标记和箭头。
|
||||||
|
final class SettingMenuRow: UIControl {
|
||||||
|
private let titleLabel = UILabel()
|
||||||
|
private let rightStack = UIStackView()
|
||||||
|
private let valueLabel = UILabel()
|
||||||
|
private let upgradeImageView = UIImageView()
|
||||||
|
private let chevronImageView = UIImageView()
|
||||||
|
private let divider = UIView()
|
||||||
|
private let showsChevron: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
title: String,
|
||||||
|
value: String? = nil,
|
||||||
|
valueColor: UIColor = AppColor.textPrimary,
|
||||||
|
showsChevron: Bool = true,
|
||||||
|
showsDivider: Bool = true
|
||||||
|
) {
|
||||||
|
self.showsChevron = showsChevron
|
||||||
|
super.init(frame: .zero)
|
||||||
|
setupUI()
|
||||||
|
setupConstraints()
|
||||||
|
titleLabel.text = title
|
||||||
|
valueLabel.text = value
|
||||||
|
valueLabel.textColor = valueColor
|
||||||
|
chevronImageView.isHidden = !showsChevron
|
||||||
|
divider.isHidden = !showsDivider
|
||||||
|
accessibilityTraits.insert(.button)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override var isHighlighted: Bool {
|
||||||
|
didSet {
|
||||||
|
backgroundColor = isHighlighted ? UIColor.black.withAlphaComponent(0.04) : .clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func configure(value: String?, showsUpgrade: Bool) {
|
||||||
|
valueLabel.text = value
|
||||||
|
upgradeImageView.isHidden = !showsUpgrade
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
titleLabel.font = .systemFont(ofSize: 14)
|
||||||
|
titleLabel.textColor = UIColor(hex: 0x4B5563)
|
||||||
|
|
||||||
|
valueLabel.font = .systemFont(ofSize: 14)
|
||||||
|
valueLabel.textColor = AppColor.textPrimary
|
||||||
|
|
||||||
|
upgradeImageView.image = UIImage(systemName: "arrow.up.circle")?.withRenderingMode(.alwaysTemplate)
|
||||||
|
upgradeImageView.tintColor = UIColor(hex: 0x1F1F1F)
|
||||||
|
upgradeImageView.contentMode = .scaleAspectFit
|
||||||
|
upgradeImageView.isHidden = true
|
||||||
|
|
||||||
|
chevronImageView.image = UIImage(systemName: "chevron.right")?.withRenderingMode(.alwaysTemplate)
|
||||||
|
chevronImageView.tintColor = AppColor.textTertiary
|
||||||
|
chevronImageView.contentMode = .scaleAspectFit
|
||||||
|
|
||||||
|
divider.backgroundColor = AppColor.border
|
||||||
|
|
||||||
|
rightStack.axis = .horizontal
|
||||||
|
rightStack.alignment = .center
|
||||||
|
rightStack.spacing = 4
|
||||||
|
[valueLabel, upgradeImageView, chevronImageView].forEach(rightStack.addArrangedSubview)
|
||||||
|
|
||||||
|
[titleLabel, rightStack, divider].forEach(addSubview)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupConstraints() {
|
||||||
|
snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(52)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleLabel.snp.makeConstraints { make in
|
||||||
|
make.leading.centerY.equalToSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
upgradeImageView.snp.makeConstraints { make in
|
||||||
|
make.width.height.equalTo(24)
|
||||||
|
}
|
||||||
|
|
||||||
|
chevronImageView.snp.makeConstraints { make in
|
||||||
|
make.width.height.equalTo(24)
|
||||||
|
}
|
||||||
|
|
||||||
|
rightStack.snp.makeConstraints { make in
|
||||||
|
make.centerY.equalToSuperview()
|
||||||
|
make.trailing.equalToSuperview()
|
||||||
|
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
divider.snp.makeConstraints { make in
|
||||||
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
|
make.height.equalTo(0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,优先展示本地图片或视频封面。
|
/// 举报模块附件缩略 tile,优先展示本地图片或视频封面。
|
||||||
final class WildReportAttachmentTileView: UIControl {
|
final class WildReportAttachmentTileView: UIControl {
|
||||||
private let gradientLayer = CAGradientLayer()
|
private let gradientLayer = CAGradientLayer()
|
||||||
|
|||||||
@ -9,11 +9,16 @@ import UIKit
|
|||||||
/// 举报摄影师首页,提供立即举报、我的举报、风险地图和规则入口。
|
/// 举报摄影师首页,提供立即举报、我的举报、风险地图和规则入口。
|
||||||
final class WildPhotographerReportHomeViewController: BaseViewController {
|
final class WildPhotographerReportHomeViewController: BaseViewController {
|
||||||
private let viewModel: WildPhotographerReportHomeViewModel
|
private let viewModel: WildPhotographerReportHomeViewModel
|
||||||
|
private let api: any WildPhotographerReportServing
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let contentStack = UIStackView()
|
private let contentStack = UIStackView()
|
||||||
|
|
||||||
init(viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel()) {
|
init(
|
||||||
|
viewModel: WildPhotographerReportHomeViewModel = WildPhotographerReportHomeViewModel(),
|
||||||
|
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI
|
||||||
|
) {
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
|
self.api = api
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,20 +30,6 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
|
|||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "举报摄影师"
|
title = "举报摄影师"
|
||||||
navigationItem.largeTitleDisplayMode = .never
|
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() {
|
override func setupUI() {
|
||||||
@ -68,6 +59,16 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
|
|||||||
viewModel.onStateChange = { [weak self] in
|
viewModel.onStateChange = { [weak self] in
|
||||||
Task { @MainActor in self?.rebuildContent() }
|
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
|
@MainActor
|
||||||
@ -81,18 +82,16 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
|
|||||||
case .normal:
|
case .normal:
|
||||||
contentStack.addArrangedSubview(makeHeroCard())
|
contentStack.addArrangedSubview(makeHeroCard())
|
||||||
contentStack.addArrangedSubview(makeRiskMapEntry())
|
contentStack.addArrangedSubview(makeRiskMapEntry())
|
||||||
contentStack.addArrangedSubview(makeNoticeCard())
|
if viewModel.shouldShowNoticeModule {
|
||||||
|
contentStack.addArrangedSubview(makeNoticeCard())
|
||||||
|
}
|
||||||
contentStack.addArrangedSubview(makeTrustCard())
|
contentStack.addArrangedSubview(makeTrustCard())
|
||||||
case .loading:
|
case .loading:
|
||||||
contentStack.addArrangedSubview(makeStateCard(icon: "clock", title: "加载中", message: "正在加载举报服务"))
|
contentStack.addArrangedSubview(makeStateCard(icon: "clock", title: "加载中", message: "正在加载举报服务"))
|
||||||
case .empty:
|
case .empty:
|
||||||
contentStack.addArrangedSubview(makeStateCard(icon: "exclamationmark.shield", title: viewModel.emptyTitle, message: viewModel.emptyMessage, buttonTitle: "恢复演示数据") { [weak self] in
|
contentStack.addArrangedSubview(makeStateCard(icon: "exclamationmark.shield", title: viewModel.emptyTitle, message: viewModel.emptyMessage))
|
||||||
self?.viewModel.restoreDemoData()
|
|
||||||
})
|
|
||||||
case .error:
|
case .error:
|
||||||
contentStack.addArrangedSubview(makeStateCard(icon: "wifi.exclamationmark", title: "加载失败", message: "举报服务暂时不可用,请稍后重试。", buttonTitle: "重新加载") { [weak self] in
|
contentStack.addArrangedSubview(makeStateCard(icon: "wifi.exclamationmark", title: "加载失败", message: "举报服务暂时不可用,请稍后重试。"))
|
||||||
self?.viewModel.retryLoadAfterError()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -172,31 +171,28 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
|
|||||||
headerIcon.tintColor = AppColor.primary
|
headerIcon.tintColor = AppColor.primary
|
||||||
headerIcon.snp.makeConstraints { make in make.size.equalTo(24) }
|
headerIcon.snp.makeConstraints { make in make.size.equalTo(24) }
|
||||||
let title = WildReportUI.label("举报须知", font: .systemFont(ofSize: 18, weight: .bold), color: AppColor.textPrimary)
|
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(headerIcon)
|
||||||
header.addArrangedSubview(title)
|
header.addArrangedSubview(title)
|
||||||
header.addArrangedSubview(UIView())
|
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)
|
card.stack.addArrangedSubview(header)
|
||||||
items.forEach { item in
|
viewModel.noticeItems.forEach { item in
|
||||||
card.stack.addArrangedSubview(makeBullet(item))
|
card.stack.addArrangedSubview(makeBullet(item))
|
||||||
}
|
}
|
||||||
return card
|
return card
|
||||||
@ -206,7 +202,7 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
|
|||||||
let badges = UIStackView()
|
let badges = UIStackView()
|
||||||
badges.axis = .horizontal
|
badges.axis = .horizontal
|
||||||
badges.distribution = .fillEqually
|
badges.distribution = .fillEqually
|
||||||
badges.spacing = AppSpacing.sm
|
badges.spacing = AppSpacing.xs
|
||||||
[
|
[
|
||||||
("实名", "身份核验 真实可靠", "person.crop.circle.badge.checkmark", AppColor.primary, AppColor.primaryLight),
|
("实名", "身份核验 真实可靠", "person.crop.circle.badge.checkmark", AppColor.primary, AppColor.primaryLight),
|
||||||
("安全", "数据加密 隐私保护", "checkmark.shield.fill", AppColor.success, AppColor.successBackground),
|
("安全", "数据加密 隐私保护", "checkmark.shield.fill", AppColor.success, AppColor.successBackground),
|
||||||
@ -308,19 +304,14 @@ final class WildPhotographerReportHomeViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func mapTapped() {
|
@objc private func mapTapped() {
|
||||||
let record = viewModel.reports.first ?? WildPhotographerReportMockStore.seedReports[0]
|
|
||||||
navigationController?.pushViewController(
|
navigationController?.pushViewController(
|
||||||
WildReportRiskMapViewController(record: record, riskPoints: viewModel.riskPoints),
|
WildReportRiskMapViewController(),
|
||||||
animated: true
|
animated: true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func ruleTapped() {
|
@objc private func ruleTapped() {
|
||||||
navigationController?.pushViewController(WildReportRuleViewController(), animated: true)
|
navigationController?.pushViewController(WildReportRuleViewController(ruleGroups: viewModel.ruleGroups), animated: true)
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func backTapped() {
|
|
||||||
navigationController?.popViewController(animated: true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -455,7 +446,19 @@ private final class WildReportTrustItemView: UIView {
|
|||||||
|
|
||||||
/// 举报规则页面。
|
/// 举报规则页面。
|
||||||
final class WildReportRuleViewController: BaseViewController {
|
final class WildReportRuleViewController: BaseViewController {
|
||||||
|
private let scrollView = UIScrollView()
|
||||||
private let stack = UIStackView()
|
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() {
|
override func setupNavigationBar() {
|
||||||
title = "举报规则"
|
title = "举报规则"
|
||||||
@ -463,24 +466,32 @@ final class WildReportRuleViewController: BaseViewController {
|
|||||||
|
|
||||||
override func setupUI() {
|
override func setupUI() {
|
||||||
view.backgroundColor = AppColor.pageBackgroundSoft
|
view.backgroundColor = AppColor.pageBackgroundSoft
|
||||||
|
scrollView.alwaysBounceVertical = true
|
||||||
|
scrollView.showsVerticalScrollIndicator = false
|
||||||
stack.axis = .vertical
|
stack.axis = .vertical
|
||||||
stack.spacing = AppSpacing.sm
|
stack.spacing = AppSpacing.sm
|
||||||
view.addSubview(stack)
|
view.addSubview(scrollView)
|
||||||
addSection(title: "可举报行为", items: ["疑似打野摄影师主动揽客", "未佩戴工牌开展摄影服务", "引导游客线下付款或私下交易"])
|
scrollView.addSubview(stack)
|
||||||
addSection(title: "材料要求", items: ["尽量提供现场图片、视频、位置和文字说明", "联系方式可选填,用于景区工作人员核实"])
|
ruleGroups.forEach { group in
|
||||||
addSection(title: "处理说明", items: ["举报提交后景区公安会接收线索", "处理进度可在“我的举报”中查看", "请勿提交虚假或恶意举报"])
|
addSection(title: group.title, items: group.items)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setupConstraints() {
|
override func setupConstraints() {
|
||||||
|
scrollView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||||
|
}
|
||||||
stack.snp.makeConstraints { make in
|
stack.snp.makeConstraints { make in
|
||||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md)
|
make.edges.equalTo(scrollView.contentLayoutGuide).inset(AppSpacing.md)
|
||||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
make.width.equalTo(scrollView.frameLayoutGuide).offset(-AppSpacing.md * 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addSection(title: String, items: [String]) {
|
private func addSection(title: String, items: [String]) {
|
||||||
let card = WildReportCardView(spacing: AppSpacing.sm)
|
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
|
items.forEach { item in
|
||||||
card.stack.addArrangedSubview(WildReportUI.label("· \(item)", font: .app(.body), color: AppColor.textSecondary, lines: 0))
|
card.stack.addArrangedSubview(WildReportUI.label("· \(item)", font: .app(.body), color: AppColor.textSecondary, lines: 0))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Kingfisher
|
import Kingfisher
|
||||||
|
import MAMapKit
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@ -928,7 +929,7 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
viewModel.paymentScreenshotItems.forEach { item in
|
viewModel.paymentScreenshotItems.forEach { item in
|
||||||
let tile = WildReportDetailMediaThumbnailView(reporterItem: item)
|
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
|
tile.snp.makeConstraints { make in
|
||||||
make.size.equalTo(108)
|
make.size.equalTo(108)
|
||||||
}
|
}
|
||||||
@ -987,7 +988,8 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
|||||||
section.addArrangedSubview(makeLocationHeader())
|
section.addArrangedSubview(makeLocationHeader())
|
||||||
let mapPreview = WildReportDetailMapPreviewView(
|
let mapPreview = WildReportDetailMapPreviewView(
|
||||||
scenicName: viewModel.scenicAreaName,
|
scenicName: viewModel.scenicAreaName,
|
||||||
detailAddress: viewModel.detailAddress
|
detailAddress: viewModel.detailAddress,
|
||||||
|
coordinate: viewModel.mapCoordinate
|
||||||
)
|
)
|
||||||
section.addArrangedSubview(mapPreview)
|
section.addArrangedSubview(mapPreview)
|
||||||
mapPreview.snp.makeConstraints { make in
|
mapPreview.snp.makeConstraints { make in
|
||||||
@ -1198,18 +1200,63 @@ final class WildPhotographerReportDetailViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func shareTapped() { viewModel.shareReport() }
|
@objc private func shareTapped() { viewModel.shareReport() }
|
||||||
@objc private func previewTapped() { viewModel.showMediaPreview(kind: "图片/视频") }
|
@objc private func previewTapped() { presentReporterMediaPreview(selectedKey: nil) }
|
||||||
@objc private func paymentPreviewTapped() { viewModel.showMediaPreview(kind: "支付截图") }
|
@objc private func paymentPreviewTapped() { presentPaymentMediaPreview(selectedKey: nil) }
|
||||||
@objc private func locationTapped() { viewModel.showLocationPreview() }
|
@objc private func locationTapped() { viewModel.showLocationPreview() }
|
||||||
|
|
||||||
@objc private func reporterMediaTapped(_ recognizer: UITapGestureRecognizer) {
|
@objc private func reporterMediaTapped(_ recognizer: UITapGestureRecognizer) {
|
||||||
guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return }
|
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) {
|
@objc private func completionMediaTapped(_ recognizer: UITapGestureRecognizer) {
|
||||||
guard let tile = recognizer.view as? WildReportDetailMediaThumbnailView else { return }
|
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() {
|
@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 {
|
private final class WildReportDetailMediaThumbnailView: UIView {
|
||||||
let previewKindText: String
|
let previewKindText: String
|
||||||
|
let previewKey: String?
|
||||||
private let gradient = CAGradientLayer()
|
private let gradient = CAGradientLayer()
|
||||||
private let remoteImageView = UIImageView()
|
private let remoteImageView = UIImageView()
|
||||||
private let footerLabel = PaddingLabel(insets: UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8))
|
private let footerLabel = PaddingLabel(insets: UIEdgeInsets(top: 3, left: 8, bottom: 3, right: 8))
|
||||||
private let playView = UIImageView(image: UIImage(systemName: "play.circle.fill"))
|
private let playView = UIImageView(image: UIImage(systemName: "play.circle.fill"))
|
||||||
|
|
||||||
init(reporterItem: WildReportReporterMediaItem) {
|
init(reporterItem: WildReportReporterMediaItem) {
|
||||||
|
previewKey = reporterItem.previewKey
|
||||||
switch reporterItem {
|
switch reporterItem {
|
||||||
case .image(let index, let url, let fileName, let fileTypeText):
|
case .image(let index, let url, let fileName, let fileTypeText):
|
||||||
previewKindText = fileName?.isEmpty == false ? fileName! : "第\(index + 1)张举报图片"
|
previewKindText = fileName?.isEmpty == false ? fileName! : "第\(index + 1)张举报图片"
|
||||||
@ -1342,11 +1431,12 @@ private final class WildReportDetailMediaThumbnailView: UIView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(completionItem: WildReportCompletionMediaItem) {
|
init(completionItem: WildReportCompletionMediaItem) {
|
||||||
|
previewKey = completionItem.previewKey
|
||||||
switch completionItem.kind {
|
switch completionItem.kind {
|
||||||
case .image:
|
case .image:
|
||||||
previewKindText = "处理凭证图片"
|
previewKindText = "处理凭证图片"
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
configure(kind: .image(index: 0), footerText: "凭证")
|
configure(kind: .image(index: 0), footerText: "凭证", imageURL: completionItem.coverURL)
|
||||||
case .video:
|
case .video:
|
||||||
previewKindText = "处理凭证视频"
|
previewKindText = "处理凭证视频"
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
@ -1500,107 +1590,41 @@ private final class WildReportDetailMediaThumbnailView: UIView {
|
|||||||
|
|
||||||
/// 举报详情页地图预览,绘制简化道路、路径和定位点。
|
/// 举报详情页地图预览,绘制简化道路、路径和定位点。
|
||||||
private final class WildReportDetailMapPreviewView: UIView {
|
private final class WildReportDetailMapPreviewView: UIView {
|
||||||
private let scenicName: String
|
private let mapView: MAMapView
|
||||||
private let detailAddress: String
|
|
||||||
private let detailLabel = UILabel()
|
|
||||||
private let scenicLabel = UILabel()
|
|
||||||
private let pinView = UIImageView(image: UIImage(systemName: "mappin.circle.fill"))
|
|
||||||
|
|
||||||
init(scenicName: String, detailAddress: String) {
|
init(scenicName: String, detailAddress: String, coordinate: CLLocationCoordinate2D?) {
|
||||||
self.scenicName = scenicName
|
_ = AMapBootstrap.configureIfNeeded()
|
||||||
self.detailAddress = detailAddress
|
mapView = MAMapView(frame: .zero)
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
backgroundColor = UIColor(hex: 0xE1F4D8)
|
backgroundColor = AppColor.pageBackgroundSoft
|
||||||
layer.cornerRadius = 10
|
layer.cornerRadius = 10
|
||||||
clipsToBounds = true
|
clipsToBounds = true
|
||||||
|
|
||||||
detailLabel.text = detailAddress.replacingOccurrences(of: "附近", with: "")
|
mapView.showsCompass = false
|
||||||
detailLabel.font = .systemFont(ofSize: 13, weight: .semibold)
|
mapView.showsScale = false
|
||||||
detailLabel.textColor = UIColor(hex: 0x64748B)
|
mapView.isScrollEnabled = false
|
||||||
detailLabel.textAlignment = .center
|
mapView.isZoomEnabled = false
|
||||||
|
mapView.isRotateEnabled = false
|
||||||
scenicLabel.text = scenicName
|
mapView.isRotateCameraEnabled = false
|
||||||
scenicLabel.font = .systemFont(ofSize: 13, weight: .semibold)
|
mapView.zoomLevel = 16
|
||||||
scenicLabel.textColor = UIColor(hex: 0x418C48)
|
addSubview(mapView)
|
||||||
scenicLabel.textAlignment = .center
|
mapView.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
@available(*, unavailable)
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 举报详情页处理进度时间线单行。
|
/// 举报详情页处理进度时间线单行。
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import QuickLook
|
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@ -20,7 +19,6 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
|||||||
private let contactField = UITextField()
|
private let contactField = UITextField()
|
||||||
private let submitButton = UIButton(type: .system)
|
private let submitButton = UIButton(type: .system)
|
||||||
private var pickerTarget: WildReportPickerTarget = .image
|
private var pickerTarget: WildReportPickerTarget = .image
|
||||||
private var previewURL: URL?
|
|
||||||
private weak var activeInputView: UIView?
|
private weak var activeInputView: UIView?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@ -213,7 +211,12 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
|||||||
grid.axis = .vertical
|
grid.axis = .vertical
|
||||||
grid.spacing = 8
|
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
|
stride(from: 0, to: types.count, by: 3).forEach { startIndex in
|
||||||
let rowTypes = Array(types[startIndex ..< min(startIndex + 3, types.count)])
|
let rowTypes = Array(types[startIndex ..< min(startIndex + 3, types.count)])
|
||||||
let row = UIStackView()
|
let row = UIStackView()
|
||||||
@ -223,7 +226,7 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
|||||||
|
|
||||||
rowTypes.forEach { type in
|
rowTypes.forEach { type in
|
||||||
let button = WildReportTypeOptionButton()
|
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.addAction(UIAction { [weak self] _ in self?.viewModel.selectReportType(type) }, for: .touchUpInside)
|
||||||
button.snp.makeConstraints { make in make.height.equalTo(72) }
|
button.snp.makeConstraints { make in make.height.equalTo(72) }
|
||||||
row.addArrangedSubview(button)
|
row.addArrangedSubview(button)
|
||||||
@ -537,14 +540,11 @@ final class WildPhotographerReportSubmitViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func previewAttachment(_ attachment: WildReportAttachment) {
|
private func previewAttachment(_ attachment: WildReportAttachment) {
|
||||||
guard let url = attachment.localFileURL else {
|
guard let item = MediaPreviewItem(wildReportAttachment: attachment) else {
|
||||||
showToast("当前附件暂无本地预览")
|
showToast("当前附件暂无本地预览")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
previewURL = url
|
presentMediaPreview(items: [item], startIndex: 0)
|
||||||
let preview = QLPreviewController()
|
|
||||||
preview.dataSource = self
|
|
||||||
present(preview, animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func refreshLocationTapped() { viewModel.refreshCurrentLocation() }
|
@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 {
|
extension WildPhotographerReportSubmitViewController: UITextViewDelegate {
|
||||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||||
activeInputView = textView
|
activeInputView = textView
|
||||||
|
|||||||
@ -21,22 +21,21 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
private let mapView = MKMapView()
|
private let mapView = MKMapView()
|
||||||
private let scenicCapsule = UIView()
|
private let scenicCapsule = UIView()
|
||||||
private let scenicNameLabel = UILabel()
|
private let scenicNameLabel = UILabel()
|
||||||
|
private let locateButton = UIButton(type: .system)
|
||||||
private let legendBar = UIStackView()
|
private let legendBar = UIStackView()
|
||||||
private let detailContainerView = UIView()
|
private let detailContainerView = UIView()
|
||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let contentStack = UIStackView()
|
private let contentStack = UIStackView()
|
||||||
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
|
||||||
private var annotationMap: [String: WildReportMapMarker] = [:]
|
private var annotationMap: [String: WildReportMapMarker] = [:]
|
||||||
|
private var isShowingRiskMapLoading = false
|
||||||
|
|
||||||
init(
|
init(
|
||||||
record: WildReportRecord,
|
|
||||||
riskPoints: [WildReportRiskPoint],
|
|
||||||
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
|
api: any WildPhotographerReportServing = NetworkServices.shared.wildPhotographerReportAPI,
|
||||||
locationProvider: any LocationProviding = LocationProvider.shared,
|
locationProvider: any LocationProviding = LocationProvider.shared,
|
||||||
scenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId },
|
scenicIdProvider: @escaping () -> Int = { AppStore.shared.currentScenicId },
|
||||||
scenicNameProvider: @escaping () -> String = { AppStore.shared.currentScenicName }
|
scenicNameProvider: @escaping () -> String = { AppStore.shared.currentScenicName }
|
||||||
) {
|
) {
|
||||||
self.viewModel = WildReportRiskMapViewModel(record: record, riskPoints: riskPoints)
|
self.viewModel = WildReportRiskMapViewModel()
|
||||||
self.api = api
|
self.api = api
|
||||||
self.locationProvider = locationProvider
|
self.locationProvider = locationProvider
|
||||||
self.scenicIdProvider = scenicIdProvider
|
self.scenicIdProvider = scenicIdProvider
|
||||||
@ -61,6 +60,13 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
|
super.viewDidDisappear(animated)
|
||||||
|
if isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
|
||||||
|
setRiskMapLoadingVisible(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override func setupNavigationBar() {
|
override func setupNavigationBar() {
|
||||||
title = "附近风险地图"
|
title = "附近风险地图"
|
||||||
}
|
}
|
||||||
@ -82,6 +88,7 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
mapContainerView.addSubview(mapView)
|
mapContainerView.addSubview(mapView)
|
||||||
|
|
||||||
configureScenicCapsule()
|
configureScenicCapsule()
|
||||||
|
configureLocateButton()
|
||||||
configureLegendBar()
|
configureLegendBar()
|
||||||
configureDetailArea()
|
configureDetailArea()
|
||||||
refreshContent(animated: false)
|
refreshContent(animated: false)
|
||||||
@ -101,6 +108,10 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
make.top.leading.equalToSuperview().inset(12)
|
make.top.leading.equalToSuperview().inset(12)
|
||||||
make.height.equalTo(34)
|
make.height.equalTo(34)
|
||||||
}
|
}
|
||||||
|
locateButton.snp.makeConstraints { make in
|
||||||
|
make.top.trailing.equalToSuperview().inset(12)
|
||||||
|
make.size.equalTo(44)
|
||||||
|
}
|
||||||
detailContainerView.snp.makeConstraints { make in
|
detailContainerView.snp.makeConstraints { make in
|
||||||
make.height.greaterThanOrEqualTo(180)
|
make.height.greaterThanOrEqualTo(180)
|
||||||
}
|
}
|
||||||
@ -112,9 +123,6 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
self?.refreshContent(animated: true)
|
self?.refreshContent(animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModel.onShowMessage = { [weak self] message in
|
|
||||||
Task { @MainActor in self?.showToast(message) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureScenicCapsule() {
|
private func configureScenicCapsule() {
|
||||||
@ -146,6 +154,20 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func configureLocateButton() {
|
||||||
|
locateButton.backgroundColor = UIColor.white.withAlphaComponent(0.96)
|
||||||
|
locateButton.tintColor = AppColor.primary
|
||||||
|
locateButton.setImage(UIImage(systemName: "location.fill"), for: .normal)
|
||||||
|
locateButton.layer.cornerRadius = 22
|
||||||
|
locateButton.layer.shadowColor = UIColor.black.cgColor
|
||||||
|
locateButton.layer.shadowOpacity = 0.1
|
||||||
|
locateButton.layer.shadowRadius = 8
|
||||||
|
locateButton.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||||
|
locateButton.accessibilityLabel = "回到我的位置"
|
||||||
|
locateButton.addTarget(self, action: #selector(locateButtonTapped), for: .touchUpInside)
|
||||||
|
mapContainerView.addSubview(locateButton)
|
||||||
|
}
|
||||||
|
|
||||||
private func configureLegendBar() {
|
private func configureLegendBar() {
|
||||||
legendBar.axis = .horizontal
|
legendBar.axis = .horizontal
|
||||||
legendBar.alignment = .center
|
legendBar.alignment = .center
|
||||||
@ -177,14 +199,16 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
make.edges.equalToSuperview()
|
make.edges.equalToSuperview()
|
||||||
}
|
}
|
||||||
contentStack.snp.makeConstraints { make in
|
contentStack.snp.makeConstraints { make in
|
||||||
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 14, left: 18, bottom: 18, right: 18))
|
make.edges.equalToSuperview().inset(16)
|
||||||
make.width.equalTo(scrollView.snp.width).offset(-36)
|
make.width.equalTo(scrollView.snp.width).offset(-32)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func refreshContent(animated: Bool) {
|
private func refreshContent(animated: Bool) {
|
||||||
|
setRiskMapLoadingVisible(viewModel.isLoading)
|
||||||
scenicNameLabel.text = viewModel.scenicAreaName
|
scenicNameLabel.text = viewModel.scenicAreaName
|
||||||
|
updateLocateButtonState()
|
||||||
syncMapAnnotations()
|
syncMapAnnotations()
|
||||||
mapView.setRegion(viewModel.region, animated: animated)
|
mapView.setRegion(viewModel.region, animated: animated)
|
||||||
updateAnnotationSelection()
|
updateAnnotationSelection()
|
||||||
@ -192,6 +216,20 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
rebuildPanel()
|
rebuildPanel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func setRiskMapLoadingVisible(_ isVisible: Bool) {
|
||||||
|
guard isShowingRiskMapLoading != isVisible else { return }
|
||||||
|
isShowingRiskMapLoading = isVisible
|
||||||
|
isVisible ? showLoading() : hideLoading()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func updateLocateButtonState() {
|
||||||
|
let hasCurrentLocation = viewModel.markers.contains { $0.kind == .selfLocation }
|
||||||
|
locateButton.isEnabled = hasCurrentLocation
|
||||||
|
locateButton.alpha = hasCurrentLocation ? 1 : 0.46
|
||||||
|
}
|
||||||
|
|
||||||
private func syncMapAnnotations() {
|
private func syncMapAnnotations() {
|
||||||
let oldAnnotations = mapView.annotations.compactMap { $0 as? WildReportMapAnnotation }
|
let oldAnnotations = mapView.annotations.compactMap { $0 as? WildReportMapAnnotation }
|
||||||
mapView.removeAnnotations(oldAnnotations)
|
mapView.removeAnnotations(oldAnnotations)
|
||||||
@ -234,9 +272,6 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
view.removeFromSuperview()
|
view.removeFromSuperview()
|
||||||
}
|
}
|
||||||
|
|
||||||
if viewModel.isLoading {
|
|
||||||
contentStack.addArrangedSubview(makeLoadingView(text: "正在加载风险点位..."))
|
|
||||||
}
|
|
||||||
if let message = viewModel.errorMessage, !viewModel.isLoading {
|
if let message = viewModel.errorMessage, !viewModel.isLoading {
|
||||||
contentStack.addArrangedSubview(makeStateCard(icon: "exclamationmark.triangle.fill", text: message))
|
contentStack.addArrangedSubview(makeStateCard(icon: "exclamationmark.triangle.fill", text: message))
|
||||||
}
|
}
|
||||||
@ -248,39 +283,63 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let card = WildReportCardView(spacing: 16, inset: 16)
|
let card = makeRadarResultCard(clue: clue)
|
||||||
card.layer.cornerRadius = 16
|
|
||||||
card.stack.addArrangedSubview(makeHeader(marker: marker, clue: clue))
|
|
||||||
if viewModel.isLoadingDetail {
|
|
||||||
card.stack.addArrangedSubview(makeInlineLoading(text: "正在加载标记详情..."))
|
|
||||||
}
|
|
||||||
if let nearest = clue.nearestAbnormalText {
|
|
||||||
card.stack.addArrangedSubview(makeHighlight(text: nearest, color: AppColor.danger))
|
|
||||||
}
|
|
||||||
addInfoRows(for: clue, to: card.stack)
|
|
||||||
addDescriptionBlocks(for: clue, to: card.stack)
|
|
||||||
addMediaRow(for: clue, to: card.stack)
|
|
||||||
addProcessingBlock(for: clue, to: card.stack)
|
|
||||||
card.stack.addArrangedSubview(makeButtonRow(for: clue))
|
|
||||||
contentStack.addArrangedSubview(card)
|
contentStack.addArrangedSubview(card)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeHeader(marker: WildReportMapMarker, clue: WildReportRadarActiveClue) -> UIView {
|
private func makeRadarResultCard(clue: WildReportRadarActiveClue) -> UIView {
|
||||||
|
let card = WildReportCardView(spacing: 14, inset: 16)
|
||||||
|
card.layer.cornerRadius = 16
|
||||||
|
card.stack.spacing = 14
|
||||||
|
card.stack.addArrangedSubview(makeRadarHeader(clue: clue))
|
||||||
|
|
||||||
|
if clue.type == .blue, let nearest = clue.nearestAbnormalText {
|
||||||
|
card.stack.addArrangedSubview(makeCompareText(nearest))
|
||||||
|
}
|
||||||
|
|
||||||
|
if clue.type == .green {
|
||||||
|
addPhotographerProfile(for: clue, to: card.stack)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isReportClue(clue) {
|
||||||
|
card.stack.addArrangedSubview(makeReporterInfo(clue: clue))
|
||||||
|
addReportContents(for: clue, to: card.stack)
|
||||||
|
addRadarImages(for: clue, to: card.stack)
|
||||||
|
if clue.processingStarted, let record = clue.record {
|
||||||
|
card.stack.addArrangedSubview(makeProcessingPanel(clue: clue, record: record))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if clue.type == .green, let summary = clue.summary {
|
||||||
|
card.stack.addArrangedSubview(makeCompareText(summary))
|
||||||
|
}
|
||||||
|
|
||||||
|
if clue.type == .green, let detail = clue.detail {
|
||||||
|
card.stack.addArrangedSubview(makeDetailSection(detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !clue.completed {
|
||||||
|
card.stack.addArrangedSubview(makeRadarActionButtons(for: clue))
|
||||||
|
}
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRadarHeader(clue: WildReportRadarActiveClue) -> UIView {
|
||||||
let row = UIStackView()
|
let row = UIStackView()
|
||||||
row.axis = .horizontal
|
row.axis = .horizontal
|
||||||
row.alignment = .center
|
row.alignment = .top
|
||||||
row.spacing = 12
|
row.spacing = 12
|
||||||
|
|
||||||
let iconWrap = UIView()
|
let iconWrap = UIView()
|
||||||
iconWrap.backgroundColor = marker.kind.color.withAlphaComponent(0.14)
|
iconWrap.backgroundColor = headerIconColor(for: clue)
|
||||||
iconWrap.layer.cornerRadius = 22
|
iconWrap.layer.cornerRadius = 12
|
||||||
let icon = UIImageView(image: UIImage(systemName: iconName(for: marker.kind)))
|
let icon = UIImageView(image: UIImage(systemName: "location.fill"))
|
||||||
icon.tintColor = marker.kind.color
|
icon.tintColor = .white
|
||||||
icon.contentMode = .scaleAspectFit
|
icon.contentMode = .scaleAspectFit
|
||||||
iconWrap.addSubview(icon)
|
iconWrap.addSubview(icon)
|
||||||
icon.snp.makeConstraints { make in
|
icon.snp.makeConstraints { make in
|
||||||
make.center.equalToSuperview()
|
make.center.equalToSuperview()
|
||||||
make.size.equalTo(22)
|
make.size.equalTo(20)
|
||||||
}
|
}
|
||||||
iconWrap.snp.makeConstraints { make in
|
iconWrap.snp.makeConstraints { make in
|
||||||
make.size.equalTo(44)
|
make.size.equalTo(44)
|
||||||
@ -288,14 +347,34 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
|
|
||||||
let textStack = UIStackView()
|
let textStack = UIStackView()
|
||||||
textStack.axis = .vertical
|
textStack.axis = .vertical
|
||||||
textStack.spacing = 4
|
textStack.spacing = 6
|
||||||
let title = WildReportUI.label(clue.displayTitle, font: .systemFont(ofSize: 17, weight: .bold), color: AppColor.textPrimary, lines: 2)
|
if isReportClue(clue) {
|
||||||
let metaText = [clue.distanceText ?? clue.distance, clue.updatedAt].compactMap { $0 }.joined(separator: " · ")
|
let title = WildReportUI.label("线索 ID:\(clue.id)", font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
|
||||||
let meta = WildReportUI.label(metaText.isEmpty ? marker.title : metaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary, lines: 1)
|
textStack.addArrangedSubview(title)
|
||||||
textStack.addArrangedSubview(title)
|
let metaRow = UIStackView()
|
||||||
textStack.addArrangedSubview(meta)
|
metaRow.axis = .horizontal
|
||||||
|
metaRow.spacing = 12
|
||||||
|
if let distance = clue.distanceText ?? clue.distance {
|
||||||
|
metaRow.addArrangedSubview(WildReportUI.label("距离:距离您\(distance)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
|
||||||
|
}
|
||||||
|
if let updatedAt = clue.updatedAt {
|
||||||
|
metaRow.addArrangedSubview(WildReportUI.label("时间:\(updatedAt)", font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
|
||||||
|
}
|
||||||
|
if !metaRow.arrangedSubviews.isEmpty {
|
||||||
|
textStack.addArrangedSubview(metaRow)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let title = WildReportUI.label(clue.displayTitle, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary, lines: 2)
|
||||||
|
textStack.addArrangedSubview(title)
|
||||||
|
if clue.type == .green {
|
||||||
|
let metaText = [clue.distance.map { "距蓝点 \($0)" }, clue.direction, clue.updatedAt].compactMap { $0 }.joined(separator: " ")
|
||||||
|
if !metaText.isEmpty {
|
||||||
|
textStack.addArrangedSubview(WildReportUI.label(metaText, font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let badge = WildReportRiskStatusBadge(text: clue.statusText, color: marker.kind.color)
|
let badge = WildReportRiskStatusBadge(text: clue.statusText, color: statusColor(for: clue))
|
||||||
row.addArrangedSubview(iconWrap)
|
row.addArrangedSubview(iconWrap)
|
||||||
row.addArrangedSubview(textStack)
|
row.addArrangedSubview(textStack)
|
||||||
row.addArrangedSubview(badge)
|
row.addArrangedSubview(badge)
|
||||||
@ -304,6 +383,341 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func addPhotographerProfile(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
|
||||||
|
let row = UIStackView()
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.alignment = .center
|
||||||
|
row.spacing = 12
|
||||||
|
|
||||||
|
let avatarWrap = UIView()
|
||||||
|
avatarWrap.backgroundColor = AppColor.success.withAlphaComponent(0.12)
|
||||||
|
avatarWrap.layer.cornerRadius = 28
|
||||||
|
let avatar = UIImageView(image: UIImage(systemName: clue.avatar ?? "person.crop.circle.fill"))
|
||||||
|
avatar.tintColor = AppColor.success
|
||||||
|
avatar.contentMode = .scaleAspectFit
|
||||||
|
avatarWrap.addSubview(avatar)
|
||||||
|
avatar.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.size.equalTo(42)
|
||||||
|
}
|
||||||
|
avatarWrap.snp.makeConstraints { make in
|
||||||
|
make.size.equalTo(56)
|
||||||
|
}
|
||||||
|
|
||||||
|
let textStack = UIStackView()
|
||||||
|
textStack.axis = .vertical
|
||||||
|
textStack.spacing = 4
|
||||||
|
if let name = clue.name {
|
||||||
|
textStack.addArrangedSubview(WildReportUI.label(name, font: .systemFont(ofSize: 16, weight: .bold), color: AppColor.textPrimary))
|
||||||
|
}
|
||||||
|
if let storeName = clue.storeName {
|
||||||
|
textStack.addArrangedSubview(WildReportUI.label(storeName, font: .systemFont(ofSize: 13, weight: .semibold), color: AppColor.success))
|
||||||
|
}
|
||||||
|
|
||||||
|
row.addArrangedSubview(avatarWrap)
|
||||||
|
row.addArrangedSubview(textStack)
|
||||||
|
stack.addArrangedSubview(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeReporterInfo(clue: WildReportRadarActiveClue) -> UIView {
|
||||||
|
let box = UIView()
|
||||||
|
box.backgroundColor = UIColor(hex: 0xF9FAFB)
|
||||||
|
box.layer.cornerRadius = 10
|
||||||
|
|
||||||
|
let inner = UIStackView()
|
||||||
|
inner.axis = .vertical
|
||||||
|
inner.spacing = 10
|
||||||
|
box.addSubview(inner)
|
||||||
|
inner.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
inner.addArrangedSubview(makeInfoLabelRow(label: "举报人", value: clue.reporterName ?? "-"))
|
||||||
|
inner.addArrangedSubview(makePhoneRow(
|
||||||
|
label: "举报人手机号",
|
||||||
|
phone: clue.reporterPhone,
|
||||||
|
displayPhone: clue.maskedReporterPhone ?? clue.reporterPhone
|
||||||
|
))
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addReportContents(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
|
||||||
|
guard !clue.reportContents.isEmpty else { return }
|
||||||
|
let section = UIStackView()
|
||||||
|
section.axis = .vertical
|
||||||
|
section.spacing = 10
|
||||||
|
for item in clue.reportContents {
|
||||||
|
let itemStack = UIStackView()
|
||||||
|
itemStack.axis = .vertical
|
||||||
|
itemStack.spacing = 4
|
||||||
|
if !item.time.isEmpty {
|
||||||
|
itemStack.addArrangedSubview(WildReportUI.label(item.time, font: .systemFont(ofSize: 11, weight: .semibold), color: AppColor.textSecondary))
|
||||||
|
}
|
||||||
|
itemStack.addArrangedSubview(makeLineSpacedLabel(item.content, font: .systemFont(ofSize: 13), color: AppColor.textPrimary))
|
||||||
|
section.addArrangedSubview(itemStack)
|
||||||
|
}
|
||||||
|
stack.addArrangedSubview(section)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addRadarImages(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
|
||||||
|
guard !clue.images.isEmpty else { return }
|
||||||
|
let section = UIStackView()
|
||||||
|
section.axis = .vertical
|
||||||
|
section.spacing = 10
|
||||||
|
section.addArrangedSubview(WildReportUI.label("现场图片/视频", font: .systemFont(ofSize: 15, weight: .bold), color: AppColor.textPrimary))
|
||||||
|
|
||||||
|
let scroll = UIScrollView()
|
||||||
|
scroll.showsHorizontalScrollIndicator = false
|
||||||
|
let row = UIStackView()
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.spacing = 10
|
||||||
|
scroll.addSubview(row)
|
||||||
|
row.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview()
|
||||||
|
make.height.equalTo(108)
|
||||||
|
}
|
||||||
|
for url in clue.images {
|
||||||
|
let tile = WildReportRiskImageTile(url: url, size: 108)
|
||||||
|
tile.onTap = { [weak self] in self?.presentImagePreview(url: url) }
|
||||||
|
row.addArrangedSubview(tile)
|
||||||
|
}
|
||||||
|
section.addArrangedSubview(scroll)
|
||||||
|
scroll.snp.makeConstraints { make in
|
||||||
|
make.height.equalTo(108)
|
||||||
|
}
|
||||||
|
stack.addArrangedSubview(section)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeProcessingPanel(clue: WildReportRadarActiveClue, record: WildReportRadarProcessRecord) -> UIView {
|
||||||
|
let box = UIView()
|
||||||
|
box.backgroundColor = UIColor(hex: 0xFFFBEB)
|
||||||
|
box.layer.cornerRadius = 10
|
||||||
|
|
||||||
|
let inner = UIStackView()
|
||||||
|
inner.axis = .vertical
|
||||||
|
inner.spacing = 10
|
||||||
|
box.addSubview(inner)
|
||||||
|
inner.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
inner.addArrangedSubview(makeInfoLabelRow(label: "处理人", value: record.handlerName))
|
||||||
|
inner.addArrangedSubview(makeInfoLabelRow(
|
||||||
|
label: "状态",
|
||||||
|
value: clue.completed ? "已处理" : "正在处理中",
|
||||||
|
valueColor: clue.completed ? AppColor.success : UIColor(hex: 0xF59E0B)
|
||||||
|
))
|
||||||
|
inner.addArrangedSubview(makePhoneRow(label: "联系电话", phone: record.phone, displayPhone: record.maskedPhone))
|
||||||
|
|
||||||
|
let progressStack = UIStackView()
|
||||||
|
progressStack.axis = .vertical
|
||||||
|
progressStack.spacing = 10
|
||||||
|
progressStack.addArrangedSubview(WildReportUI.label("处理进度", font: .systemFont(ofSize: 14, weight: .bold), color: AppColor.textPrimary))
|
||||||
|
for item in clue.processingTimeline {
|
||||||
|
progressStack.addArrangedSubview(makeTimelineRow(item))
|
||||||
|
}
|
||||||
|
inner.addArrangedSubview(progressStack)
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeTimelineRow(_ item: WildReportRadarProcessingTimelineItem) -> UIView {
|
||||||
|
let row = UIStackView()
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.alignment = .top
|
||||||
|
row.spacing = 10
|
||||||
|
|
||||||
|
let dot = UIView()
|
||||||
|
dot.backgroundColor = timelineDotColor(item.status)
|
||||||
|
dot.layer.cornerRadius = 5
|
||||||
|
dot.snp.makeConstraints { make in
|
||||||
|
make.size.equalTo(10)
|
||||||
|
}
|
||||||
|
let dotContainer = UIView()
|
||||||
|
dotContainer.addSubview(dot)
|
||||||
|
dot.snp.makeConstraints { make in
|
||||||
|
make.top.equalToSuperview().offset(4)
|
||||||
|
make.centerX.equalToSuperview()
|
||||||
|
}
|
||||||
|
dotContainer.snp.makeConstraints { make in
|
||||||
|
make.width.equalTo(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
let textStack = UIStackView()
|
||||||
|
textStack.axis = .vertical
|
||||||
|
textStack.spacing = 2
|
||||||
|
textStack.addArrangedSubview(WildReportUI.label(item.title, font: .systemFont(ofSize: 13, weight: .semibold), color: AppColor.textPrimary))
|
||||||
|
textStack.addArrangedSubview(WildReportUI.label(item.time, font: .systemFont(ofSize: 12), color: AppColor.textSecondary))
|
||||||
|
|
||||||
|
row.addArrangedSubview(dotContainer)
|
||||||
|
row.addArrangedSubview(textStack)
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRadarActionButtons(for clue: WildReportRadarActiveClue) -> UIView {
|
||||||
|
let row = UIStackView()
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.spacing = 10
|
||||||
|
row.distribution = .fillEqually
|
||||||
|
|
||||||
|
let navButton = makeRadarButton(title: clue.actionText, imageName: "map.fill", color: AppColor.primary)
|
||||||
|
navButton.addTarget(self, action: #selector(navigateTapped), for: .touchUpInside)
|
||||||
|
row.addArrangedSubview(navButton)
|
||||||
|
|
||||||
|
if isReportClue(clue), !clue.completed {
|
||||||
|
let shareButton = makeRadarButton(title: "分享", imageName: "square.and.arrow.up", color: AppColor.success)
|
||||||
|
shareButton.addTarget(self, action: #selector(shareTapped), for: .touchUpInside)
|
||||||
|
row.addArrangedSubview(shareButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
[navButton].forEach { button in
|
||||||
|
button.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeRadarButton(title: String, imageName: String, color: UIColor) -> UIButton {
|
||||||
|
let button = UIButton(type: .system)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
|
||||||
|
button.setTitle(title, for: .normal)
|
||||||
|
button.setImage(UIImage(systemName: imageName), for: .normal)
|
||||||
|
button.tintColor = .white
|
||||||
|
button.setTitleColor(.white, for: .normal)
|
||||||
|
button.backgroundColor = color
|
||||||
|
button.layer.cornerRadius = 12
|
||||||
|
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: -3, bottom: 0, right: 3)
|
||||||
|
button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 12, bottom: 0, right: 12)
|
||||||
|
button.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCompareText(_ text: String) -> UIView {
|
||||||
|
let label = makeLineSpacedLabel(text, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
|
||||||
|
label.backgroundColor = UIColor(hex: 0xF3F4F6)
|
||||||
|
label.layer.cornerRadius = 10
|
||||||
|
label.clipsToBounds = true
|
||||||
|
label.textInsets = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12)
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeDetailSection(_ detail: String) -> UIView {
|
||||||
|
let row = UIStackView()
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.alignment = .top
|
||||||
|
row.spacing = 8
|
||||||
|
let icon = UIImageView(image: UIImage(systemName: "doc.text.fill"))
|
||||||
|
icon.tintColor = AppColor.primary
|
||||||
|
icon.contentMode = .scaleAspectFit
|
||||||
|
icon.snp.makeConstraints { make in
|
||||||
|
make.size.equalTo(16)
|
||||||
|
}
|
||||||
|
row.addArrangedSubview(icon)
|
||||||
|
row.addArrangedSubview(makeLineSpacedLabel(detail, font: .systemFont(ofSize: 13), color: AppColor.textPrimary))
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeInfoLabelRow(label: String, value: String, valueColor: UIColor = AppColor.textPrimary) -> UIView {
|
||||||
|
let row = UIStackView()
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.alignment = .center
|
||||||
|
row.spacing = 12
|
||||||
|
let titleLabel = WildReportUI.label(label, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
|
||||||
|
let valueLabel = WildReportUI.label(value, font: .systemFont(ofSize: 14, weight: .semibold), color: valueColor, lines: 0)
|
||||||
|
valueLabel.textAlignment = .right
|
||||||
|
row.addArrangedSubview(titleLabel)
|
||||||
|
row.addArrangedSubview(valueLabel)
|
||||||
|
titleLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
valueLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePhoneRow(label: String, phone: String?, displayPhone: String?) -> UIView {
|
||||||
|
let row = UIStackView()
|
||||||
|
row.axis = .horizontal
|
||||||
|
row.alignment = .center
|
||||||
|
row.spacing = 12
|
||||||
|
let titleLabel = WildReportUI.label(label, font: .systemFont(ofSize: 13), color: AppColor.textSecondary)
|
||||||
|
row.addArrangedSubview(titleLabel)
|
||||||
|
row.addArrangedSubview(UIView())
|
||||||
|
|
||||||
|
guard let displayPhone, !displayPhone.isEmpty else {
|
||||||
|
row.addArrangedSubview(WildReportUI.label("-", font: .systemFont(ofSize: 14, weight: .semibold), color: AppColor.textPrimary))
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
let button = UIButton(type: .system)
|
||||||
|
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||||
|
button.setTitle(displayPhone, for: .normal)
|
||||||
|
button.setImage(UIImage(systemName: "phone.fill"), for: .normal)
|
||||||
|
button.tintColor = AppColor.primary
|
||||||
|
button.setTitleColor(AppColor.primary, for: .normal)
|
||||||
|
button.semanticContentAttribute = .forceRightToLeft
|
||||||
|
button.imageEdgeInsets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: -4)
|
||||||
|
if let phone {
|
||||||
|
button.addAction(UIAction { [weak self] _ in
|
||||||
|
self?.callPhone(phone)
|
||||||
|
}, for: .touchUpInside)
|
||||||
|
}
|
||||||
|
row.addArrangedSubview(button)
|
||||||
|
button.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeLineSpacedLabel(_ text: String, font: UIFont, color: UIColor) -> WildReportRiskInsetLabel {
|
||||||
|
let label = WildReportRiskInsetLabel()
|
||||||
|
label.font = font
|
||||||
|
label.textColor = color
|
||||||
|
label.numberOfLines = 0
|
||||||
|
let paragraph = NSMutableParagraphStyle()
|
||||||
|
paragraph.lineSpacing = 4
|
||||||
|
label.attributedText = NSAttributedString(
|
||||||
|
string: text,
|
||||||
|
attributes: [.paragraphStyle: paragraph, .font: font, .foregroundColor: color]
|
||||||
|
)
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isReportClue(_ clue: WildReportRadarActiveClue) -> Bool {
|
||||||
|
clue.type == .red || clue.type == .yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
private func headerIconColor(for clue: WildReportRadarActiveClue) -> UIColor {
|
||||||
|
if isReportClue(clue), clue.processingStarted, !clue.completed {
|
||||||
|
return UIColor(hex: 0xF59E0B)
|
||||||
|
}
|
||||||
|
switch clue.type {
|
||||||
|
case .blue: return AppColor.primary
|
||||||
|
case .green: return AppColor.success
|
||||||
|
case .red: return AppColor.danger
|
||||||
|
case .yellow: return UIColor(hex: 0xF59E0B)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusColor(for clue: WildReportRadarActiveClue) -> UIColor {
|
||||||
|
if clue.completed { return AppColor.success }
|
||||||
|
if clue.processingStarted, !clue.completed { return UIColor(hex: 0xF59E0B) }
|
||||||
|
switch clue.type {
|
||||||
|
case .blue: return AppColor.primary
|
||||||
|
case .green: return AppColor.success
|
||||||
|
case .red: return AppColor.danger
|
||||||
|
case .yellow: return UIColor(hex: 0xF59E0B)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timelineDotColor(_ status: String) -> UIColor {
|
||||||
|
switch status {
|
||||||
|
case "done": return AppColor.success
|
||||||
|
case "current": return UIColor(hex: 0xF59E0B)
|
||||||
|
default: return UIColor(hex: 0xD1D5DB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func callPhone(_ phone: String) {
|
||||||
|
guard phone.contains("*") == false,
|
||||||
|
let url = URL(string: "tel://\(phone)")
|
||||||
|
else { return }
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
|
||||||
private func addInfoRows(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
|
private func addInfoRows(for clue: WildReportRadarActiveClue, to stack: UIStackView) {
|
||||||
if let name = clue.name {
|
if let name = clue.name {
|
||||||
stack.addArrangedSubview(WildReportInfoRowView(title: clue.type == .green ? "摄影师" : "点位", value: name))
|
stack.addArrangedSubview(WildReportInfoRowView(title: clue.type == .green ? "摄影师" : "点位", value: name))
|
||||||
@ -401,15 +815,6 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
return row
|
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 {
|
private func makeInlineLoading(text: String) -> UIView {
|
||||||
let row = UIStackView()
|
let row = UIStackView()
|
||||||
row.axis = .horizontal
|
row.axis = .horizontal
|
||||||
@ -464,7 +869,7 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func presentImagePreview(url: String) {
|
private func presentImagePreview(url: String) {
|
||||||
present(WildReportRiskImagePreviewController(url: url), animated: true)
|
presentImagePreview(imageURLs: [url], startIndex: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func navigateTapped() {
|
@objc private func navigateTapped() {
|
||||||
@ -493,6 +898,10 @@ final class WildReportRiskMapViewController: BaseViewController {
|
|||||||
@objc private func shareTapped() {
|
@objc private func shareTapped() {
|
||||||
viewModel.shareSelectedClue()
|
viewModel.shareSelectedClue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func locateButtonTapped() {
|
||||||
|
viewModel.focusCurrentLocation()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension WildReportRiskMapViewController: MKMapViewDelegate {
|
extension WildReportRiskMapViewController: MKMapViewDelegate {
|
||||||
@ -532,16 +941,17 @@ final class WildReportMapAnnotation: NSObject, MKAnnotation {
|
|||||||
|
|
||||||
/// 风险地图自定义脉冲圆点标记。
|
/// 风险地图自定义脉冲圆点标记。
|
||||||
private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
|
private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
|
||||||
private let pulseOuter = UIView()
|
private let titleBadge = WildReportMapMarkerTitleBadge()
|
||||||
private let pulseInner = UIView()
|
private let markerContainer = UIView()
|
||||||
|
private let pulseRings = (0..<3).map { _ in UIView() }
|
||||||
private let selectedRing = UIView()
|
private let selectedRing = UIView()
|
||||||
private let dot = UIView()
|
private let dot = UIView()
|
||||||
private let label = UILabel()
|
private var showsTitleBadge = false
|
||||||
|
|
||||||
override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
|
override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
|
||||||
super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
|
super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
|
||||||
frame = CGRect(x: 0, y: 0, width: 56, height: 56)
|
frame = CGRect(x: 0, y: 0, width: 72, height: 96)
|
||||||
centerOffset = CGPoint(x: 0, y: -12)
|
centerOffset = .zero
|
||||||
canShowCallout = false
|
canShowCallout = false
|
||||||
setupUI()
|
setupUI()
|
||||||
}
|
}
|
||||||
@ -553,41 +963,51 @@ private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
|
|||||||
|
|
||||||
func apply(marker: WildReportMapMarker, selected: Bool) {
|
func apply(marker: WildReportMapMarker, selected: Bool) {
|
||||||
let color = marker.kind.color
|
let color = marker.kind.color
|
||||||
pulseOuter.layer.borderColor = color.withAlphaComponent(0.18).cgColor
|
updateTitleBadgeVisibility(marker.kind == .selfLocation)
|
||||||
pulseInner.layer.borderColor = color.withAlphaComponent(0.28).cgColor
|
titleBadge.text = marker.title
|
||||||
selectedRing.layer.borderColor = color.cgColor
|
pulseRings.forEach { ring in
|
||||||
|
ring.layer.borderColor = color.withAlphaComponent(0.32).cgColor
|
||||||
|
}
|
||||||
|
selectedRing.layer.borderColor = color.withAlphaComponent(0.45).cgColor
|
||||||
selectedRing.isHidden = !selected
|
selectedRing.isHidden = !selected
|
||||||
dot.backgroundColor = color
|
dot.backgroundColor = color
|
||||||
label.isHidden = marker.kind != .selfLocation
|
startPulseIfNeeded()
|
||||||
label.text = "我"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
backgroundColor = .clear
|
backgroundColor = .clear
|
||||||
[pulseOuter, pulseInner, selectedRing, dot, label].forEach(addSubview)
|
titleBadge.isHidden = true
|
||||||
[pulseOuter, pulseInner, selectedRing].forEach { view in
|
addSubview(titleBadge)
|
||||||
|
addSubview(markerContainer)
|
||||||
|
pulseRings.forEach(markerContainer.addSubview)
|
||||||
|
markerContainer.addSubview(selectedRing)
|
||||||
|
markerContainer.addSubview(dot)
|
||||||
|
|
||||||
|
pulseRings.forEach { view in
|
||||||
view.backgroundColor = .clear
|
view.backgroundColor = .clear
|
||||||
view.layer.borderWidth = 2
|
view.layer.borderWidth = 2
|
||||||
|
view.layer.cornerRadius = 10
|
||||||
view.isUserInteractionEnabled = false
|
view.isUserInteractionEnabled = false
|
||||||
}
|
}
|
||||||
pulseOuter.layer.cornerRadius = 20
|
|
||||||
pulseInner.layer.cornerRadius = 14
|
|
||||||
selectedRing.layer.cornerRadius = 14
|
selectedRing.layer.cornerRadius = 14
|
||||||
|
selectedRing.layer.borderWidth = 2
|
||||||
dot.layer.cornerRadius = 7
|
dot.layer.cornerRadius = 7
|
||||||
dot.layer.borderWidth = 2
|
dot.layer.borderWidth = 2
|
||||||
dot.layer.borderColor = UIColor.white.cgColor
|
dot.layer.borderColor = UIColor.white.cgColor
|
||||||
|
|
||||||
label.font = .systemFont(ofSize: 10, weight: .bold)
|
titleBadge.snp.makeConstraints { make in
|
||||||
label.textColor = .white
|
make.top.centerX.equalToSuperview()
|
||||||
label.textAlignment = .center
|
make.height.equalTo(24)
|
||||||
|
|
||||||
pulseOuter.snp.makeConstraints { make in
|
|
||||||
make.center.equalToSuperview()
|
|
||||||
make.size.equalTo(40)
|
|
||||||
}
|
}
|
||||||
pulseInner.snp.makeConstraints { make in
|
markerContainer.snp.makeConstraints { make in
|
||||||
make.center.equalToSuperview()
|
make.center.equalToSuperview()
|
||||||
make.size.equalTo(28)
|
make.size.equalTo(56)
|
||||||
|
}
|
||||||
|
pulseRings.forEach { ring in
|
||||||
|
ring.snp.makeConstraints { make in
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
make.size.equalTo(20)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
selectedRing.snp.makeConstraints { make in
|
selectedRing.snp.makeConstraints { make in
|
||||||
make.center.equalToSuperview()
|
make.center.equalToSuperview()
|
||||||
@ -597,21 +1017,81 @@ private final class WildReportPulseMarkerAnnotationView: MKAnnotationView {
|
|||||||
make.center.equalToSuperview()
|
make.center.equalToSuperview()
|
||||||
make.size.equalTo(14)
|
make.size.equalTo(14)
|
||||||
}
|
}
|
||||||
label.snp.makeConstraints { make in
|
|
||||||
make.center.equalTo(dot)
|
|
||||||
make.size.equalTo(14)
|
|
||||||
}
|
|
||||||
startPulse()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startPulse() {
|
private func updateTitleBadgeVisibility(_ isVisible: Bool) {
|
||||||
let animation = CABasicAnimation(keyPath: "opacity")
|
guard showsTitleBadge != isVisible else { return }
|
||||||
animation.fromValue = 0.85
|
showsTitleBadge = isVisible
|
||||||
animation.toValue = 0.2
|
titleBadge.isHidden = !isVisible
|
||||||
animation.duration = 1.2
|
markerContainer.snp.remakeConstraints { make in
|
||||||
animation.autoreverses = true
|
if isVisible {
|
||||||
animation.repeatCount = .infinity
|
make.top.equalTo(titleBadge.snp.bottom).offset(4)
|
||||||
pulseOuter.layer.add(animation, forKey: "opacityPulse")
|
make.centerX.equalToSuperview()
|
||||||
|
} else {
|
||||||
|
make.center.equalToSuperview()
|
||||||
|
}
|
||||||
|
make.size.equalTo(56)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPulseIfNeeded() {
|
||||||
|
guard pulseRings.first?.layer.animation(forKey: "riskMapPulseOpacity") == nil else { return }
|
||||||
|
let endSizes: [CGFloat] = [28, 40, 52]
|
||||||
|
for (index, ring) in pulseRings.enumerated() {
|
||||||
|
ring.layer.opacity = 0.72
|
||||||
|
|
||||||
|
let scale = CABasicAnimation(keyPath: "transform.scale")
|
||||||
|
scale.fromValue = 1
|
||||||
|
scale.toValue = endSizes[index] / 20
|
||||||
|
|
||||||
|
let opacity = CABasicAnimation(keyPath: "opacity")
|
||||||
|
opacity.fromValue = 0.72
|
||||||
|
opacity.toValue = 0
|
||||||
|
|
||||||
|
let animation = CAAnimationGroup()
|
||||||
|
animation.animations = [scale, opacity]
|
||||||
|
animation.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||||
|
animation.duration = 2
|
||||||
|
animation.repeatCount = .infinity
|
||||||
|
animation.isRemovedOnCompletion = false
|
||||||
|
ring.layer.add(animation, forKey: "riskMapPulseOpacity")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 风险地图当前位置标记标题胶囊。
|
||||||
|
private final class WildReportMapMarkerTitleBadge: UIView {
|
||||||
|
var text: String? {
|
||||||
|
get { label.text }
|
||||||
|
set { label.text = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
private let label = UILabel()
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
setupUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupUI() {
|
||||||
|
backgroundColor = .white
|
||||||
|
layer.cornerRadius = 12
|
||||||
|
layer.shadowColor = UIColor.black.cgColor
|
||||||
|
layer.shadowOpacity = 0.08
|
||||||
|
layer.shadowRadius = 4
|
||||||
|
layer.shadowOffset = CGSize(width: 0, height: 2)
|
||||||
|
|
||||||
|
label.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||||
|
label.textColor = AppColor.textPrimary
|
||||||
|
addSubview(label)
|
||||||
|
label.snp.makeConstraints { make in
|
||||||
|
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -675,12 +1155,31 @@ private final class WildReportRiskStatusBadge: UILabel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 风险地图底部卡片带内边距文本。
|
||||||
|
private final class WildReportRiskInsetLabel: UILabel {
|
||||||
|
var textInsets = UIEdgeInsets.zero {
|
||||||
|
didSet { invalidateIntrinsicContentSize() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override func drawText(in rect: CGRect) {
|
||||||
|
super.drawText(in: rect.inset(by: textInsets))
|
||||||
|
}
|
||||||
|
|
||||||
|
override var intrinsicContentSize: CGSize {
|
||||||
|
let size = super.intrinsicContentSize
|
||||||
|
return CGSize(
|
||||||
|
width: size.width + textInsets.left + textInsets.right,
|
||||||
|
height: size.height + textInsets.top + textInsets.bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 风险地图举报图片缩略图。
|
/// 风险地图举报图片缩略图。
|
||||||
private final class WildReportRiskImageTile: UIView {
|
private final class WildReportRiskImageTile: UIView {
|
||||||
private let imageView = UIImageView()
|
private let imageView = UIImageView()
|
||||||
var onTap: (() -> Void)?
|
var onTap: (() -> Void)?
|
||||||
|
|
||||||
init(url: String) {
|
init(url: String, size: CGFloat = 72) {
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
backgroundColor = AppColor.pageBackgroundSoft
|
backgroundColor = AppColor.pageBackgroundSoft
|
||||||
layer.cornerRadius = 10
|
layer.cornerRadius = 10
|
||||||
@ -698,7 +1197,7 @@ private final class WildReportRiskImageTile: UIView {
|
|||||||
}
|
}
|
||||||
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped)))
|
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapped)))
|
||||||
snp.makeConstraints { make in
|
snp.makeConstraints { make in
|
||||||
make.size.equalTo(72)
|
make.size.equalTo(size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -711,46 +1210,3 @@ private final class WildReportRiskImageTile: UIView {
|
|||||||
onTap?()
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import PhotosUI
|
import PhotosUI
|
||||||
import QuickLook
|
|
||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@ -23,7 +22,6 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
|
|||||||
private let submitButton = UIButton(type: .system)
|
private let submitButton = UIButton(type: .system)
|
||||||
|
|
||||||
private var pickerTarget: WildReportPickerTarget = .image
|
private var pickerTarget: WildReportPickerTarget = .image
|
||||||
private var previewURL: URL?
|
|
||||||
private weak var activeInputView: UIView?
|
private weak var activeInputView: UIView?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@ -322,14 +320,11 @@ final class WildReportSupplementEvidenceViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func previewAttachment(_ attachment: WildReportAttachment) {
|
private func previewAttachment(_ attachment: WildReportAttachment) {
|
||||||
guard let url = attachment.localFileURL else {
|
guard let item = MediaPreviewItem(wildReportAttachment: attachment) else {
|
||||||
showToast("当前附件暂无本地预览")
|
showToast("当前附件暂无本地预览")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
previewURL = url
|
presentMediaPreview(items: [item], startIndex: 0)
|
||||||
let preview = QLPreviewController()
|
|
||||||
preview.dataSource = self
|
|
||||||
present(preview, animated: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func submitTapped() {
|
@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 {
|
extension WildReportSupplementEvidenceViewController: UITextViewDelegate {
|
||||||
func textViewDidBeginEditing(_ textView: UITextView) {
|
func textViewDidBeginEditing(_ textView: UITextView) {
|
||||||
activeInputView = textView
|
activeInputView = textView
|
||||||
|
|||||||
126
suixinkanTests/MaterialManagementAPITests.swift
Normal file
126
suixinkanTests/MaterialManagementAPITests.swift
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
//
|
||||||
|
// MaterialManagementAPITests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 素材管理 API 路径、参数与请求体测试。
|
||||||
|
@MainActor
|
||||||
|
final class MaterialManagementAPITests: XCTestCase {
|
||||||
|
func testListBuildsAndroidQueryWithMaterialType() async throws {
|
||||||
|
let session = MockURLSession(responses: [envelopeJSON(#"{"total":0,"list":[],"order":null}"#)])
|
||||||
|
let api = MaterialManagementAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
_ = try await api.list(status: 4, keyword: "雪山", page: 2, pageSize: 10)
|
||||||
|
|
||||||
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
|
XCTAssertEqual(request.httpMethod, "GET")
|
||||||
|
XCTAssertEqual(request.url?.path, "/api/app/media-album/list")
|
||||||
|
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
|
||||||
|
XCTAssertEqual(query?.first { $0.name == "status" }?.value, "4")
|
||||||
|
XCTAssertEqual(query?.first { $0.name == "keyword" }?.value, "雪山")
|
||||||
|
XCTAssertEqual(query?.first { $0.name == "type" }?.value, "1")
|
||||||
|
XCTAssertEqual(query?.first { $0.name == "page" }?.value, "2")
|
||||||
|
XCTAssertEqual(query?.first { $0.name == "page_size" }?.value, "10")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListDecodesNullableOrderAmounts() async throws {
|
||||||
|
let session = MockURLSession(responses: [envelopeJSON(#"{"total":0,"list":[],"order":{"avg_change":-1025,"avg_order_amount":null,"refund_total":null,"total_num":0}}"#)])
|
||||||
|
let api = MaterialManagementAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
let response = try await api.list(status: 4, keyword: nil, page: 1, pageSize: 10)
|
||||||
|
|
||||||
|
XCTAssertEqual(response.order?.avgOrderAmount, "0")
|
||||||
|
XCTAssertEqual(response.order?.refundTotal, "0")
|
||||||
|
XCTAssertEqual(response.order?.avgChange, -1025)
|
||||||
|
XCTAssertEqual(response.order?.totalNum, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDetailOperationDeleteAndTagUseAndroidPaths() async throws {
|
||||||
|
let detail = envelopeJSON(#"{"id":8,"status":1,"listing_status":1}"#)
|
||||||
|
let empty = envelopeJSON("{}")
|
||||||
|
let tag = envelopeJSON("23")
|
||||||
|
let session = MockURLSession(responses: [detail, empty, empty, tag])
|
||||||
|
let api = MaterialManagementAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
_ = try await api.detail(id: 8)
|
||||||
|
try await api.setListingStatus(id: 8, listingStatus: 0)
|
||||||
|
try await api.delete(id: 8)
|
||||||
|
let tagId = try await api.addTag(name: "snow-tag")
|
||||||
|
|
||||||
|
XCTAssertEqual(tagId, 23)
|
||||||
|
XCTAssertEqual(session.requests[0].url?.path, "/api/app/media-album/detail")
|
||||||
|
XCTAssertEqual(session.requests[1].url?.path, "/api/app/media-album/operation")
|
||||||
|
XCTAssertEqual(session.requests[2].url?.path, "/api/app/media-album/delete")
|
||||||
|
XCTAssertEqual(session.requests[3].url?.path, "/api/app/media-album/add-tag")
|
||||||
|
XCTAssertTrue(session.requests.prefix(3).allSatisfy { request in
|
||||||
|
URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
|
||||||
|
.queryItems?
|
||||||
|
.first { $0.name == "type" }?
|
||||||
|
.value == "1"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUploadAndEditEncodeAndroidBodyKeys() async throws {
|
||||||
|
let session = MockURLSession(responses: [envelopeJSON("{}"), envelopeJSON("{}")])
|
||||||
|
let api = MaterialManagementAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
let request = MaterialUploadRequest(
|
||||||
|
name: "素材A",
|
||||||
|
mediaType: .image,
|
||||||
|
mediaList: [
|
||||||
|
MaterialUploadMediaItem(
|
||||||
|
originalName: "a.jpg",
|
||||||
|
ossUrl: "https://cdn/a.jpg",
|
||||||
|
size: 1024,
|
||||||
|
fileWidthSize: MaterialUploadFileSize(width: 1920, height: 1080)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
coverUrl: "https://cdn/c.jpg",
|
||||||
|
coverSize: "512",
|
||||||
|
scenicSpotId: 9,
|
||||||
|
materialTag: "1,2"
|
||||||
|
)
|
||||||
|
|
||||||
|
try await api.upload(request)
|
||||||
|
try await api.edit(MaterialUploadRequest(
|
||||||
|
id: 7,
|
||||||
|
name: "素材B",
|
||||||
|
mediaType: .video,
|
||||||
|
mediaList: request.mediaList,
|
||||||
|
coverUrl: request.coverUrl,
|
||||||
|
coverSize: request.coverSize,
|
||||||
|
scenicSpotId: request.scenicSpotId,
|
||||||
|
materialTag: "3"
|
||||||
|
))
|
||||||
|
|
||||||
|
let uploadBody = try bodyJSON(session.requests[0])
|
||||||
|
XCTAssertEqual(session.requests[0].url?.path, "/api/app/media-album/upload")
|
||||||
|
XCTAssertEqual(uploadBody["name"] as? String, "素材A")
|
||||||
|
XCTAssertEqual(uploadBody["type"] as? String, "1")
|
||||||
|
XCTAssertEqual(uploadBody["media_type"] as? String, "1")
|
||||||
|
XCTAssertNil(uploadBody["project_id"])
|
||||||
|
XCTAssertEqual(uploadBody["material_tag"] as? String, "1,2")
|
||||||
|
let media = (uploadBody["media_list"] as? [[String: Any]])?.first
|
||||||
|
XCTAssertEqual(media?["original_name"] as? String, "a.jpg")
|
||||||
|
XCTAssertEqual(media?["oss_url"] as? String, "https://cdn/a.jpg")
|
||||||
|
|
||||||
|
let editBody = try bodyJSON(session.requests[1])
|
||||||
|
XCTAssertEqual(session.requests[1].url?.path, "/api/app/media-album/edit")
|
||||||
|
XCTAssertEqual(editBody["id"] as? Int, 7)
|
||||||
|
XCTAssertEqual(editBody["type"] as? String, "1")
|
||||||
|
XCTAssertEqual(editBody["media_type"] as? String, "2")
|
||||||
|
XCTAssertNil(editBody["project_id"])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func envelopeJSON(_ dataJSON: String) -> Data {
|
||||||
|
"""
|
||||||
|
{"code":100000,"msg":"success","data":\(dataJSON)}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bodyJSON(_ request: URLRequest) throws -> [String: Any] {
|
||||||
|
try XCTUnwrap(JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any])
|
||||||
|
}
|
||||||
|
}
|
||||||
258
suixinkanTests/MaterialManagementViewModelTests.swift
Normal file
258
suixinkanTests/MaterialManagementViewModelTests.swift
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
//
|
||||||
|
// MaterialManagementViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 素材管理 ViewModel 测试。
|
||||||
|
@MainActor
|
||||||
|
final class MaterialManagementViewModelTests: XCTestCase {
|
||||||
|
func testListLoadsMoreSearchesAndMapsFilterStatus() async {
|
||||||
|
let api = MockMaterialAPI()
|
||||||
|
api.listResponses = [
|
||||||
|
MaterialListResponse(total: 12, list: (0 ..< 10).map { makeItem(id: $0) }, order: MaterialOrderInfo(totalNum: 1386, avgOrderAmount: "12", refundTotal: "3")),
|
||||||
|
MaterialListResponse(total: 12, list: [makeItem(id: 10), makeItem(id: 11)]),
|
||||||
|
MaterialListResponse(total: 1, list: [makeItem(id: 99)]),
|
||||||
|
]
|
||||||
|
let viewModel = MaterialListViewModel()
|
||||||
|
|
||||||
|
await viewModel.loadInitial(api: api)
|
||||||
|
await viewModel.loadMoreIfNeeded(lastVisibleIndex: 8, api: api)
|
||||||
|
await viewModel.selectFilter(.pending, api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.listCalls.map(\.status), [4, 4, 0])
|
||||||
|
XCTAssertEqual(api.listCalls.map(\.page), [1, 2, 1])
|
||||||
|
XCTAssertEqual(viewModel.items.map(\.id), [99])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRejectedMaterialCannotToggleListing() async {
|
||||||
|
let api = MockMaterialAPI()
|
||||||
|
api.listResponses = [MaterialListResponse(total: 1, list: [makeItem(id: 1, status: 0, listingStatus: 0)])]
|
||||||
|
let viewModel = MaterialListViewModel()
|
||||||
|
var messages: [String] = []
|
||||||
|
viewModel.onShowMessage = { messages.append($0) }
|
||||||
|
|
||||||
|
await viewModel.loadInitial(api: api)
|
||||||
|
await viewModel.toggleListing(materialId: 1, checked: true, api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(messages.last, "审核通过才可以操作上下架")
|
||||||
|
XCTAssertTrue(api.operationCalls.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testApprovedMaterialToggleUpdatesLocalItem() async {
|
||||||
|
let api = MockMaterialAPI()
|
||||||
|
api.listResponses = [MaterialListResponse(total: 1, list: [makeItem(id: 2, status: 1, listingStatus: 0)])]
|
||||||
|
let viewModel = MaterialListViewModel()
|
||||||
|
|
||||||
|
await viewModel.loadInitial(api: api)
|
||||||
|
await viewModel.toggleListing(materialId: 2, checked: true, api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.operationCalls.first?.id, 2)
|
||||||
|
XCTAssertEqual(api.operationCalls.first?.listingStatus, 1)
|
||||||
|
XCTAssertEqual(viewModel.items.first?.listingStatus, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDetailDeleteCallsCallback() async {
|
||||||
|
let api = MockMaterialAPI()
|
||||||
|
let viewModel = MaterialDetailViewModel(materialId: 7)
|
||||||
|
var deletedId: Int?
|
||||||
|
viewModel.onDeleted = { deletedId = $0 }
|
||||||
|
|
||||||
|
await viewModel.delete(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.deletedIds, [7])
|
||||||
|
XCTAssertEqual(deletedId, 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUploadValidationMessages() async {
|
||||||
|
let api = MockMaterialAPI()
|
||||||
|
let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 })
|
||||||
|
var messages: [String] = []
|
||||||
|
viewModel.onShowMessage = { messages.append($0) }
|
||||||
|
|
||||||
|
await viewModel.submit(api: api)
|
||||||
|
viewModel.updateMaterialName("素材")
|
||||||
|
await viewModel.submit(api: api)
|
||||||
|
await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: MockMaterialUploader(urls: ["https://cdn/a.jpg"]))
|
||||||
|
await viewModel.submit(api: api)
|
||||||
|
await viewModel.setCoverImage(makeUploadItem(fileName: "c.jpg"), uploader: MockMaterialUploader(urls: ["https://cdn/c.jpg"]))
|
||||||
|
await viewModel.submit(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(messages.prefix(4), [
|
||||||
|
"请输入素材名称",
|
||||||
|
"请至少上传一个素材",
|
||||||
|
"请上传封面图片",
|
||||||
|
"请选择关联打卡点",
|
||||||
|
])
|
||||||
|
XCTAssertNil(api.lastUploadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUploadSuccessBuildsMaterialRequestWithTags() async {
|
||||||
|
let api = MockMaterialAPI()
|
||||||
|
api.spotResponse = ScenicSpotListResponse(list: [ScenicSpotItem(id: 9, name: "打卡点")], total: 1)
|
||||||
|
let viewModel = MaterialFormViewModel(mode: .create, currentScenicIdProvider: { 3 })
|
||||||
|
let uploader = MockMaterialUploader(urls: ["https://cdn/a.jpg", "https://cdn/c.jpg"])
|
||||||
|
var didSucceed = false
|
||||||
|
viewModel.onSubmitSuccess = { didSucceed = true }
|
||||||
|
|
||||||
|
await viewModel.loadInitial(api: api)
|
||||||
|
viewModel.updateMaterialName("素材")
|
||||||
|
viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点"))
|
||||||
|
viewModel.updateTagInput("snow-tag")
|
||||||
|
await viewModel.addTag(api: api)
|
||||||
|
await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader)
|
||||||
|
await viewModel.setCoverImage(makeUploadItem(fileName: "c.jpg"), uploader: uploader)
|
||||||
|
await viewModel.submit(api: api)
|
||||||
|
|
||||||
|
XCTAssertTrue(didSucceed)
|
||||||
|
XCTAssertEqual(api.lastUploadRequest?.name, "素材")
|
||||||
|
XCTAssertEqual(api.lastUploadRequest?.type, "1")
|
||||||
|
XCTAssertEqual(api.lastUploadRequest?.mediaType, "1")
|
||||||
|
XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.ossUrl, "https://cdn/a.jpg")
|
||||||
|
XCTAssertEqual(api.lastUploadRequest?.coverUrl, "https://cdn/c.jpg")
|
||||||
|
XCTAssertEqual(api.lastUploadRequest?.scenicSpotId, 9)
|
||||||
|
XCTAssertEqual(api.lastUploadRequest?.materialTag, "42")
|
||||||
|
XCTAssertNil(api.lastUploadRequest?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testEditLoadsDetailAndBuildsEditRequest() async {
|
||||||
|
let api = MockMaterialAPI()
|
||||||
|
api.spotResponse = ScenicSpotListResponse(list: [ScenicSpotItem(id: 9, name: "打卡点")], total: 1)
|
||||||
|
api.detailResponse = try! decodeDetail("""
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"name": "旧素材",
|
||||||
|
"status": 1,
|
||||||
|
"listing_status": 1,
|
||||||
|
"cover_url": "https://cdn/cover.jpg",
|
||||||
|
"cover_size": 22,
|
||||||
|
"scenic_spot_id": 9,
|
||||||
|
"material_tag": [{"id": 3, "name": "cloudy"}],
|
||||||
|
"media_list": [{
|
||||||
|
"id": 5,
|
||||||
|
"original_name": "old.jpg",
|
||||||
|
"oss_url": "https://cdn/old.jpg",
|
||||||
|
"type": 1,
|
||||||
|
"size": 10,
|
||||||
|
"width": 100,
|
||||||
|
"height": 80
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
let viewModel = MaterialFormViewModel(mode: .edit(id: 11), currentScenicIdProvider: { 3 })
|
||||||
|
|
||||||
|
await viewModel.loadInitial(api: api)
|
||||||
|
await viewModel.submit(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.materialName, "旧素材")
|
||||||
|
XCTAssertEqual(viewModel.selectedScenicSpot?.id, 9)
|
||||||
|
XCTAssertEqual(viewModel.uploadedMediaList.first?.ossUrl, "https://cdn/old.jpg")
|
||||||
|
XCTAssertEqual(api.lastEditRequest?.id, 11)
|
||||||
|
XCTAssertEqual(api.lastEditRequest?.type, "1")
|
||||||
|
XCTAssertEqual(api.lastEditRequest?.materialTag, "3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材管理 API mock。
|
||||||
|
/// 素材管理 API mock。
|
||||||
|
@MainActor
|
||||||
|
private final class MockMaterialAPI: MaterialManagementServing {
|
||||||
|
var listResponses: [MaterialListResponse] = []
|
||||||
|
var listCalls: [(status: Int, keyword: String?, page: Int, pageSize: Int)] = []
|
||||||
|
var operationCalls: [(id: Int, listingStatus: Int)] = []
|
||||||
|
var deletedIds: [Int] = []
|
||||||
|
var tagId = 42
|
||||||
|
var lastUploadRequest: MaterialUploadRequest?
|
||||||
|
var lastEditRequest: MaterialUploadRequest?
|
||||||
|
var spotResponse = ScenicSpotListResponse(list: [], total: 0)
|
||||||
|
var detailResponse = try! decodeDetail(#"{"id":1,"status":1,"listing_status":1}"#)
|
||||||
|
|
||||||
|
func list(status: Int, keyword: String?, page: Int, pageSize: Int) async throws -> MaterialListResponse {
|
||||||
|
listCalls.append((status, keyword, page, pageSize))
|
||||||
|
guard !listResponses.isEmpty else { return MaterialListResponse() }
|
||||||
|
return listResponses.removeFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
func detail(id: Int) async throws -> MaterialDetail {
|
||||||
|
detailResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func setListingStatus(id: Int, listingStatus: Int) async throws {
|
||||||
|
operationCalls.append((id, listingStatus))
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(id: Int) async throws {
|
||||||
|
deletedIds.append(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addTag(name: String) async throws -> Int {
|
||||||
|
tagId
|
||||||
|
}
|
||||||
|
|
||||||
|
func upload(_ request: MaterialUploadRequest) async throws {
|
||||||
|
lastUploadRequest = request
|
||||||
|
}
|
||||||
|
|
||||||
|
func edit(_ request: MaterialUploadRequest) async throws {
|
||||||
|
lastEditRequest = request
|
||||||
|
}
|
||||||
|
|
||||||
|
func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse {
|
||||||
|
spotResponse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 素材管理 OSS 上传 mock。
|
||||||
|
/// 素材管理 OSS 上传 mock。
|
||||||
|
@MainActor
|
||||||
|
private final class MockMaterialUploader: MaterialOSSUploading {
|
||||||
|
private var urls: [String]
|
||||||
|
|
||||||
|
init(urls: [String]) {
|
||||||
|
self.urls = urls
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadMaterialFile(
|
||||||
|
data: Data,
|
||||||
|
fileName: String,
|
||||||
|
fileType: Int,
|
||||||
|
scenicId: Int,
|
||||||
|
onProgress: @escaping (Int) -> Void
|
||||||
|
) async throws -> String {
|
||||||
|
onProgress(100)
|
||||||
|
return urls.isEmpty ? "https://cdn/default.jpg" : urls.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeItem(id: Int, status: Int = 1, listingStatus: Int = 0) -> MaterialItem {
|
||||||
|
MaterialItem(
|
||||||
|
id: id,
|
||||||
|
name: "素材\(id)",
|
||||||
|
coverUrl: "",
|
||||||
|
createdAt: "",
|
||||||
|
status: status,
|
||||||
|
downloadCount: 0,
|
||||||
|
likesCount: 0,
|
||||||
|
collectCount: 0,
|
||||||
|
shareCount: 0,
|
||||||
|
listingStatus: listingStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeUploadItem(fileName: String, ossUrl: String = "") -> MaterialUploadedMediaItem {
|
||||||
|
MaterialUploadedMediaItem(
|
||||||
|
data: Data(repeating: 1, count: 10),
|
||||||
|
fileName: fileName,
|
||||||
|
size: 10,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
ossUrl: ossUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeDetail(_ json: String) throws -> MaterialDetail {
|
||||||
|
try JSONDecoder().decode(MaterialDetail.self, from: Data(json.utf8))
|
||||||
|
}
|
||||||
55
suixinkanTests/MediaPreviewModelsTests.swift
Normal file
55
suixinkanTests/MediaPreviewModelsTests.swift
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
//
|
||||||
|
// MediaPreviewModelsTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 通用媒体预览模型测试。
|
||||||
|
final class MediaPreviewModelsTests: XCTestCase {
|
||||||
|
|
||||||
|
func testClampedStartIndexKeepsIndexInBounds() {
|
||||||
|
XCTAssertEqual(MediaPreviewItem.clampedStartIndex(-3, itemCount: 4), 0)
|
||||||
|
XCTAssertEqual(MediaPreviewItem.clampedStartIndex(2, itemCount: 4), 2)
|
||||||
|
XCTAssertEqual(MediaPreviewItem.clampedStartIndex(8, itemCount: 4), 3)
|
||||||
|
XCTAssertEqual(MediaPreviewItem.clampedStartIndex(8, itemCount: 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRemoteImageAndVideoURLConstructionFiltersInvalidText() {
|
||||||
|
let image = MediaPreviewItem.remoteImage(" https://cdn.example.com/a.jpg ")
|
||||||
|
let video = MediaPreviewItem.remoteVideo("https://cdn.example.com/a.mp4")
|
||||||
|
|
||||||
|
XCTAssertEqual(image?.mediaType, .image)
|
||||||
|
XCTAssertEqual(video?.mediaType, .video)
|
||||||
|
XCTAssertNil(MediaPreviewItem.remoteImage(""))
|
||||||
|
XCTAssertNil(MediaPreviewItem.remoteImage(" "))
|
||||||
|
XCTAssertNil(MediaPreviewItem.remoteVideo("file:///tmp/a.mp4"))
|
||||||
|
XCTAssertNil(MediaPreviewItem.remoteVideo("not a url"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLocalImageAndVideoTypeRecognition() {
|
||||||
|
let localImage = MediaPreviewItem.localImage(url: URL(fileURLWithPath: "/tmp/a.jpg"))
|
||||||
|
let memoryImage = MediaPreviewItem.image(UIImage())
|
||||||
|
let localVideo = MediaPreviewItem.localVideo(url: URL(fileURLWithPath: "/tmp/a.mp4"))
|
||||||
|
|
||||||
|
XCTAssertTrue(localImage.isImage)
|
||||||
|
XCTAssertTrue(memoryImage.isImage)
|
||||||
|
XCTAssertTrue(localVideo.isVideo)
|
||||||
|
XCTAssertEqual(localVideo.mediaType, .video)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCurrentItemReadsClampedItemFromMixedMedia() {
|
||||||
|
let items = [
|
||||||
|
MediaPreviewItem.localImage(url: URL(fileURLWithPath: "/tmp/a.jpg")),
|
||||||
|
MediaPreviewItem.localVideo(url: URL(fileURLWithPath: "/tmp/a.mp4")),
|
||||||
|
MediaPreviewItem.remoteImage("https://cdn.example.com/b.jpg")!,
|
||||||
|
]
|
||||||
|
|
||||||
|
XCTAssertEqual(MediaPreviewItem.currentItem(in: items, startIndex: -1)?.mediaType, .image)
|
||||||
|
XCTAssertEqual(MediaPreviewItem.currentItem(in: items, startIndex: 1)?.mediaType, .video)
|
||||||
|
XCTAssertEqual(MediaPreviewItem.currentItem(in: items, startIndex: 99)?.mediaType, .image)
|
||||||
|
XCTAssertNil(MediaPreviewItem.currentItem(in: [], startIndex: 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,13 +13,15 @@ final class MockLocationProvider: LocationProviding, @unchecked Sendable {
|
|||||||
var reverseGeocodeResult = "北京市东城区"
|
var reverseGeocodeResult = "北京市东城区"
|
||||||
var requestSnapshotError: Error?
|
var requestSnapshotError: Error?
|
||||||
var requestCoordinateError: Error?
|
var requestCoordinateError: Error?
|
||||||
|
var requestedAccuracies: [CLLocationAccuracy] = []
|
||||||
|
|
||||||
func requestSnapshot() async throws -> HomeLocationSnapshot {
|
func requestSnapshot() async throws -> HomeLocationSnapshot {
|
||||||
if let requestSnapshotError { throw requestSnapshotError }
|
if let requestSnapshotError { throw requestSnapshotError }
|
||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
|
||||||
|
requestedAccuracies.append(desiredAccuracy)
|
||||||
if let requestCoordinateError { throw requestCoordinateError }
|
if let requestCoordinateError { throw requestCoordinateError }
|
||||||
return coordinate
|
return coordinate
|
||||||
}
|
}
|
||||||
|
|||||||
@ -259,7 +259,7 @@ final class ProfileSpaceSettingsViewModelTests: XCTestCase {
|
|||||||
XCTAssertTrue(navigationController.topViewController is ProfileSpaceSettingsViewController)
|
XCTAssertTrue(navigationController.topViewController is ProfileSpaceSettingsViewController)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSystemSettingsRouteStillSelectsProfileTab() {
|
func testSystemSettingsRoutePushesSettingPage() {
|
||||||
AppStore.shared.currentScenicId = 11
|
AppStore.shared.currentScenicId = 11
|
||||||
let tabBarController = MainTabBarController()
|
let tabBarController = MainTabBarController()
|
||||||
tabBarController.loadViewIfNeeded()
|
tabBarController.loadViewIfNeeded()
|
||||||
@ -273,7 +273,8 @@ final class ProfileSpaceSettingsViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
HomeRouteHandler.open(uri: "system_settings", from: root, storeItem: nil)
|
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 {
|
private func makeViewModel() -> ProfileSpaceSettingsViewModel {
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
@testable import suixinkan
|
@testable import suixinkan
|
||||||
|
|
||||||
@MainActor
|
|
||||||
/// 样片管理 API 测试。
|
/// 样片管理 API 测试。
|
||||||
|
@MainActor
|
||||||
final class SampleManagementAPITests: XCTestCase {
|
final class SampleManagementAPITests: XCTestCase {
|
||||||
func testListBuildsAndroidQuery() async throws {
|
func testListBuildsAndroidQuery() async throws {
|
||||||
let session = MockURLSession(responses: [envelopeJSON(#"{"total":0,"list":[],"order":null}"#)])
|
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")
|
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 {
|
func testDetailAndOperationUseSampleType() async throws {
|
||||||
let detail = envelopeJSON(#"{"id":8,"status":1,"listing_status":1}"#)
|
let detail = envelopeJSON(#"{"id":8,"status":1,"listing_status":1}"#)
|
||||||
let empty = envelopeJSON("{}")
|
let empty = envelopeJSON("{}")
|
||||||
|
|||||||
91
suixinkanTests/SettingViewModelTests.swift
Normal file
91
suixinkanTests/SettingViewModelTests.swift
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
//
|
||||||
|
// SettingViewModelTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// 设置中心 ViewModel 测试。
|
||||||
|
final class SettingViewModelTests: XCTestCase {
|
||||||
|
func testAppVersionUsesClientInfoFormatting() {
|
||||||
|
let viewModel = SettingViewModel(
|
||||||
|
infoDictionary: [
|
||||||
|
"CFBundleShortVersionString": "1.2",
|
||||||
|
"CFBundleVersion": "9",
|
||||||
|
],
|
||||||
|
environment: .testing
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.appVersion, "1.2.9")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAppDownloadURLUsesEnvironmentBaseURL() {
|
||||||
|
let viewModel = SettingViewModel(infoDictionary: [:], environment: .testing)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.appDownloadURL.absoluteString, "https://api-test.zhifly.cn/h5/app/download")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVersionCodeComparison() {
|
||||||
|
XCTAssertTrue(SettingViewModel.isNewerVersionCode(11, than: 10))
|
||||||
|
XCTAssertFalse(SettingViewModel.isNewerVersionCode(10, than: 10))
|
||||||
|
XCTAssertFalse(SettingViewModel.isNewerVersionCode(9, than: 10))
|
||||||
|
XCTAssertFalse(SettingViewModel.isNewerVersionCode(11, than: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAgreementDestinationsMapToAndroidPaths() {
|
||||||
|
let viewModel = SettingViewModel(infoDictionary: [:], environment: .testing)
|
||||||
|
|
||||||
|
let about = viewModel.agreementDestination(for: .aboutUs)
|
||||||
|
let userAgreement = viewModel.agreementDestination(for: .userAgreement)
|
||||||
|
let privacy = viewModel.agreementDestination(for: .privacyPolicy)
|
||||||
|
|
||||||
|
XCTAssertEqual(about.title, "关于我们")
|
||||||
|
XCTAssertEqual(about.url.absoluteString, "https://api-test.zhifly.cn/h5/app/about-us")
|
||||||
|
XCTAssertEqual(userAgreement.title, "用户协议")
|
||||||
|
XCTAssertEqual(userAgreement.url.absoluteString, "https://api-test.zhifly.cn/h5/app/user-agreement")
|
||||||
|
XCTAssertEqual(privacy.title, "隐私政策")
|
||||||
|
XCTAssertEqual(privacy.url.absoluteString, "https://api-test.zhifly.cn/h5/app/privacy-policy")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testCheckLatestVersionMarksNewVersionWhenVersionCodeIsHigher() async throws {
|
||||||
|
let json = """
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"type": 1,
|
||||||
|
"version": "1.0.1",
|
||||||
|
"operator": "admin",
|
||||||
|
"description": "update",
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2026-07-09 00:00:00",
|
||||||
|
"updated_at": "2026-07-09 00:00:00",
|
||||||
|
"deleted_at": null,
|
||||||
|
"version_code": 8,
|
||||||
|
"apk_url": "https://example.com/app.apk",
|
||||||
|
"is_force_update": 0,
|
||||||
|
"approve_notes": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""".data(using: .utf8)!
|
||||||
|
let session = MockURLSession(responses: [json])
|
||||||
|
let client = APIClient(environment: .testing, session: session)
|
||||||
|
let api = SettingAPI(client: client)
|
||||||
|
let viewModel = SettingViewModel(
|
||||||
|
infoDictionary: [
|
||||||
|
"CFBundleShortVersionString": "1.0.0",
|
||||||
|
"CFBundleVersion": "7",
|
||||||
|
],
|
||||||
|
environment: .testing
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.checkLatestVersion(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.latestVersion?.versionCode, 8)
|
||||||
|
XCTAssertTrue(viewModel.hasNewVersion)
|
||||||
|
XCTAssertEqual(session.requests.first?.url?.path, "/api/app/latest-version")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,40 +4,13 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
import MapKit
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import suixinkan
|
@testable import suixinkan
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
/// 举报摄影师模块测试,覆盖 Mock 数据、接口类型、提交校验、列表筛选和补充证据。
|
/// 举报摄影师模块测试,覆盖接口类型、提交校验、列表筛选和补充证据。
|
||||||
final class WildPhotographerReportTests: XCTestCase {
|
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 {
|
func testReportTypesAPILoadsRemoteOptions() async throws {
|
||||||
let json = Data("""
|
let json = Data("""
|
||||||
{
|
{
|
||||||
@ -68,6 +41,75 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testReportCopyAPILoadsNoticeAndRuleGroups() async throws {
|
||||||
|
let json = Data("""
|
||||||
|
{
|
||||||
|
"code": 100000,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"notice": ["需实名登录", "请尽量上传清晰证据"],
|
||||||
|
"rule_groups": [
|
||||||
|
{
|
||||||
|
"title": "可举报行为",
|
||||||
|
"items": ["疑似打野摄影师主动揽客"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"time": "2026-07-09 10:15:17"
|
||||||
|
}
|
||||||
|
""".utf8)
|
||||||
|
let session = MockURLSession(responses: [json])
|
||||||
|
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
|
||||||
|
|
||||||
|
let response = try await api.reportCopy()
|
||||||
|
|
||||||
|
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/copy")
|
||||||
|
XCTAssertEqual(response.notice, ["需实名登录", "请尽量上传清晰证据"])
|
||||||
|
XCTAssertEqual(response.ruleGroups, [
|
||||||
|
WildReportRuleGroup(title: "可举报行为", items: ["疑似打野摄影师主动揽客"])
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHomeViewModelLoadsReportCopyAndDisplayRules() async {
|
||||||
|
let viewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
api.reportCopyResponse = WildReportCopyResponse(
|
||||||
|
notice: ["需实名登录", "请尽量上传清晰证据"],
|
||||||
|
ruleGroups: [WildReportRuleGroup(title: "材料要求", items: ["尽量提供现场图片"])]
|
||||||
|
)
|
||||||
|
|
||||||
|
await viewModel.loadReportCopy(api: api)
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.noticeItems, ["需实名登录", "请尽量上传清晰证据"])
|
||||||
|
XCTAssertEqual(viewModel.ruleGroups, [WildReportRuleGroup(title: "材料要求", items: ["尽量提供现场图片"])])
|
||||||
|
XCTAssertTrue(viewModel.shouldShowNoticeModule)
|
||||||
|
XCTAssertTrue(viewModel.shouldShowRuleButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHomeViewModelHidesNoticeModuleWhenCopyIsEmpty() async {
|
||||||
|
let viewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
api.reportCopyResponse = WildReportCopyResponse(notice: [" "], ruleGroups: [WildReportRuleGroup(title: " ", items: [])])
|
||||||
|
|
||||||
|
await viewModel.loadReportCopy(api: api)
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.noticeItems.isEmpty)
|
||||||
|
XCTAssertTrue(viewModel.ruleGroups.isEmpty)
|
||||||
|
XCTAssertFalse(viewModel.shouldShowNoticeModule)
|
||||||
|
XCTAssertFalse(viewModel.shouldShowRuleButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHomeViewModelShowsNoticeWithoutRuleButtonWhenRulesAreEmpty() async {
|
||||||
|
let viewModel = WildPhotographerReportHomeViewModel()
|
||||||
|
let api = MockWildPhotographerReportAPI()
|
||||||
|
api.reportCopyResponse = WildReportCopyResponse(notice: ["需实名登录"], ruleGroups: [])
|
||||||
|
|
||||||
|
await viewModel.loadReportCopy(api: api)
|
||||||
|
|
||||||
|
XCTAssertTrue(viewModel.shouldShowNoticeModule)
|
||||||
|
XCTAssertFalse(viewModel.shouldShowRuleButton)
|
||||||
|
}
|
||||||
|
|
||||||
func testReportListAPILoadsRemoteReports() async throws {
|
func testReportListAPILoadsRemoteReports() async throws {
|
||||||
let json = Data("""
|
let json = Data("""
|
||||||
{
|
{
|
||||||
@ -239,7 +281,7 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertEqual(evidences.first?["file_name"] as? String, "supplement.jpg")
|
XCTAssertEqual(evidences.first?["file_name"] as? String, "supplement.jpg")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRiskMapAPISendsRequestBodyAndDecodesMarkers() async throws {
|
func testRiskMapAPISendsGETQueryAndDecodesMarkers() async throws {
|
||||||
let json = Data("""
|
let json = Data("""
|
||||||
{
|
{
|
||||||
"code": 100000,
|
"code": 100000,
|
||||||
@ -291,20 +333,20 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
))
|
))
|
||||||
|
|
||||||
let request = try XCTUnwrap(session.requests.first)
|
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")
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/risk-map")
|
||||||
let bodyData = try XCTUnwrap(request.httpBody)
|
XCTAssertNil(request.httpBody)
|
||||||
let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
|
let queryItems = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems ?? []
|
||||||
XCTAssertEqual(body["scenic_id"] as? Int, 128)
|
XCTAssertEqual(queryItems.first(where: { $0.name == "scenic_id" })?.value, "128")
|
||||||
XCTAssertEqual(body["latitude"] as? Double, 43.3779)
|
XCTAssertEqual(queryItems.first(where: { $0.name == "latitude" })?.value, "43.3779")
|
||||||
XCTAssertEqual(body["longitude"] as? Double, 84.0217)
|
XCTAssertEqual(queryItems.first(where: { $0.name == "longitude" })?.value, "84.0217")
|
||||||
XCTAssertEqual(response.scenicName, "伊犁那拉提景区-5A")
|
XCTAssertEqual(response.scenicName, "伊犁那拉提景区-5A")
|
||||||
XCTAssertEqual(response.greenMarkerTip, "绿色为景区内摄影师")
|
XCTAssertEqual(response.greenMarkerTip, "绿色为景区内摄影师")
|
||||||
XCTAssertEqual(response.markers.map(\.markerType), ["red", "yellow"])
|
XCTAssertEqual(response.markers.map(\.markerType), ["red", "yellow"])
|
||||||
XCTAssertEqual(response.markers.last?.handleStatus?.displayText, WildReportStatus.processing.rawValue)
|
XCTAssertEqual(response.markers.last?.handleStatus?.displayText, WildReportStatus.processing.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMarkerDetailAPISendsRequestBodyAndDecodesGreenDetail() async throws {
|
func testMarkerDetailAPISendsGETQueryAndDecodesGreenDetail() async throws {
|
||||||
let json = Data("""
|
let json = Data("""
|
||||||
{
|
{
|
||||||
"code": 100000,
|
"code": 100000,
|
||||||
@ -340,15 +382,16 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
))
|
))
|
||||||
|
|
||||||
let request = try XCTUnwrap(session.requests.first)
|
let request = try XCTUnwrap(session.requests.first)
|
||||||
XCTAssertEqual(request.httpMethod, "POST")
|
XCTAssertEqual(request.httpMethod, "GET")
|
||||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/marker-detail")
|
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/marker-detail")
|
||||||
let bodyData = try XCTUnwrap(request.httpBody)
|
XCTAssertNil(request.httpBody)
|
||||||
let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
|
let components = try XCTUnwrap(URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false))
|
||||||
XCTAssertEqual(body["scenic_id"] as? Int, 128)
|
let queryItems = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value) })
|
||||||
XCTAssertEqual(body["marker_type"] as? String, "green")
|
XCTAssertEqual(queryItems["scenic_id"], "128")
|
||||||
XCTAssertEqual(body["id"] as? Int, 901)
|
XCTAssertEqual(queryItems["marker_type"], "green")
|
||||||
XCTAssertEqual(body["latitude"] as? Double, 43.3779)
|
XCTAssertEqual(queryItems["id"], "901")
|
||||||
XCTAssertEqual(body["longitude"] as? Double, 84.0217)
|
XCTAssertEqual(queryItems["latitude"], "43.3779")
|
||||||
|
XCTAssertEqual(queryItems["longitude"], "84.0217")
|
||||||
XCTAssertEqual(response.name, "阿依达娜")
|
XCTAssertEqual(response.name, "阿依达娜")
|
||||||
XCTAssertEqual(response.storeName, "空中草原旅拍店")
|
XCTAssertEqual(response.storeName, "空中草原旅拍店")
|
||||||
XCTAssertTrue(response.online)
|
XCTAssertTrue(response.online)
|
||||||
@ -428,10 +471,10 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
WildReportType(value: 2, label: "疑似打野"),
|
WildReportType(value: 2, label: "疑似打野"),
|
||||||
WildReportType(value: 4, 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 homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
let api = MockWildPhotographerReportAPI()
|
let api = MockWildPhotographerReportAPI()
|
||||||
@ -441,43 +484,61 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
|
|
||||||
await viewModel.loadReportTypes(api: api)
|
await viewModel.loadReportTypes(api: api)
|
||||||
|
|
||||||
XCTAssertEqual(viewModel.reportTypes, WildReportType.fallbackOptions)
|
XCTAssertTrue(viewModel.reportTypes.isEmpty)
|
||||||
XCTAssertEqual(viewModel.selectedReportType, .defaultSelected)
|
XCTAssertNil(viewModel.selectedReportType)
|
||||||
XCTAssertEqual(messages.last, "举报类型获取失败,已使用默认类型")
|
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSubmitFailsWithoutEvidence() {
|
func testSubmitFailsWithoutEvidence() async {
|
||||||
let homeViewModel = WildPhotographerReportHomeViewModel()
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
viewModel.selectReportType(WildReportType(value: 2, label: "疑似打野"))
|
||||||
viewModel.updateDescription("有说明")
|
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, "请至少上传一项现场证据。")
|
XCTAssertEqual(viewModel.validationMessage, "请至少上传一项现场证据。")
|
||||||
XCTAssertNil(viewModel.submitResult)
|
XCTAssertNil(viewModel.submitResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSubmitFailsWithoutDescription() {
|
func testSubmitFailsWithoutDescription() async throws {
|
||||||
let homeViewModel = WildPhotographerReportHomeViewModel()
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
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.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, "请填写举报说明。")
|
XCTAssertEqual(viewModel.validationMessage, "请填写举报说明。")
|
||||||
XCTAssertNil(viewModel.submitResult)
|
XCTAssertNil(viewModel.submitResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSubmitFailsWithoutConfirmedLocation() {
|
func testSubmitFailsWithoutConfirmedLocation() async throws {
|
||||||
let homeViewModel = WildPhotographerReportHomeViewModel()
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
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.updateDescription("测试说明")
|
||||||
|
|
||||||
viewModel.submitReport()
|
await viewModel.submitReport(
|
||||||
|
api: MockWildPhotographerReportAPI(),
|
||||||
|
uploader: MockWildReportAttachmentUploader(),
|
||||||
|
scenicId: 9,
|
||||||
|
scenicName: "测试景区"
|
||||||
|
)
|
||||||
|
|
||||||
XCTAssertEqual(viewModel.validationMessage, "请先更新举报位置后再提交举报。")
|
XCTAssertEqual(viewModel.validationMessage, "请先更新举报位置后再提交举报。")
|
||||||
XCTAssertNil(viewModel.submitResult)
|
XCTAssertNil(viewModel.submitResult)
|
||||||
@ -488,6 +549,7 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
let api = MockWildPhotographerReportAPI()
|
let api = MockWildPhotographerReportAPI()
|
||||||
let uploader = MockWildReportAttachmentUploader()
|
let uploader = MockWildReportAttachmentUploader()
|
||||||
|
viewModel.selectReportType(WildReportType(value: 2, label: "疑似打野"))
|
||||||
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
|
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
|
||||||
viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")])
|
viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")])
|
||||||
viewModel.updateDescription("测试说明")
|
viewModel.updateDescription("测试说明")
|
||||||
@ -497,7 +559,7 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertNil(viewModel.validationMessage)
|
XCTAssertNil(viewModel.validationMessage)
|
||||||
XCTAssertNotNil(viewModel.submitResult)
|
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(uploader.uploadedFileNames, ["scene.jpg", "payment.jpg"])
|
||||||
XCTAssertEqual(api.submitRequests.first?.scenicId, 9)
|
XCTAssertEqual(api.submitRequests.first?.scenicId, 9)
|
||||||
XCTAssertEqual(api.submitRequests.first?.reportType, 2)
|
XCTAssertEqual(api.submitRequests.first?.reportType, 2)
|
||||||
@ -512,9 +574,10 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
func testPaymentScreenshotsDoNotReplaceRequiredEvidence() async throws {
|
func testPaymentScreenshotsDoNotReplaceRequiredEvidence() async throws {
|
||||||
let homeViewModel = WildPhotographerReportHomeViewModel()
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
|
||||||
|
viewModel.selectReportType(WildReportType(value: 2, label: "疑似打野"))
|
||||||
viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")])
|
viewModel.addPaymentImages([try makeAttachment(kind: .payment, fileName: "payment.jpg")])
|
||||||
viewModel.updateDescription("测试说明")
|
viewModel.updateDescription("测试说明")
|
||||||
viewModel.applyResolvedLocation("测试景区 · 测试点")
|
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: "测试景区 · 测试点"))
|
||||||
|
|
||||||
await viewModel.submitReport(
|
await viewModel.submitReport(
|
||||||
api: MockWildPhotographerReportAPI(),
|
api: MockWildPhotographerReportAPI(),
|
||||||
@ -546,9 +609,10 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
let api = MockWildPhotographerReportAPI()
|
let api = MockWildPhotographerReportAPI()
|
||||||
let uploader = MockWildReportAttachmentUploader()
|
let uploader = MockWildReportAttachmentUploader()
|
||||||
uploader.error = OSSUploadError.emptyFile
|
uploader.error = OSSUploadError.emptyFile
|
||||||
|
viewModel.selectReportType(WildReportType(value: 2, label: "疑似打野"))
|
||||||
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
|
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
|
||||||
viewModel.updateDescription("测试说明")
|
viewModel.updateDescription("测试说明")
|
||||||
viewModel.applyResolvedLocation("测试景区 · 测试点")
|
viewModel.applyResolvedLocation(HomeLocationSnapshot(latitude: 30.12, longitude: 120.34, address: "测试景区 · 测试点"))
|
||||||
|
|
||||||
await viewModel.submitReport(api: api, uploader: uploader, scenicId: 9, scenicName: "测试景区")
|
await viewModel.submitReport(api: api, uploader: uploader, scenicId: 9, scenicName: "测试景区")
|
||||||
|
|
||||||
@ -556,23 +620,6 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.validationMessage, OSSUploadError.emptyFile.localizedDescription)
|
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 {
|
func testListInitialLoadUsesAllStatusAndFirstPage() async {
|
||||||
let homeViewModel = WildPhotographerReportHomeViewModel()
|
let homeViewModel = WildPhotographerReportHomeViewModel()
|
||||||
let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
|
let viewModel = WildPhotographerReportListViewModel(homeViewModel: homeViewModel)
|
||||||
@ -668,9 +715,6 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertEqual(api.supplementRequests.first?.id, 32)
|
XCTAssertEqual(api.supplementRequests.first?.id, 32)
|
||||||
XCTAssertEqual(api.supplementRequests.first?.desc, "补充说明")
|
XCTAssertEqual(api.supplementRequests.first?.desc, "补充说明")
|
||||||
XCTAssertEqual(api.supplementRequests.first?.evidences?.map(\.fileType), [1, 2])
|
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 {
|
func testSupplementEvidenceFailsWithoutServerID() async {
|
||||||
@ -757,13 +801,8 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testRiskMapViewModelRejectsInvalidScenicID() async {
|
func testRiskMapViewModelRejectsInvalidScenicID() async {
|
||||||
let viewModel = WildReportRiskMapViewModel(
|
let viewModel = WildReportRiskMapViewModel()
|
||||||
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
|
|
||||||
riskPoints: []
|
|
||||||
)
|
|
||||||
let api = MockWildPhotographerReportAPI()
|
let api = MockWildPhotographerReportAPI()
|
||||||
var messages: [String] = []
|
|
||||||
viewModel.onShowMessage = { messages.append($0) }
|
|
||||||
|
|
||||||
await viewModel.loadInitial(
|
await viewModel.loadInitial(
|
||||||
api: api,
|
api: api,
|
||||||
@ -772,27 +811,27 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertTrue(api.riskMapRequests.isEmpty)
|
XCTAssertTrue(api.riskMapRequests.isEmpty)
|
||||||
XCTAssertEqual(messages.last, "请先选择景区后再查看风险地图")
|
XCTAssertEqual(viewModel.errorMessage, "请先选择景区后再查看风险地图")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRiskMapViewModelLoadsWithLocationAndMapsMarkerTypes() async {
|
func testRiskMapViewModelLoadsWithLocationAndMapsMarkerTypes() async {
|
||||||
let viewModel = WildReportRiskMapViewModel(
|
let viewModel = WildReportRiskMapViewModel()
|
||||||
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
|
|
||||||
riskPoints: []
|
|
||||||
)
|
|
||||||
let api = MockWildPhotographerReportAPI()
|
let api = MockWildPhotographerReportAPI()
|
||||||
api.riskMapResponses = [makeRiskMapResponse()]
|
api.riskMapResponses = [makeRiskMapResponse()]
|
||||||
|
let locationProvider = MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217))
|
||||||
|
|
||||||
await viewModel.loadInitial(
|
await viewModel.loadInitial(
|
||||||
api: api,
|
api: api,
|
||||||
locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)),
|
locationProvider: locationProvider,
|
||||||
scenicId: 128,
|
scenicId: 128,
|
||||||
scenicName: "测试景区"
|
scenicName: "测试景区"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.riskMapRequests.count, 1)
|
||||||
XCTAssertEqual(api.riskMapRequests.first?.scenicId, 128)
|
XCTAssertEqual(api.riskMapRequests.first?.scenicId, 128)
|
||||||
XCTAssertEqual(api.riskMapRequests.first?.latitude, 43.3779)
|
XCTAssertEqual(api.riskMapRequests.first?.latitude, 43.3779)
|
||||||
XCTAssertEqual(api.riskMapRequests.first?.longitude, 84.0217)
|
XCTAssertEqual(api.riskMapRequests.first?.longitude, 84.0217)
|
||||||
|
XCTAssertEqual(locationProvider.requestedAccuracies.last, kCLLocationAccuracyHundredMeters)
|
||||||
XCTAssertEqual(viewModel.scenicAreaName, "伊犁那拉提景区-5A")
|
XCTAssertEqual(viewModel.scenicAreaName, "伊犁那拉提景区-5A")
|
||||||
XCTAssertTrue(viewModel.markers.contains { $0.kind == .selfLocation })
|
XCTAssertTrue(viewModel.markers.contains { $0.kind == .selfLocation })
|
||||||
XCTAssertTrue(viewModel.markers.contains { $0.kind == .wildPhotographer })
|
XCTAssertTrue(viewModel.markers.contains { $0.kind == .wildPhotographer })
|
||||||
@ -807,10 +846,7 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testRiskMapViewModelLoadsWithoutLocationWhenLocationFails() async {
|
func testRiskMapViewModelLoadsWithoutLocationWhenLocationFails() async {
|
||||||
let viewModel = WildReportRiskMapViewModel(
|
let viewModel = WildReportRiskMapViewModel()
|
||||||
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
|
|
||||||
riskPoints: []
|
|
||||||
)
|
|
||||||
let api = MockWildPhotographerReportAPI()
|
let api = MockWildPhotographerReportAPI()
|
||||||
api.riskMapResponses = [makeRiskMapResponse()]
|
api.riskMapResponses = [makeRiskMapResponse()]
|
||||||
|
|
||||||
@ -826,11 +862,29 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertFalse(viewModel.markers.contains { $0.kind == .selfLocation })
|
XCTAssertFalse(viewModel.markers.contains { $0.kind == .selfLocation })
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRiskMapViewModelLoadsMarkerDetailAndUpdatesSelectedClue() async {
|
func testRiskMapViewModelFocusCurrentLocationSelectsSelfMarker() async {
|
||||||
let viewModel = WildReportRiskMapViewModel(
|
let viewModel = WildReportRiskMapViewModel()
|
||||||
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
|
let api = MockWildPhotographerReportAPI()
|
||||||
riskPoints: []
|
api.riskMapResponses = [makeRiskMapResponse()]
|
||||||
|
await viewModel.loadInitial(
|
||||||
|
api: api,
|
||||||
|
locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)),
|
||||||
|
scenicId: 128
|
||||||
)
|
)
|
||||||
|
let redMarker = try! XCTUnwrap(viewModel.markers.first { $0.kind == .wildPhotographer })
|
||||||
|
await viewModel.selectMarker(redMarker)
|
||||||
|
|
||||||
|
let didFocus = viewModel.focusCurrentLocation()
|
||||||
|
|
||||||
|
XCTAssertTrue(didFocus)
|
||||||
|
XCTAssertEqual(viewModel.selectedMarker?.kind, .selfLocation)
|
||||||
|
XCTAssertEqual(viewModel.region.center.latitude, 43.3779, accuracy: 0.000001)
|
||||||
|
XCTAssertEqual(viewModel.region.center.longitude, 84.0217, accuracy: 0.000001)
|
||||||
|
XCTAssertTrue(api.markerDetailRequests.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRiskMapViewModelLoadsMarkerDetailAndUpdatesSelectedClue() async {
|
||||||
|
let viewModel = WildReportRiskMapViewModel()
|
||||||
let api = MockWildPhotographerReportAPI()
|
let api = MockWildPhotographerReportAPI()
|
||||||
api.riskMapResponses = [makeRiskMapResponse()]
|
api.riskMapResponses = [makeRiskMapResponse()]
|
||||||
api.markerDetailResponses = [
|
api.markerDetailResponses = [
|
||||||
@ -866,16 +920,56 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.selectedClue?.detail, "多人结伴揽客")
|
XCTAssertEqual(viewModel.selectedClue?.detail, "多人结伴揽客")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRiskMapViewModelMarkerDetailFailureKeepsSummary() async {
|
func testRiskMapViewModelMapsProcessingMarkerDetailForBottomCard() async {
|
||||||
let viewModel = WildReportRiskMapViewModel(
|
let viewModel = WildReportRiskMapViewModel()
|
||||||
record: makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord(),
|
let api = MockWildPhotographerReportAPI()
|
||||||
riskPoints: []
|
api.riskMapResponses = [makeRiskMapResponse()]
|
||||||
|
api.markerDetailResponses = [
|
||||||
|
WildReportMarkerDetailResponse(
|
||||||
|
markerType: "yellow",
|
||||||
|
id: 33,
|
||||||
|
clueId: "2026年7月9日-0039",
|
||||||
|
title: "疑似打野摄影师举报",
|
||||||
|
statusTag: "正在处理中",
|
||||||
|
latitude: "32.3774100",
|
||||||
|
longitude: "119.3979350",
|
||||||
|
distanceM: 7068,
|
||||||
|
reportTypeText: "疑似打野",
|
||||||
|
desc: "色地方噶是的法规",
|
||||||
|
reporterName: "游客",
|
||||||
|
reporterPhone: "121****1212",
|
||||||
|
createdAt: "2026-07-09 11:37:26",
|
||||||
|
evidences: [
|
||||||
|
WildReportDetailEvidence(fileType: 1, fileTypeText: "图片", fileURL: "https://example.com/one.jpg"),
|
||||||
|
WildReportDetailEvidence(fileType: 1, fileTypeText: "图片", fileURL: "https://example.com/two.jpg")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
await viewModel.loadInitial(
|
||||||
|
api: api,
|
||||||
|
locationProvider: MockRiskMapLocationProvider(.success(latitude: 32.429943033854165, longitude: 119.44032552083333)),
|
||||||
|
scenicId: 128
|
||||||
)
|
)
|
||||||
|
let yellowMarker = try! XCTUnwrap(viewModel.markers.first { $0.kind == .processingClue })
|
||||||
|
|
||||||
|
await viewModel.selectMarker(yellowMarker, api: api, scenicId: 128)
|
||||||
|
|
||||||
|
XCTAssertEqual(api.markerDetailRequests.first?.markerType, "yellow")
|
||||||
|
XCTAssertEqual(viewModel.selectedClue?.id, "2026年7月9日-0039")
|
||||||
|
XCTAssertEqual(viewModel.selectedClue?.statusText, "正在处理中")
|
||||||
|
XCTAssertEqual(viewModel.selectedClue?.reporterName, "游客")
|
||||||
|
XCTAssertEqual(viewModel.selectedClue?.reporterPhone, "121****1212")
|
||||||
|
XCTAssertEqual(viewModel.selectedClue?.reportContents.first?.content, "色地方噶是的法规")
|
||||||
|
XCTAssertEqual(viewModel.selectedClue?.images, ["https://example.com/one.jpg", "https://example.com/two.jpg"])
|
||||||
|
XCTAssertEqual(viewModel.selectedClue?.processingStarted, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRiskMapViewModelMarkerDetailFailureKeepsSummary() async {
|
||||||
|
let viewModel = WildReportRiskMapViewModel()
|
||||||
let api = MockWildPhotographerReportAPI()
|
let api = MockWildPhotographerReportAPI()
|
||||||
api.riskMapResponses = [makeRiskMapResponse()]
|
api.riskMapResponses = [makeRiskMapResponse()]
|
||||||
api.markerDetailError = URLError(.badServerResponse)
|
api.markerDetailError = URLError(.badServerResponse)
|
||||||
var messages: [String] = []
|
|
||||||
viewModel.onShowMessage = { messages.append($0) }
|
|
||||||
await viewModel.loadInitial(
|
await viewModel.loadInitial(
|
||||||
api: api,
|
api: api,
|
||||||
locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)),
|
locationProvider: MockRiskMapLocationProvider(.success(latitude: 43.3779, longitude: 84.0217)),
|
||||||
@ -886,7 +980,7 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
await viewModel.selectMarker(redMarker, api: api, scenicId: 128)
|
await viewModel.selectMarker(redMarker, api: api, scenicId: 128)
|
||||||
|
|
||||||
XCTAssertEqual(viewModel.selectedClue?.summary, "疑似打野")
|
XCTAssertEqual(viewModel.selectedClue?.summary, "疑似打野")
|
||||||
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
|
XCTAssertEqual(viewModel.errorMessage, URLError(.badServerResponse).localizedDescription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDetailViewModelLoadsRemoteDetailWhenServerIDExists() async {
|
func testDetailViewModelLoadsRemoteDetailWhenServerIDExists() async {
|
||||||
@ -946,6 +1040,8 @@ final class WildPhotographerReportTests: XCTestCase {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private final class MockWildPhotographerReportAPI: WildPhotographerReportServing {
|
private final class MockWildPhotographerReportAPI: WildPhotographerReportServing {
|
||||||
var reportTypeResponse = WildReportTypeListResponse(list: [])
|
var reportTypeResponse = WildReportTypeListResponse(list: [])
|
||||||
|
var reportCopyResponse = WildReportCopyResponse()
|
||||||
|
var reportCopyError: Error?
|
||||||
var error: Error?
|
var error: Error?
|
||||||
var submitRequests: [WildReportSubmitRequest] = []
|
var submitRequests: [WildReportSubmitRequest] = []
|
||||||
var reportListResponses: [WildReportListResponse] = []
|
var reportListResponses: [WildReportListResponse] = []
|
||||||
@ -968,6 +1064,11 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
|
|||||||
return reportTypeResponse
|
return reportTypeResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func reportCopy() async throws -> WildReportCopyResponse {
|
||||||
|
if let reportCopyError { throw reportCopyError }
|
||||||
|
return reportCopyResponse
|
||||||
|
}
|
||||||
|
|
||||||
func submitReport(_ request: WildReportSubmitRequest) async throws {
|
func submitReport(_ request: WildReportSubmitRequest) async throws {
|
||||||
if let error { throw error }
|
if let error { throw error }
|
||||||
submitRequests.append(request)
|
submitRequests.append(request)
|
||||||
@ -1018,11 +1119,24 @@ private enum MockRiskMapLocationResult: Sendable {
|
|||||||
case failure
|
case failure
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MockRiskMapLocationProvider: LocationProviding {
|
private final class MockRiskMapLocationProvider: LocationProviding, @unchecked Sendable {
|
||||||
private let result: MockRiskMapLocationResult
|
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.result = result
|
||||||
|
self.onCoordinateRequest = onCoordinateRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestSnapshot() async throws -> HomeLocationSnapshot {
|
func requestSnapshot() async throws -> HomeLocationSnapshot {
|
||||||
@ -1034,7 +1148,11 @@ private struct MockRiskMapLocationProvider: LocationProviding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
|
||||||
|
lock.lock()
|
||||||
|
recordedAccuracies.append(desiredAccuracy)
|
||||||
|
lock.unlock()
|
||||||
|
await onCoordinateRequest?(desiredAccuracy)
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let latitude, let longitude):
|
case .success(let latitude, let longitude):
|
||||||
return CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
return CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||||
|
|||||||
Reference in New Issue
Block a user