Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,286 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user