Files
suixinkan_ios_new/suixinkan/Features/Projects/ViewModels/ProjectViewModels.swift
汉秋 703078352c 从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构
将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +08:00

622 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// ProjectViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
import Combine
///
enum ProjectEditorMode: Equatable {
case create
case edit(PhotographerProjectDetailResponse)
}
///
enum StoreProjectEditorMode: Equatable {
case create
case edit(PhotographerProjectDetailResponse)
}
///
enum ProjectEditorError: LocalizedError, Equatable {
case missingScenic
case missingUser
case missingName
case missingDescription
case missingPrice
case missingSpot
case missingImage
case missingStore
///
var errorDescription: String? {
switch self {
case .missingScenic: return "当前缺少景区信息"
case .missingUser: return "当前缺少用户信息"
case .missingName: return "请输入项目名称"
case .missingDescription: return "请输入项目简介"
case .missingPrice: return "请填写有效价格"
case .missingSpot: return "请至少选择一个打卡点"
case .missingImage: return "请至少上传项目封面"
case .missingStore: return "请选择所属门店"
}
}
}
/// ViewModel
@MainActor
final class ProjectManagementViewModel: ObservableObject {
@Published private(set) var items: [PhotographerProjectItem] = []
@Published private(set) var loading = false
@Published private(set) var loadingMore = false
@Published private(set) var hasMore = false
@Published private(set) var selectedDetail: PhotographerProjectDetailResponse?
@Published var searchText = ""
@Published var errorMessage: String?
@Published private var page = 1
@Published private var total = 0
private let pageSize = 10
///
func reload(api: any ProjectServing, scenicId: Int?) async {
guard let scenicId else {
resetList()
return
}
loading = true
errorMessage = nil
defer { loading = false }
do {
page = 1
let response = try await api.projectList(
scenicId: scenicId,
name: normalizedSearch,
page: page,
pageSize: pageSize
)
total = response.total
items = sorted(response.list)
hasMore = items.count < total
page = 2
} catch {
resetList()
errorMessage = error.localizedDescription
}
}
///
func loadMore(api: any ProjectServing, scenicId: Int?) async {
guard let scenicId, hasMore, !loadingMore else { return }
loadingMore = true
defer { loadingMore = false }
do {
let response = try await api.projectList(scenicId: scenicId, name: normalizedSearch, page: page, pageSize: pageSize)
total = response.total
items = sorted(deduplicated(items + response.list))
hasMore = items.count < total
page += 1
} catch {
errorMessage = error.localizedDescription
}
}
///
func loadDetail(id: Int, api: any ProjectServing) async {
do {
selectedDetail = try await api.projectDetail(id: id)
} catch {
errorMessage = error.localizedDescription
}
}
///
func delete(id: Int, api: any ProjectServing) async {
do {
try await api.deleteProject(id: id)
items.removeAll { $0.id == id }
total = max(total - 1, 0)
hasMore = items.count < total
} catch {
errorMessage = error.localizedDescription
}
}
private var normalizedSearch: String? {
let value = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
private func resetList() {
items = []
loading = false
loadingMore = false
hasMore = false
page = 1
total = 0
}
private func sorted(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] {
values.sorted { lhs, rhs in
if lhs.status != rhs.status { return lhs.status < rhs.status }
return lhs.id > rhs.id
}
}
private func deduplicated(_ values: [PhotographerProjectItem]) -> [PhotographerProjectItem] {
var result: [Int: PhotographerProjectItem] = [:]
values.forEach { result[$0.id] = $0 }
return Array(result.values)
}
}
/// ViewModel
@MainActor
final class ProjectEditorViewModel: ObservableObject {
@Published var name = ""
@Published var descriptionText = ""
@Published var price = ""
@Published var otPrice = ""
@Published var deposit = ""
@Published var attrLabelText = ""
@Published var materialNum = "1"
@Published var photoNum = "1"
@Published var videoNum = "1"
@Published var selectedSpotIds: Set<Int> = []
@Published var coverImage: ProjectLocalImage?
@Published var carouselImages: [ProjectLocalImage] = []
@Published var existingCoverURL = ""
@Published var existingCarouselURLs: [String] = []
@Published private(set) var submitting = false
@Published private(set) var uploadProgress = 0
@Published var errorMessage: String?
private var mode: ProjectEditorMode
///
init(mode: ProjectEditorMode) {
self.mode = mode
if case let .edit(detail) = mode {
apply(detail)
}
}
/// ObservableObject
func apply(_ detail: PhotographerProjectDetailResponse) {
mode = .edit(detail)
name = detail.name
descriptionText = detail.description
price = detail.price
otPrice = detail.otPrice
deposit = detail.priceDeposit
attrLabelText = detail.attrLabel.joined(separator: "")
materialNum = "\(max(detail.materialNum, 0))"
photoNum = "\(max(detail.photoNum, 0))"
videoNum = "\(max(detail.videoNum, 0))"
selectedSpotIds = Set(detail.scenicList.map(\.id))
existingCoverURL = detail.coverProject
existingCarouselURLs = detail.coverCarousel
coverImage = nil
carouselImages = []
}
///
func submit(
scenicId: Int?,
userId: Int?,
api: any ProjectServing,
uploadService: any OSSUploadServing
) async -> Bool {
guard !submitting else { return false }
guard let scenicId else { return fail(.missingScenic) }
guard let userId, userId > 0 else { return fail(.missingUser) }
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedDescription = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedName.isEmpty else { return fail(.missingName) }
guard !normalizedDescription.isEmpty else { return fail(.missingDescription) }
guard Double(price) ?? 0 > 0 else { return fail(.missingPrice) }
guard !selectedSpotIds.isEmpty else { return fail(.missingSpot) }
submitting = true
errorMessage = nil
uploadProgress = 0
defer { submitting = false }
do {
let coverURL = try await resolveCoverURL(scenicId: scenicId, uploadService: uploadService)
guard !coverURL.isEmpty else { throw ProjectEditorError.missingImage }
let carouselURLs = try await resolveCarouselURLs(scenicId: scenicId, uploadService: uploadService)
let request = ProjectCreateRequest(
id: editID,
type: 11,
scenicId: scenicId,
name: normalizedName,
price: normalizedMoney(price) ?? "0.00",
otPrice: normalizedMoney(otPrice),
coverProject: coverURL,
coverCarousel: carouselURLs.isEmpty ? nil : carouselURLs,
description: normalizedDescription,
attrLabel: normalizedLabels,
extra: ProjectCreateExtra(
priceDeposit: normalizedMoney(deposit) ?? "0.00",
materialNum: max(Int(materialNum) ?? 0, 0),
photoNum: max(Int(photoNum) ?? 0, 0),
videoNum: max(Int(videoNum) ?? 0, 0),
scenicSpotId: selectedSpotIds.sorted(),
photogUid: [userId]
)
)
if editID == nil {
try await api.createProject(request)
} else {
try await api.editProject(request)
}
uploadProgress = 100
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
private var editID: Int? {
if case let .edit(detail) = mode {
return detail.id
}
return nil
}
private var normalizedLabels: [String]? {
let labels = attrLabelText
.components(separatedBy: CharacterSet(charactersIn: ","))
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
return labels.isEmpty ? nil : labels
}
private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String {
if let coverImage {
return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { [weak self] progress in
self?.uploadProgress = progress
}
}
return existingCoverURL
}
private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
var urls = existingCarouselURLs
for image in carouselImages {
let url = try await uploadService.uploadProjectImage(data: image.data, fileName: image.fileName, scenicId: scenicId) { [weak self] progress in
self?.uploadProgress = progress
}
urls.append(url)
}
return urls
}
private func normalizedMoney(_ value: String) -> String? {
let amount = Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
return amount > 0 ? String(format: "%.2f", amount) : nil
}
private func fail(_ error: ProjectEditorError) -> Bool {
errorMessage = error.localizedDescription
return false
}
}
/// ViewModel
@MainActor
final class StoreProjectManagementViewModel: ObservableObject {
@Published private(set) var items: [PhotographerProjectItem] = []
@Published private(set) var loading = false
@Published private(set) var selectedDetail: PhotographerProjectDetailResponse?
@Published var searchText = ""
@Published var selectedType = 0
@Published var errorMessage: String?
///
var filteredItems: [PhotographerProjectItem] {
let keyword = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
return items.filter { item in
let typeMatched = selectedType == 0 || item.type == selectedType
let keywordMatched = keyword.isEmpty || item.name.localizedCaseInsensitiveContains(keyword)
return typeMatched && keywordMatched
}
}
///
func reload(api: any ProjectServing, userId: String?) async {
let normalizedUserId = userId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !normalizedUserId.isEmpty else {
items = []
return
}
loading = true
errorMessage = nil
defer { loading = false }
do {
let response = try await api.storeManagerProjectList(userId: normalizedUserId, page: 1, pageSize: 100)
items = response.list
} catch {
items = []
errorMessage = error.localizedDescription
}
}
///
func loadDetail(id: Int, api: any ProjectServing) async {
do {
selectedDetail = try await api.storeManagerProjectDetail(id: id)
} catch {
errorMessage = error.localizedDescription
}
}
///
func delete(id: Int, api: any ProjectServing) async {
do {
try await api.storeManagerDeleteProject(id: id)
items.removeAll { $0.id == id }
} catch {
errorMessage = error.localizedDescription
}
}
}
/// ViewModel
@MainActor
final class StoreProjectEditorViewModel: ObservableObject {
@Published var projectType = 19
@Published var scenicList: [StoreManagerScenicItem] = []
@Published var storeList: [StoreItem] = []
@Published var selectedScenicIds: Set<Int> = []
@Published var selectedStoreId: Int?
@Published var name = ""
@Published var descriptionText = ""
@Published var coverImage: ProjectLocalImage?
@Published var carouselImages: [ProjectLocalImage] = []
@Published var existingCoverURL = ""
@Published var existingCarouselURLs: [String] = []
@Published var settleSpotNum = "1"
@Published var projectPrice = ""
@Published var priceMaterial = ""
@Published var pricePhoto = ""
@Published var priceVideo = ""
@Published var priceDeposit = ""
@Published var materialNum = "1"
@Published var photoNum = "1"
@Published var videoNum = "1"
@Published private(set) var submitting = false
@Published var errorMessage: String?
private var mode: StoreProjectEditorMode
///
init(mode: StoreProjectEditorMode) {
self.mode = mode
if case let .edit(detail) = mode {
apply(detail)
}
}
/// ObservableObject
func apply(_ detail: PhotographerProjectDetailResponse) {
mode = .edit(detail)
projectType = detail.type == 4 ? 4 : 19
name = detail.name
descriptionText = detail.description
existingCoverURL = detail.coverProject
existingCarouselURLs = detail.coverCarousel
selectedScenicIds = Set(detail.scenicList.map(\.id))
projectPrice = detail.price
priceDeposit = detail.priceDeposit
materialNum = "\(max(detail.materialNum, 0))"
photoNum = "\(max(detail.photoNum, 0))"
videoNum = "\(max(detail.videoNum, 0))"
coverImage = nil
carouselImages = []
}
///
func loadScenicList(api: any ProjectServing, userId: Int) async {
guard scenicList.isEmpty, userId > 0 else { return }
do {
scenicList = try await api.storeManagerScenicList(userId: "\(userId)").list
} catch {
errorMessage = error.localizedDescription
}
}
///
func loadStoreList(api: any ProjectServing) async {
guard projectType == 4, let scenicId = selectedScenicIds.first else { return }
do {
storeList = try await api.storeAll().list.filter { $0.scenicId == scenicId }
if selectedStoreId == nil {
selectedStoreId = storeList.first?.id
}
} catch {
errorMessage = error.localizedDescription
}
}
///
func toggleScenic(_ id: Int) {
if projectType == 4 {
selectedScenicIds = [id]
} else if selectedScenicIds.contains(id) {
selectedScenicIds.remove(id)
} else {
selectedScenicIds.insert(id)
}
}
///
func submit(userId: Int, api: any ProjectServing, uploadService: any OSSUploadServing) async -> Bool {
guard !submitting else { return false }
guard userId > 0 else { return fail(.missingUser) }
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let normalizedDescription = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedName.isEmpty else { return fail(.missingName) }
guard !normalizedDescription.isEmpty else { return fail(.missingDescription) }
guard !selectedScenicIds.isEmpty else { return fail(.missingScenic) }
guard Double(projectType == 4 ? priceDeposit : projectPrice) ?? 0 > 0 else { return fail(.missingPrice) }
if projectType == 4, selectedStoreId == nil { return fail(.missingStore) }
submitting = true
errorMessage = nil
defer { submitting = false }
do {
let scenicIdForUpload = selectedScenicIds.first ?? 0
let coverURL = try await resolveCoverURL(scenicId: scenicIdForUpload, uploadService: uploadService)
guard !coverURL.isEmpty else { throw ProjectEditorError.missingImage }
let carouselURLs = try await resolveCarouselURLs(scenicId: scenicIdForUpload, uploadService: uploadService)
if projectType == 4 {
try await submitOfflineProject(
api: api,
name: normalizedName,
description: normalizedDescription,
coverURL: coverURL,
carouselURLs: carouselURLs
)
} else {
try await submitMultiPointProject(
userId: userId,
api: api,
name: normalizedName,
description: normalizedDescription,
coverURL: coverURL,
carouselURLs: carouselURLs
)
}
return true
} catch {
errorMessage = error.localizedDescription
return false
}
}
private var editID: Int? {
if case let .edit(detail) = mode {
return detail.id
}
return nil
}
private func resolveCoverURL(scenicId: Int, uploadService: any OSSUploadServing) async throws -> String {
if let coverImage {
return try await uploadService.uploadProjectImage(data: coverImage.data, fileName: coverImage.fileName, scenicId: scenicId) { _ in }
}
return existingCoverURL
}
private func resolveCarouselURLs(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] {
var urls = existingCarouselURLs
for image in carouselImages {
let url = try await uploadService.uploadProjectImage(data: image.data, fileName: image.fileName, scenicId: scenicId) { _ in }
urls.append(url)
}
return urls
}
private func submitMultiPointProject(
userId: Int,
api: any ProjectServing,
name: String,
description: String,
coverURL: String,
carouselURLs: [String]
) async throws {
if let editID {
try await api.storeManagerUpdate(
StoreManagerUpdateRequest(
id: editID,
name: name,
type: 19,
description: description,
coverProject: coverURL,
coverCarousel: carouselURLs,
projectRule: nil,
scenicId: selectedScenicIds.sorted(),
settleSpotNum: max(Int(settleSpotNum) ?? 1, 1),
price: Double(projectPrice) ?? 0,
priceMaterial: Double(priceMaterial) ?? 0,
pricePhoto: Double(pricePhoto) ?? 0,
priceVideo: Double(priceVideo) ?? 0,
priceMaterialAll: nil,
packageList: nil,
userId: userId,
singleSpotMaterialNum: max(Int(materialNum) ?? 0, 0),
singleSpotPhotoNum: max(Int(photoNum) ?? 0, 0),
singleSpotVideoNum: max(Int(videoNum) ?? 0, 0)
)
)
} else {
try await api.storeManagerCreate(
StoreManagerCreateRequest(
name: name,
storeId: nil,
type: 19,
description: description,
coverProject: coverURL,
coverCarousel: carouselURLs,
projectRule: nil,
scenicId: selectedScenicIds.sorted(),
settleSpotNum: max(Int(settleSpotNum) ?? 1, 1),
price: Double(projectPrice) ?? 0,
priceMaterial: Double(priceMaterial) ?? 0,
pricePhoto: Double(pricePhoto) ?? 0,
priceVideo: Double(priceVideo) ?? 0,
priceMaterialAll: nil,
packageList: nil,
userId: userId,
singleSpotMaterialNum: max(Int(materialNum) ?? 0, 0),
singleSpotPhotoNum: max(Int(photoNum) ?? 0, 0),
singleSpotVideoNum: max(Int(videoNum) ?? 0, 0)
)
)
}
}
private func submitOfflineProject(api: any ProjectServing, name: String, description: String, coverURL: String, carouselURLs: [String]) async throws {
let scenicId = selectedScenicIds.first ?? 0
let storeId = selectedStoreId ?? 0
if let editID {
try await api.storeManagerOfflineUpdate(
StoreManagerOfflineUpdateRequest(
id: editID,
name: name,
scenicId: scenicId,
description: description,
price: Double(priceDeposit) ?? 0,
coverProject: coverURL,
coverCarousel: carouselURLs,
storeId: storeId
)
)
} else {
try await api.storeManagerOfflineCreate(
StoreManagerOfflineCreateRequest(
name: name,
scenicId: scenicId,
storeId: storeId,
description: description,
price: Double(priceDeposit) ?? 0,
coverProject: coverURL,
coverCarousel: carouselURLs
)
)
}
}
private func fail(_ error: ProjectEditorError) -> Bool {
errorMessage = error.localizedDescription
return false
}
}