将打卡点管理与位置上报从占位页迁移为完整 MVVM 流程,包含前台定位支持与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
281 lines
9.2 KiB
Swift
281 lines
9.2 KiB
Swift
//
|
||
// PunchPointViewModels.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/24.
|
||
//
|
||
|
||
import Foundation
|
||
import Observation
|
||
|
||
/// 打卡点列表 ViewModel,负责筛选、分页、详情加载和删除刷新。
|
||
@MainActor
|
||
@Observable
|
||
final class PunchPointListViewModel {
|
||
var selectedFilter: PunchPointFilter = .all
|
||
var items: [PunchPointItem] = []
|
||
var selectedDetail: PunchPointItem?
|
||
var errorMessage: String?
|
||
var isLoading = false
|
||
var isLoadingMore = false
|
||
var total = 0
|
||
|
||
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
|
||
@Observable
|
||
final class PunchPointEditorViewModel {
|
||
var name = ""
|
||
var description = ""
|
||
var address = ""
|
||
var latitudeText = ""
|
||
var longitudeText = ""
|
||
var scenicSpotText = ""
|
||
var remoteImages: [String] = []
|
||
var localImages: [PunchPointLocalImage] = []
|
||
var errorMessage: String?
|
||
var isSubmitting = false
|
||
|
||
let editingItem: PunchPointItem?
|
||
|
||
/// 初始化编辑 ViewModel,可传入已有打卡点作为编辑表单初值。
|
||
init(item: PunchPointItem? = nil) {
|
||
editingItem = item
|
||
if let 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
|
||
}
|
||
}
|
||
|
||
/// 设置经纬度和地址,通常由定位或地图选点回填。
|
||
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
|
||
}
|
||
}
|