// // 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 = [] { 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 = [] { 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 } }