// // PunchPointViewModels.swift // suixinkan // import CoreLocation import Foundation /// 打卡点图片上传协议,便于表单 ViewModel 注入替身。 @MainActor protocol PunchPointImageUploading { /// 上传打卡点图片并返回 OSS URL。 func uploadPunchPointImage( data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void ) async throws -> String } /// 打卡点列表页 ViewModel,负责筛选、分页、二维码与删除。 final class PunchPointListViewModel { private(set) var items: [PunchPointItem] = [] private(set) var filterType: PunchPointFilterType = .all private(set) var isLoading = false private(set) var isRefreshing = false private(set) var canLoadMore = false var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onShowQRCode: ((String) -> Void)? private var currentPage = 1 private var totalCount = 0 private let pageSize = 10 private let currentScenicIdProvider: () -> Int /// 初始化打卡点列表 ViewModel。 init(currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }) { self.currentScenicIdProvider = currentScenicIdProvider } /// 首次加载。 func loadInitial(api: any PunchPointAPIProtocol) async { await load(reset: true, showLoading: true, api: api) } /// 下拉刷新。 func refresh(api: any PunchPointAPIProtocol) async { isRefreshing = true notifyStateChange() await load(reset: true, showLoading: false, api: api) } /// 选择筛选项并刷新第一页。 func selectFilter(_ type: PunchPointFilterType, api: any PunchPointAPIProtocol) async { guard filterType != type else { return } filterType = type notifyStateChange() await load(reset: true, showLoading: false, api: api) } /// 滚动接近底部时加载更多。 func loadMoreIfNeeded(lastVisibleIndex: Int, api: any PunchPointAPIProtocol) async { guard lastVisibleIndex >= items.count - 3 else { return } await load(reset: false, showLoading: false, api: api) } /// 展示二维码。 func showQRCode(for item: PunchPointItem) { guard item.status == 1 || item.status == 2 else { onShowMessage?("未审核通过不可查看二维码") return } guard let url = item.mpQrcode?.trimmingCharacters(in: .whitespacesAndNewlines), !url.isEmpty else { onShowMessage?("暂无二维码") return } onShowQRCode?(url) } /// 删除打卡点。 func delete(item: PunchPointItem, api: any PunchPointAPIProtocol) async { do { try await api.delete(id: item.id) items.removeAll { $0.id == item.id } totalCount = max(0, totalCount - 1) canLoadMore = items.count < totalCount notifyStateChange() } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "删除失败" : "删除失败:\(error.localizedDescription)") } } private func load(reset: Bool, showLoading: Bool, api: any PunchPointAPIProtocol) async { if reset { currentPage = 1 canLoadMore = false } else { guard canLoadMore, !isLoading, !isRefreshing else { return } currentPage += 1 } let scenicId = currentScenicIdProvider() guard scenicId > 0 else { onShowMessage?("请先选择景区") if reset { items = [] canLoadMore = false totalCount = 0 } notifyStateChange() return } isLoading = showLoading notifyStateChange() defer { isLoading = false isRefreshing = false notifyStateChange() } do { let response = try await api.list( scenicAreaId: String(scenicId), status: filterType.apiStatus, page: currentPage, pageSize: pageSize ) totalCount = response.total items = reset ? response.list : items + response.list canLoadMore = response.list.count >= pageSize && items.count < totalCount } catch is CancellationError { return } catch { if !reset, currentPage > 1 { currentPage -= 1 } if reset { items = [] totalCount = 0 canLoadMore = false } onShowMessage?(error.localizedDescription.isEmpty ? "获取打卡点列表失败" : "获取打卡点列表失败:\(error.localizedDescription)") } } private func notifyStateChange() { onStateChange?() } } /// 打卡点详情页 ViewModel,负责详情加载。 final class PunchPointDetailViewModel { private(set) var detail: PunchPointDetail? private(set) var isLoading = false var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? let punchPointId: Int64 /// 初始化详情 ViewModel。 init(punchPointId: Int64) { self.punchPointId = punchPointId } /// 加载详情。 func load(api: any PunchPointAPIProtocol) async { guard punchPointId > 0 else { onShowMessage?("打卡点信息异常") return } isLoading = true notifyStateChange() defer { isLoading = false notifyStateChange() } do { detail = try await api.info(id: punchPointId) } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "获取打卡点详情失败" : error.localizedDescription) } } private func notifyStateChange() { onStateChange?() } } /// 打卡点新建与编辑页 ViewModel,负责表单、定位、图片上传和提交。 final class PunchPointFormViewModel { /// 表单模式。 enum Mode: Equatable { case create case edit(id: Int64) } private(set) var name = "" private(set) var address = "" private(set) var description = "" private(set) var coordinate: CLLocationCoordinate2D? private(set) var images: [PunchPointImageState] = [] private(set) var uploadDialogState: PunchPointUploadDialogState? private(set) var isSubmitting = false private(set) var isLocating = false var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? var onSubmitSuccess: (() -> Void)? var onCoordinateChange: ((CLLocationCoordinate2D) -> Void)? let mode: Mode private let currentScenicIdProvider: () -> Int private let locationProvider: any LocationProviding /// 初始化打卡点表单 ViewModel。 init( mode: Mode = .create, currentScenicIdProvider: @escaping () -> Int = { AppStore.shared.session.currentScenicId }, locationProvider: any LocationProviding = LocationProvider.shared ) { self.mode = mode self.currentScenicIdProvider = currentScenicIdProvider self.locationProvider = locationProvider } /// 坐标展示文本。 var coordinatesText: String { guard let coordinate else { return "" } return "\(coordinate.latitude),\(coordinate.longitude)" } /// 使用详情初始化编辑表单。 func initialize(with detail: PunchPointDetail) { name = detail.name address = detail.displayAddress description = detail.description ?? "" coordinate = detail.coordinate images = detail.guideImages.map(PunchPointImageState.remote(url:)) if let coordinate { onCoordinateChange?(coordinate) } notifyStateChange() } /// 更新名称。 func updateName(_ text: String) { name = text notifyStateChange() } /// 更新地址。 func updateAddress(_ text: String) { address = text notifyStateChange() } /// 更新描述,最多 50 字。 func updateDescription(_ text: String) { description = String(text.prefix(50)) notifyStateChange() } /// 自动定位当前坐标并回填地址。 func autoLocation() async { await locate(updateMap: true, missingPermissionMessage: "定位权限未授予,无法自动定位") } /// 点击定位按钮。 func locateToCurrent() async { await locate(updateMap: true, missingPermissionMessage: "定位权限未授予,请在设置中开启") } /// 地图点击选择坐标。 func selectCoordinate(_ coordinate: CLLocationCoordinate2D) async { self.coordinate = coordinate notifyStateChange() onCoordinateChange?(coordinate) let resolved = await locationProvider.reverseGeocode( latitude: coordinate.latitude, longitude: coordinate.longitude ) if !resolved.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { address = resolved notifyStateChange() } } /// 添加本地图片并立即顺序上传。 func addLocalImages(_ newImages: [PunchPointImageState], uploader: any PunchPointImageUploading) async { let remaining = 9 - images.count guard remaining > 0 else { onShowMessage?("最多只能选择9张图片") return } let accepted = Array(newImages.prefix(remaining)) guard !accepted.isEmpty else { return } let start = images.count images.append(contentsOf: accepted) notifyStateChange() for offset in accepted.indices { await uploadImage(at: start + offset, title: "正在上传(\(offset + 1)/\(accepted.count))", uploader: uploader) } uploadDialogState = nil notifyStateChange() } /// 删除图片。 func deleteImage(at index: Int) { guard images.indices.contains(index), !images[index].isUploading else { return } images.remove(at: index) notifyStateChange() } /// 重新上传失败图片。 func retryUpload(at index: Int, uploader: any PunchPointImageUploading) async { guard images.indices.contains(index) else { return } images[index].errorMessage = nil notifyStateChange() await uploadImage(at: index, title: "正在上传(1/1)", uploader: uploader) uploadDialogState = nil notifyStateChange() } /// 提交审核。 func submit(api: any PunchPointAPIProtocol) async { guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { onShowMessage?("请输入打卡点名称") return } guard let coordinate else { onShowMessage?("请选择打卡点坐标") return } guard !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { onShowMessage?("请输入打卡点地址") return } guard !images.isEmpty else { onShowMessage?("请上传至少一张图片") return } guard !images.contains(where: { $0.isUploading }) else { onShowMessage?("图片正在上传,请稍后提交") return } guard !images.contains(where: { !$0.isUploaded }) else { onShowMessage?("请先完成图片上传") return } let scenicId = currentScenicIdProvider() guard scenicId > 0 else { onShowMessage?("请先选择景区") return } let request = PunchPointSaveRequest( id: editId, scenicAreaId: String(scenicId), name: name, description: description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : description, region: PunchPointSaveRegion( lot: coordinate.longitude, lat: coordinate.latitude, address: address ), scenicSpotString: address, guideImages: images.map(\.uploadedURL) ) isSubmitting = true notifyStateChange() defer { isSubmitting = false notifyStateChange() } do { switch mode { case .create: try await api.add(request) case .edit: try await api.edit(request) } onShowMessage?("提交成功") onSubmitSuccess?() } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "提交失败" : "提交失败\(error.localizedDescription)") } } private var editId: Int64? { if case .edit(let id) = mode { return id } return nil } private func locate(updateMap: Bool, missingPermissionMessage: String) async { isLocating = true notifyStateChange() defer { isLocating = false notifyStateChange() } do { let snapshot = try await locationProvider.requestSnapshot() let coordinate = CLLocationCoordinate2D(latitude: snapshot.latitude, longitude: snapshot.longitude) self.coordinate = coordinate if !snapshot.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { address = snapshot.address } if updateMap { onCoordinateChange?(coordinate) } } catch LocationProviderError.permissionDenied { onShowMessage?(missingPermissionMessage) } catch is CancellationError { return } catch { onShowMessage?(error.localizedDescription.isEmpty ? "定位失败" : error.localizedDescription) } } private func uploadImage(at index: Int, title: String, uploader: any PunchPointImageUploading) async { guard images.indices.contains(index) else { return } let scenicId = currentScenicIdProvider() guard scenicId > 0 else { onShowMessage?("请先选择景区") return } guard !images[index].isUploaded else { return } images[index].isUploading = true images[index].uploadProgress = 0 images[index].errorMessage = nil uploadDialogState = PunchPointUploadDialogState(title: title, progress: 0) notifyStateChange() do { let item = images[index] let url = try await uploader.uploadPunchPointImage( data: item.data, fileName: item.fileName, scenicId: scenicId, onProgress: { [weak self] progress in guard let self else { return } Task { @MainActor in guard self.images.indices.contains(index) else { return } self.images[index].uploadProgress = progress self.images[index].isUploading = progress < 100 self.uploadDialogState = PunchPointUploadDialogState(title: title, progress: progress) self.notifyStateChange() } } ) guard images.indices.contains(index) else { return } images[index].uploadedURL = url images[index].previewURL = url images[index].isUploading = false images[index].uploadProgress = 100 } catch is CancellationError { return } catch { guard images.indices.contains(index) else { return } images[index].isUploading = false images[index].uploadProgress = 0 images[index].errorMessage = error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription onShowMessage?(error.localizedDescription.isEmpty ? "图片上传失败,请重试" : error.localizedDescription) } } private func notifyStateChange() { onStateChange?() } }