Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,624 @@
//
// ProjectViewModels.swift
// suixinkan
//
// Created by Codex on 2026/6/24.
//
import Foundation
///
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 {
var onChange: (() -> Void)?
private(set) var items: [PhotographerProjectItem] = [] { didSet { onChange?() } }
private(set) var loading = false { didSet { onChange?() } }
private(set) var loadingMore = false { didSet { onChange?() } }
private(set) var hasMore = false { didSet { onChange?() } }
private(set) var selectedDetail: PhotographerProjectDetailResponse? { didSet { onChange?() } }
var searchText = "" { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
private var page = 1
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 {
var onChange: (() -> Void)?
var name = "" { didSet { onChange?() } }
var descriptionText = "" { didSet { onChange?() } }
var price = "" { didSet { onChange?() } }
var otPrice = "" { didSet { onChange?() } }
var deposit = "" { didSet { onChange?() } }
var attrLabelText = "" { didSet { onChange?() } }
var materialNum = "1" { didSet { onChange?() } }
var photoNum = "1" { didSet { onChange?() } }
var videoNum = "1" { didSet { onChange?() } }
var selectedSpotIds: Set<Int> = [] { didSet { onChange?() } }
var coverImage: ProjectLocalImage? { didSet { onChange?() } }
var carouselImages: [ProjectLocalImage] = [] { didSet { onChange?() } }
var existingCoverURL = "" { didSet { onChange?() } }
var existingCarouselURLs: [String] = [] { didSet { onChange?() } }
private(set) var submitting = false { didSet { onChange?() } }
private(set) var uploadProgress = 0 { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
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 {
var onChange: (() -> Void)?
private(set) var items: [PhotographerProjectItem] = [] { didSet { onChange?() } }
private(set) var loading = false { didSet { onChange?() } }
private(set) var selectedDetail: PhotographerProjectDetailResponse? { didSet { onChange?() } }
var searchText = "" { didSet { onChange?() } }
var selectedType = 0 { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
///
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 {
var onChange: (() -> Void)?
var projectType = 19 { didSet { onChange?() } }
var scenicList: [StoreManagerScenicItem] = [] { didSet { onChange?() } }
var storeList: [StoreItem] = [] { didSet { onChange?() } }
var selectedScenicIds: Set<Int> = [] { didSet { onChange?() } }
var selectedStoreId: Int? { didSet { onChange?() } }
var name = "" { didSet { onChange?() } }
var descriptionText = "" { didSet { onChange?() } }
var coverImage: ProjectLocalImage? { didSet { onChange?() } }
var carouselImages: [ProjectLocalImage] = [] { didSet { onChange?() } }
var existingCoverURL = "" { didSet { onChange?() } }
var existingCarouselURLs: [String] = [] { didSet { onChange?() } }
var settleSpotNum = "1" { didSet { onChange?() } }
var projectPrice = "" { didSet { onChange?() } }
var priceMaterial = "" { didSet { onChange?() } }
var pricePhoto = "" { didSet { onChange?() } }
var priceVideo = "" { didSet { onChange?() } }
var priceDeposit = "" { didSet { onChange?() } }
var materialNum = "1" { didSet { onChange?() } }
var photoNum = "1" { didSet { onChange?() } }
var videoNum = "1" { didSet { onChange?() } }
private(set) var submitting = false { didSet { onChange?() } }
var errorMessage: String? { didSet { onChange?() } }
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
}
}