// // PunchPointViewModels.swift // suixinkan // // Created by Codex on 2026/6/24. // import Foundation /// 打卡点列表 ViewModel,负责筛选、分页、详情加载和删除刷新。 @MainActor final class PunchPointListViewModel { var onChange: (() -> Void)? var selectedFilter: PunchPointFilter = .all { didSet { onChange?() } } var items: [PunchPointItem] = [] { didSet { onChange?() } } var selectedDetail: PunchPointItem? { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } var isLoading = false { didSet { onChange?() } } var isLoadingMore = false { didSet { onChange?() } } var total = 0 { didSet { onChange?() } } private var page = 1 private let pageSize = 20 /// 是否还存在下一页数据。 var hasMore: Bool { items.count < total } /// 重新加载当前筛选下的打卡点列表。 func reload(scenicId: Int?, api: any PunchPointServing) async { guard let scenicId else { reset() return } page = 1 isLoading = true errorMessage = nil defer { isLoading = false } do { let payload = try await api.punchPointList( scenicId: scenicId, status: selectedFilter.rawValue, page: page, pageSize: pageSize ) items = payload.list total = payload.total } catch { items = [] total = 0 errorMessage = error.localizedDescription } } /// 加载下一页打卡点列表。 func loadMore(scenicId: Int?, api: any PunchPointServing) async { guard let scenicId, hasMore, !isLoadingMore, !isLoading else { return } isLoadingMore = true let nextPage = page + 1 defer { isLoadingMore = false } do { let payload = try await api.punchPointList( scenicId: scenicId, status: selectedFilter.rawValue, page: nextPage, pageSize: pageSize ) page = nextPage items.append(contentsOf: payload.list) total = payload.total } catch { errorMessage = error.localizedDescription } } /// 切换筛选并重载列表。 func selectFilter(_ filter: PunchPointFilter, scenicId: Int?, api: any PunchPointServing) async { guard selectedFilter != filter else { return } selectedFilter = filter await reload(scenicId: scenicId, api: api) } /// 加载单个打卡点详情。 func loadDetail(id: Int, api: any PunchPointServing) async { errorMessage = nil do { selectedDetail = try await api.punchPointInfo(id: id) } catch { selectedDetail = items.first { $0.id == id } errorMessage = error.localizedDescription } } /// 删除打卡点并刷新当前列表。 func delete(_ item: PunchPointItem, scenicId: Int?, api: any PunchPointServing) async -> Bool { do { try await api.deletePunchPoint(id: item.id) await reload(scenicId: scenicId, api: api) return true } catch { errorMessage = error.localizedDescription return false } } /// 清空列表状态,通常用于缺少当前景区时。 func reset() { items = [] selectedDetail = nil errorMessage = nil total = 0 page = 1 isLoading = false isLoadingMore = false } } /// 打卡点编辑 ViewModel,负责新增、编辑、表单校验和 OSS 图片上传。 @MainActor final class PunchPointEditorViewModel { var onChange: (() -> Void)? var name = "" { didSet { onChange?() } } var description = "" { didSet { onChange?() } } var address = "" { didSet { onChange?() } } var latitudeText = "" { didSet { onChange?() } } var longitudeText = "" { didSet { onChange?() } } var scenicSpotText = "" { didSet { onChange?() } } var remoteImages: [String] = [] { didSet { onChange?() } } var localImages: [PunchPointLocalImage] = [] { didSet { onChange?() } } var errorMessage: String? { didSet { onChange?() } } var isSubmitting = false { didSet { onChange?() } } private(set) var editingItem: PunchPointItem? /// 初始化编辑 ViewModel,可传入已有打卡点作为编辑表单初值。 init(item: PunchPointItem? = nil) { editingItem = item if let item { apply(item) } } /// 回填编辑详情,保留同一个 ObservableObject 实例。 func apply(_ item: PunchPointItem) { editingItem = item name = item.name description = item.description address = item.region?.address ?? "" latitudeText = item.region.map { String($0.lat) } ?? "" longitudeText = item.region.map { String($0.lot) } ?? "" scenicSpotText = item.scenicSpotStr remoteImages = item.guideImages localImages = [] } /// 设置经纬度和地址,通常由定位或地图选点回填。 func applyLocation(latitude: Double, longitude: Double, address: String) { latitudeText = String(format: "%.6f", latitude) longitudeText = String(format: "%.6f", longitude) self.address = address if scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { scenicSpotText = address } } /// 添加本地待上传图片。 func addLocalImages(_ images: [PunchPointLocalImage]) { localImages.append(contentsOf: images) } /// 删除远程图片。 func removeRemoteImage(_ url: String) { remoteImages.removeAll { $0 == url } } /// 删除本地待上传图片。 func removeLocalImage(id: UUID) { localImages.removeAll { $0.id == id } } /// 提交新增或编辑表单,图片会先上传 OSS,再提交业务接口。 func submit( scenicId: Int?, api: any PunchPointServing, uploadService: any OSSUploadServing ) async -> Bool { guard !isSubmitting else { return false } guard let scenicId else { errorMessage = "缺少当前景区" return false } guard let region = makeRegion() else { return false } let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedName.isEmpty else { errorMessage = "请输入打卡点名称" return false } guard !region.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { errorMessage = "请选择或填写打卡点地址" return false } guard !remoteImages.isEmpty || !localImages.isEmpty else { errorMessage = "请至少上传一张打卡点图片" return false } isSubmitting = true errorMessage = nil defer { isSubmitting = false } do { let uploadedImages = try await uploadImages(scenicId: scenicId, uploadService: uploadService) let allImages = remoteImages + uploadedImages if let editingItem { try await api.editPunchPoint( EditPunchPointRequest( id: editingItem.id, scenicAreaId: "\(scenicId)", name: trimmedName, description: description.trimmingCharacters(in: .whitespacesAndNewlines), region: region, scenicSpotStr: scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines), guideImages: allImages ) ) } else { try await api.addPunchPoint( AddPunchPointRequest( scenicAreaId: "\(scenicId)", name: trimmedName, description: description.trimmingCharacters(in: .whitespacesAndNewlines), region: region, scenicSpotStr: scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines), guideImages: allImages ) ) } remoteImages = allImages localImages = [] return true } catch { errorMessage = error.localizedDescription return false } } /// 构建提交区域,校验经纬度是否可用。 private func makeRegion() -> PunchPointRegion? { let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) guard let lat = Double(latitudeText.trimmingCharacters(in: .whitespacesAndNewlines)), let lot = Double(longitudeText.trimmingCharacters(in: .whitespacesAndNewlines)) else { errorMessage = "请选择打卡点坐标" return nil } return PunchPointRegion( lat: lat, lot: lot, address: trimmedAddress, scenicSpotStr: scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines) ) } /// 上传本地图片,并返回 OSS URL 列表。 private func uploadImages(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] { var uploaded: [String] = [] for index in localImages.indices { let local = localImages[index] let url = try await uploadService.uploadPunchPointImage( data: local.data, fileName: local.fileName, scenicId: scenicId ) { [weak self] progress in Task { @MainActor in self?.localImages[index].progress = progress } } localImages[index].remoteURL = url uploaded.append(url) } return uploaded } }