From 187ba990d45b436823fa5efa5c2afa9e47bd63a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 7 Jul 2026 11:09:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=99=AF=E5=8C=BA=E5=85=A5?= =?UTF-8?q?=E9=A9=BB=E7=94=B3=E8=AF=B7=E6=B5=81=E7=A8=8B=E5=B9=B6=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20Android?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- suixinkan/Core/Upload/OSSUploadService.swift | 19 + .../CooperationAcquirerViewModel.swift | 1 - suixinkan/Features/Home/API/HomeAPI.swift | 18 + .../ViewModels/PermissionApplyViewModel.swift | 16 +- .../ViewModels/ScenicSelectionViewModel.swift | 5 +- .../ViewModels/OrderListViewModel.swift | 2 +- .../Models/ScenicApplicationModels.swift | 199 +++++ .../ScenicApplicationViewModel.swift | 444 ++++++++++++ .../CooperationAcquirerViewController.swift | 18 +- .../CooperationOrderListViewController.swift | 55 +- .../PermissionApplyStatusViewController.swift | 2 +- .../Home/PermissionApplyViewController.swift | 39 +- .../Home/ScenicSelectionViewController.swift | 4 +- .../UI/Home/Views/PermissionApplyViews.swift | 53 +- .../UI/Home/Views/ScenicSelectionViews.swift | 18 +- .../OrderSourceLeadListViewController.swift | 189 +++++ .../OrderSourcePickerViewController.swift | 199 ++--- .../UI/Orders/OrdersViewController.swift | 6 +- .../ScenicApplicationViewController.swift | 338 +++++++++ .../Views/ScenicApplicationViews.swift | 684 ++++++++++++++++++ .../PermissionApplyViewModelTests.swift | 2 +- .../ScenicApplicationViewModelTests.swift | 107 +++ 22 files changed, 2202 insertions(+), 216 deletions(-) create mode 100644 suixinkan/Features/ScenicApplication/Models/ScenicApplicationModels.swift create mode 100644 suixinkan/Features/ScenicApplication/ViewModels/ScenicApplicationViewModel.swift create mode 100644 suixinkan/UI/Orders/OrderSourceLeadListViewController.swift create mode 100644 suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift create mode 100644 suixinkan/UI/ScenicApplication/Views/ScenicApplicationViews.swift create mode 100644 suixinkanTests/ScenicApplicationViewModelTests.swift diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index a8fe147..7f55e6f 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -39,6 +39,23 @@ final class OSSUploadService { try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress) } + /// 上传景区申请图片,对齐 Android `moduleType = scenic_apply`。 + func uploadScenicApplyImage( + data: Data, + fileName: String, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + try await uploadFile( + data: data, + fileName: fileName, + fileType: 2, + scenicId: scenicId, + moduleType: "scenic_apply", + onProgress: onProgress + ) + } + /// 上传任务素材,对齐 Android `moduleType = task_upload`。 func uploadTaskFile( data: Data, @@ -145,6 +162,8 @@ enum OSSUploadPolicy { return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "task_upload": return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "scenic_apply": + return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" default: return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" } diff --git a/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift b/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift index 8578246..a11a6e9 100644 --- a/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift +++ b/suixinkan/Features/CooperationOrder/ViewModels/CooperationAcquirerViewModel.swift @@ -33,7 +33,6 @@ final class CooperationAcquirerViewModel { } func loadAcquirers(api: OrderAPI, initial: Bool = false) async { - if initial { isRefreshing = false } let storeId = appStore.currentStoreId let storeIdParam = storeId > 0 ? String(storeId) : nil do { diff --git a/suixinkan/Features/Home/API/HomeAPI.swift b/suixinkan/Features/Home/API/HomeAPI.swift index 2ca625e..760461f 100644 --- a/suixinkan/Features/Home/API/HomeAPI.swift +++ b/suixinkan/Features/Home/API/HomeAPI.swift @@ -88,4 +88,22 @@ final class HomeAPI { ) ) } + + /// 拉取景区申请待审核列表。 + func scenicApplicationPending() async throws -> ScenicApplicationPendingsResponse { + try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/scenic-apply/all") + ) + } + + /// 提交景区申请。 + func submitScenicApplication(_ request: ScenicApplicationSubmitRequest) async throws { + let _: EmptyPayload = try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/scenic-apply/submit", + body: request + ) + ) + } } diff --git a/suixinkan/Features/Home/ViewModels/PermissionApplyViewModel.swift b/suixinkan/Features/Home/ViewModels/PermissionApplyViewModel.swift index 1ce859a..14e9ad2 100644 --- a/suixinkan/Features/Home/ViewModels/PermissionApplyViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/PermissionApplyViewModel.swift @@ -163,9 +163,23 @@ final class PermissionApplyViewModel { .flatMap(\.scenic) } + func clearRoleSelection() { + selectedRoleId = nil + selectedRoleName = "" + selectedScenicIds = [] + selectedScenicNames = [] + availableScenics = [] + availableRoles = availableRoles.map { role in + var copy = role + copy.isSelected = false + return copy + } + notifyStateChange() + } + func submit(api: HomeAPI) async { guard let roleId = selectedRoleId else { - onShowMessage?("请先选择角色") + onShowMessage?("请选择角色") return } let newScenicIds = selectedScenicIds.filter { id in diff --git a/suixinkan/Features/Home/ViewModels/ScenicSelectionViewModel.swift b/suixinkan/Features/Home/ViewModels/ScenicSelectionViewModel.swift index 69cb4dd..e29505a 100644 --- a/suixinkan/Features/Home/ViewModels/ScenicSelectionViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/ScenicSelectionViewModel.swift @@ -17,6 +17,7 @@ final class ScenicSelectionViewModel { private(set) var spots: [ScenicSpotDisplayItem] = [] private(set) var searchQuery = "" private(set) var currentLocationText = "最近的景区" + private(set) var isLocating = false private(set) var isLoading = false var onStateChange: (() -> Void)? @@ -53,7 +54,7 @@ final class ScenicSelectionViewModel { } func startLocation() async { - isLoading = true + isLocating = true notifyStateChange() do { let snapshot = try await locationProvider.requestSnapshot() @@ -67,7 +68,7 @@ final class ScenicSelectionViewModel { } catch { onShowMessage?("定位失败: \(error.localizedDescription)") } - isLoading = false + isLocating = false notifyStateChange() } diff --git a/suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift b/suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift index 3b57199..6d3a0ac 100644 --- a/suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift +++ b/suixinkan/Features/Orders/ViewModels/OrderListViewModel.swift @@ -373,7 +373,7 @@ final class OrderListViewModel { } func loadOrderSourceLeads(person: OrderSourcePerson, api: OrderAPI) async { - guard loadingOrderSourceLeadsFor == nil else { return } + guard loadingOrderSourceLeadsFor != person.key else { return } loadingOrderSourceLeadsFor = person.key notifyStateChange() defer { diff --git a/suixinkan/Features/ScenicApplication/Models/ScenicApplicationModels.swift b/suixinkan/Features/ScenicApplication/Models/ScenicApplicationModels.swift new file mode 100644 index 0000000..4a75e60 --- /dev/null +++ b/suixinkan/Features/ScenicApplication/Models/ScenicApplicationModels.swift @@ -0,0 +1,199 @@ +// +// ScenicApplicationModels.swift +// suixinkan +// + +import Foundation + +/// 合作类型,对齐 Android `CooperationType`。 +enum ScenicCooperationType: Int, CaseIterable, Equatable { + case photographer = 1 + case resourceProvider = 2 + case teamLeader = 3 + case photoShop = 4 + case efficiencyImprovement = 5 + + var displayName: String { + switch self { + case .photographer: + "我是摄影师,我想入驻" + case .resourceProvider: + "我是景区资源方,可以合作" + case .teamLeader: + "我有摄影师团队,带队入住" + case .photoShop: + "我是旅拍店,想合作" + case .efficiencyImprovement: + "我已经在景区运营旅拍了,想使用软件,提高效率" + } + } + + static func from(rawValue: Int) -> ScenicCooperationType { + ScenicCooperationType(rawValue: rawValue) ?? .photographer + } +} + +/// 景区申请待审核记录。 +struct ScenicApplicationPendingResponse: Decodable, Equatable { + let id: Int + let code: String + let staffId: Int + let scenicId: Int + let scenicName: String + let scenicImages: String + let scenicProvince: String + let scenicCity: String + let coopType: Int + let remark: String? + let status: Int + let rejectReason: String? + let auditedBy: String? + let auditedAt: String? + let auditNote: String? + + enum CodingKeys: String, CodingKey { + case id + case code + case staffId = "staff_id" + case scenicId = "scenic_id" + case scenicName = "scenic_name" + case scenicImages = "scenic_images" + case scenicProvince = "scenic_province" + case scenicCity = "scenic_city" + case coopType = "coop_type" + case remark + case status + case rejectReason = "reject_reason" + case auditedBy = "audited_by" + case auditedAt = "audited_at" + case auditNote = "audit_note" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(Int.self, forKey: .id) ?? 0 + code = try container.decodeIfPresent(String.self, forKey: .code) ?? "" + staffId = try container.decodeIfPresent(Int.self, forKey: .staffId) ?? 0 + scenicId = try container.decodeIfPresent(Int.self, forKey: .scenicId) ?? 0 + scenicName = try container.decodeIfPresent(String.self, forKey: .scenicName) ?? "" + scenicImages = try container.decodeIfPresent(String.self, forKey: .scenicImages) ?? "" + scenicProvince = try container.decodeIfPresent(String.self, forKey: .scenicProvince) ?? "" + scenicCity = try container.decodeIfPresent(String.self, forKey: .scenicCity) ?? "" + coopType = try container.decodeIfPresent(Int.self, forKey: .coopType) ?? 0 + remark = try container.decodeIfPresent(String.self, forKey: .remark) + status = try container.decodeIfPresent(Int.self, forKey: .status) ?? 0 + rejectReason = try container.decodeIfPresent(String.self, forKey: .rejectReason) + auditedBy = try container.decodeIfPresent(String.self, forKey: .auditedBy) + auditedAt = try container.decodeIfPresent(String.self, forKey: .auditedAt) + auditNote = try container.decodeIfPresent(String.self, forKey: .auditNote) + } + + init( + id: Int = 0, + code: String = "", + staffId: Int = 0, + scenicId: Int = 0, + scenicName: String = "", + scenicImages: String = "", + scenicProvince: String = "", + scenicCity: String = "", + coopType: Int = 0, + remark: String? = nil, + status: Int = 0, + rejectReason: String? = nil, + auditedBy: String? = nil, + auditedAt: String? = nil, + auditNote: String? = nil + ) { + self.id = id + self.code = code + self.staffId = staffId + self.scenicId = scenicId + self.scenicName = scenicName + self.scenicImages = scenicImages + self.scenicProvince = scenicProvince + self.scenicCity = scenicCity + self.coopType = coopType + self.remark = remark + self.status = status + self.rejectReason = rejectReason + self.auditedBy = auditedBy + self.auditedAt = auditedAt + self.auditNote = auditNote + } +} + +/// 景区申请待审核列表响应。 +struct ScenicApplicationPendingsResponse: Decodable, Equatable { + let items: [ScenicApplicationPendingResponse] + + init(items: [ScenicApplicationPendingResponse] = []) { + self.items = items + } +} + +/// 景区申请提交请求。 +struct ScenicApplicationSubmitRequest: Encodable { + let scenicName: String? + let scenicImages: [String]? + let scenicProvince: String + let scenicCity: String + let coopType: Int + let remark: String + let scenicId: Int + + enum CodingKeys: String, CodingKey { + case scenicName = "scenic_name" + case scenicImages = "scenic_images" + case scenicProvince = "scenic_province" + case scenicCity = "scenic_city" + case coopType = "coop_type" + case remark + case scenicId = "scenic_id" + } +} + +/// 单张景区申请图片的上传状态。 +struct ScenicApplicationImageItem: Equatable, Identifiable { + let id: UUID + var localData: Data? + var fileName: String + var remoteURL: String + var isUploading: Bool + var uploadProgress: Int + var errorMessage: String? + + var isUploaded: Bool { !remoteURL.isEmpty } + + static func fromRemoteURL(_ url: String) -> ScenicApplicationImageItem { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + let name = URL(string: trimmed)?.lastPathComponent ?? "image.jpg" + return ScenicApplicationImageItem( + id: UUID(), + localData: nil, + fileName: name, + remoteURL: trimmed, + isUploading: false, + uploadProgress: 100, + errorMessage: nil + ) + } + + static func fromLocal(data: Data, fileName: String) -> ScenicApplicationImageItem { + ScenicApplicationImageItem( + id: UUID(), + localData: data, + fileName: fileName, + remoteURL: "", + isUploading: false, + uploadProgress: 0, + errorMessage: nil + ) + } +} + +/// 图片上传进度弹窗状态。 +struct ScenicApplicationUploadDialogState: Equatable { + let title: String + let progress: Int +} diff --git a/suixinkan/Features/ScenicApplication/ViewModels/ScenicApplicationViewModel.swift b/suixinkan/Features/ScenicApplication/ViewModels/ScenicApplicationViewModel.swift new file mode 100644 index 0000000..c85ebce --- /dev/null +++ b/suixinkan/Features/ScenicApplication/ViewModels/ScenicApplicationViewModel.swift @@ -0,0 +1,444 @@ +// +// ScenicApplicationViewModel.swift +// suixinkan +// + +import Foundation + +/// 景区申请图片上传协议,便于单元测试注入 mock。 +@MainActor +protocol ScenicApplicationImageUploading: AnyObject { + func uploadScenicApplyImage( + data: Data, + fileName: String, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String +} + +extension OSSUploadService: ScenicApplicationImageUploading {} + +/// 景区申请 ViewModel,对齐 Android `ScenicApplicationViewModel`。 +final class ScenicApplicationViewModel { + + private struct UploadBatch { + let id: UUID + var indices: [Int] + var position: Int = 0 + var totalCount: Int + var completedCount: Int = 0 + } + + private(set) var scenicName = "" + private(set) var selectedImages: [ScenicApplicationImageItem] = [] + private(set) var selectedProvince = "" + private(set) var selectedCity = "" + private(set) var selectedCooperationType: ScenicCooperationType = .photographer + private(set) var remarks = "" + private(set) var isAgreed = false + private(set) var isSubmitting = false + private(set) var provinceList: [AreasResponse] = [] + private(set) var cityList: [AreasResponse] = [] + private(set) var pendingApplication: ScenicApplicationPendingResponse? + private(set) var uploadDialogState: ScenicApplicationUploadDialogState? + + var onStateChange: (() -> Void)? + var onShowMessage: ((String) -> Void)? + var onSubmitSuccess: (() -> Void)? + + private var uploadBatchQueue: [UploadBatch] = [] + private var currentBatch: UploadBatch? + private var uploadingIndex: Int? + private var isProcessingUploadQueue = false + private var lastProgressUpdateTime: TimeInterval = 0 + private let progressUpdateInterval: TimeInterval = 0.2 + + /// 是否满足提交条件,对齐 Android `ScenicApplicationUiState.canSubmit`。 + var canSubmit: Bool { + !selectedProvince.isEmpty + && !selectedCity.isEmpty + && !remarks.isEmpty + && remarks.count <= 50 + && isAgreed + && !isSubmitting + } + + /// status == 1 时为只读。 + var isReadOnly: Bool { + pendingApplication?.status == 1 + } + + /// 是否可编辑表单。 + var isEditable: Bool { + guard let pending = pendingApplication else { return true } + return pending.status == 2 || pending.status == 3 || pending.status == 9 + } + + /// 是否展示审核状态卡片(status 1/3/9)。 + var shouldShowAuditCard: Bool { + guard let pending = pendingApplication else { return false } + return pending.status == 1 || pending.status == 3 || pending.status == 9 + } + + /// 初始化加载省市与待审核数据。 + func loadInitial(profileAPI: ProfileAPI, homeAPI: HomeAPI) async { + async let areasTask: Void = loadAreas(profileAPI: profileAPI) + async let pendingTask: Void = loadPendingApplication(homeAPI: homeAPI) + _ = await (areasTask, pendingTask) + } + + func loadAreas(profileAPI: ProfileAPI) async { + do { + provinceList = try await profileAPI.areas() + notifyStateChange() + } catch { + onShowMessage?("获取省市数据失败: \(error.localizedDescription)") + } + } + + func loadPendingApplication(homeAPI: HomeAPI) async { + do { + let response = try await homeAPI.scenicApplicationPending() + guard let pending = response.items.first else { + pendingApplication = nil + notifyStateChange() + return + } + if pending.status == 1 || pending.status == 3 { + pendingApplication = pending + await waitForProvinceListIfNeeded() + initWithPendingApplication(pending) + } else { + pendingApplication = nil + } + notifyStateChange() + } catch { + pendingApplication = nil + notifyStateChange() + } + } + + func onScenicNameChange(_ name: String) { + scenicName = name + notifyStateChange() + } + + func onProvinceSelected(_ provinceName: String) { + let province = provinceList.first { $0.name == provinceName } + selectedProvince = provinceName + selectedCity = "" + cityList = province?.children ?? [] + notifyStateChange() + } + + func onCitySelected(_ cityName: String) { + selectedCity = cityName + notifyStateChange() + } + + func onCooperationTypeSelected(_ type: ScenicCooperationType) { + selectedCooperationType = type + notifyStateChange() + } + + func onRemarksChange(_ text: String) { + if text.count <= 50 { + remarks = text + notifyStateChange() + } else { + onShowMessage?("备注信息最多50字") + } + } + + func toggleAgreement() { + isAgreed.toggle() + notifyStateChange() + } + + /// 添加本地图片并排队上传。 + func addLocalImages(_ items: [ScenicApplicationImageItem]) { + guard !items.isEmpty else { return } + let startIndex = selectedImages.count + selectedImages.append(contentsOf: items) + let indices = (0..= 0, index < selectedImages.count else { return } + selectedImages.remove(at: index) + adjustBatchAfterDeletion(deletedIndex: index) + if uploadingIndex == index { + uploadingIndex = nil + uploadDialogState = nil + } else if let uploadingIndex, index < uploadingIndex { + self.uploadingIndex = uploadingIndex - 1 + } + notifyStateChange() + } + + func retryUpload(at index: Int) { + guard index >= 0, index < selectedImages.count else { return } + selectedImages[index].errorMessage = nil + uploadBatchQueue.insert( + UploadBatch(id: UUID(), indices: [index], totalCount: 1), + at: 0 + ) + notifyStateChange() + } + + /// 顺序处理待上传图片队列。 + func processPendingUploads(uploader: ScenicApplicationImageUploading, scenicId: Int) async { + guard !isProcessingUploadQueue else { return } + isProcessingUploadQueue = true + defer { isProcessingUploadQueue = false } + + while true { + if currentBatch == nil || (currentBatch?.position ?? 0) >= (currentBatch?.indices.count ?? 0) { + currentBatch = uploadBatchQueue.isEmpty ? nil : uploadBatchQueue.removeFirst() + } + guard var batch = currentBatch else { + uploadDialogState = nil + notifyStateChange() + return + } + + while batch.position < batch.indices.count { + let index = batch.indices[batch.position] + guard index < selectedImages.count else { + batch.indices.remove(at: batch.position) + batch.totalCount = max(batch.completedCount, batch.totalCount - 1) + continue + } + + let image = selectedImages[index] + if image.isUploaded { + batch.position += 1 + batch.completedCount = min(batch.totalCount, batch.completedCount + 1) + continue + } + if image.errorMessage != nil { + batch.position += 1 + continue + } + guard let data = image.localData else { + batch.position += 1 + continue + } + + currentBatch = batch + uploadingIndex = index + updateImage(at: index) { item in + var copy = item + copy.isUploading = true + copy.uploadProgress = 0 + copy.errorMessage = nil + return copy + } + let total = max(batch.totalCount, 1) + let currentPosition = min(batch.completedCount + 1, total) + uploadDialogState = ScenicApplicationUploadDialogState( + title: "正在上传(\(currentPosition)/\(total))", + progress: 0 + ) + notifyStateChange() + + do { + let url = try await uploader.uploadScenicApplyImage( + data: data, + fileName: image.fileName, + scenicId: scenicId, + onProgress: { [weak self] progress in + Task { @MainActor in + self?.handleUploadProgress( + index: index, + batch: batch, + currentPosition: currentPosition, + total: total, + progress: progress + ) + } + } + ) + updateImage(at: index) { item in + var copy = item + copy.isUploading = false + copy.uploadProgress = 100 + copy.remoteURL = url + copy.errorMessage = nil + return copy + } + batch.completedCount = min(batch.totalCount, batch.completedCount + 1) + } catch { + updateImage(at: index) { item in + var copy = item + copy.isUploading = false + copy.uploadProgress = 0 + copy.errorMessage = error.localizedDescription.isEmpty ? "上传失败" : error.localizedDescription + return copy + } + onShowMessage?("图片上传失败,请重试") + } + + batch.position += 1 + uploadingIndex = nil + currentBatch = batch + } + + currentBatch = nil + uploadDialogState = nil + notifyStateChange() + } + } + + func submit(homeAPI: HomeAPI) async { + guard canSubmit else { + onShowMessage?("请完善必填信息") + return + } + if selectedImages.contains(where: \.isUploading) { + onShowMessage?("图片正在上传,请稍后提交") + return + } + if selectedImages.contains(where: { !$0.isUploaded && $0.errorMessage == nil && $0.localData != nil }) { + onShowMessage?("请先完成图片上传") + return + } + + isSubmitting = true + notifyStateChange() + defer { + isSubmitting = false + notifyStateChange() + } + + let uploadedURLs = selectedImages.compactMap { item -> String? in + item.isUploaded ? item.remoteURL : nil + } + let request = ScenicApplicationSubmitRequest( + scenicName: scenicName.isEmpty ? nil : scenicName, + scenicImages: uploadedURLs.isEmpty ? nil : uploadedURLs, + scenicProvince: selectedProvince, + scenicCity: selectedCity, + coopType: selectedCooperationType.rawValue, + remark: remarks, + scenicId: 0 + ) + + do { + try await homeAPI.submitScenicApplication(request) + onShowMessage?("提交成功") + await loadPendingApplication(homeAPI: homeAPI) + onSubmitSuccess?() + } catch { + onShowMessage?("提交失败: \(error.localizedDescription)") + } + } + + private func initWithPendingApplication(_ pending: ScenicApplicationPendingResponse) { + let imageStates = pending.scenicImages + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map { ScenicApplicationImageItem.fromRemoteURL($0) } + + let province = provinceList.first { $0.name == pending.scenicProvince } + scenicName = pending.scenicName + selectedImages = imageStates + selectedProvince = pending.scenicProvince + selectedCity = pending.scenicCity + selectedCooperationType = ScenicCooperationType.from(rawValue: pending.coopType) + remarks = pending.remark ?? "" + cityList = province?.children ?? [] + } + + private func waitForProvinceListIfNeeded() async { + var waitCount = 0 + while provinceList.isEmpty && waitCount < 30 { + try? await Task.sleep(nanoseconds: 100_000_000) + waitCount += 1 + } + } + + private func handleUploadProgress( + index: Int, + batch: UploadBatch, + currentPosition: Int, + total: Int, + progress: Int + ) { + let now = Date().timeIntervalSince1970 + let shouldUpdate = now - lastProgressUpdateTime >= progressUpdateInterval + || progress == 0 + || progress == 100 + guard shouldUpdate else { return } + lastProgressUpdateTime = now + + updateImage(at: index) { item in + var copy = item + copy.uploadProgress = min(max(progress, 0), 100) + return copy + } + uploadDialogState = ScenicApplicationUploadDialogState( + title: "正在上传(\(currentPosition)/\(total))", + progress: min(max(progress, 0), 100) + ) + notifyStateChange() + } + + private func updateImage(at index: Int, transform: (ScenicApplicationImageItem) -> ScenicApplicationImageItem) { + guard index >= 0, index < selectedImages.count else { return } + selectedImages[index] = transform(selectedImages[index]) + } + + private func adjustBatchAfterDeletion(deletedIndex: Int) { + func adjust(_ batch: inout UploadBatch) { + var removedBefore = 0 + var index = 0 + while index < batch.indices.count { + let value = batch.indices[index] + if value == deletedIndex { + if index < batch.position { + removedBefore += 1 + } + batch.indices.remove(at: index) + if batch.totalCount > batch.completedCount { + batch.totalCount = max(batch.completedCount, batch.totalCount - 1) + } + } else if value > deletedIndex { + batch.indices[index] = value - 1 + index += 1 + } else { + index += 1 + } + } + batch.position = max(0, batch.position - removedBefore) + if batch.position > batch.indices.count { + batch.position = batch.indices.count + } + } + + if var batch = currentBatch { + adjust(&batch) + if batch.indices.isEmpty { + currentBatch = nil + uploadDialogState = nil + } else { + currentBatch = batch + } + } + + uploadBatchQueue = uploadBatchQueue.compactMap { batch in + var copy = batch + adjust(©) + return copy.indices.isEmpty ? nil : copy + } + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift b/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift index 1f3559f..2209f88 100644 --- a/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift +++ b/suixinkan/UI/CooperationOrder/CooperationAcquirerViewController.swift @@ -84,7 +84,11 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa override func viewDidLoad() { super.viewDidLoad() - Task { await viewModel.loadAcquirers(api: orderAPI, initial: true) } + Task { + showLoading() + defer { hideLoading() } + await viewModel.loadAcquirers(api: orderAPI, initial: true) + } } @objc private func startScanBind() { @@ -105,10 +109,7 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa } @objc private func refreshTriggered() { - Task { - await viewModel.refresh(api: orderAPI) - refreshControl.endRefreshing() - } + Task { await viewModel.refresh(api: orderAPI) } } private func presentScanner() { @@ -137,7 +138,12 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa } private func applyViewModel() { - emptyLabel.isHidden = !viewModel.acquirers.isEmpty + if viewModel.isRefreshing { + if !refreshControl.isRefreshing { refreshControl.beginRefreshing() } + } else { + refreshControl.endRefreshing() + } + emptyLabel.isHidden = !viewModel.acquirers.isEmpty || viewModel.isRefreshing tableView.reloadData() } diff --git a/suixinkan/UI/CooperationOrder/CooperationOrderListViewController.swift b/suixinkan/UI/CooperationOrder/CooperationOrderListViewController.swift index 9d3ae38..6b16936 100644 --- a/suixinkan/UI/CooperationOrder/CooperationOrderListViewController.swift +++ b/suixinkan/UI/CooperationOrder/CooperationOrderListViewController.swift @@ -16,6 +16,9 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD private let searchBar = CooperationOrderSearchBar() private let tableView = UITableView(frame: .zero, style: .plain) private let refreshControl = UIRefreshControl() + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private let footerSpinner = UIActivityIndicatorView(style: .medium) + private let footerLabel = UILabel() private let emptyLabel = UILabel() private var isApplyingTab = false @@ -42,6 +45,15 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD tableView.register(AcquisitionOrderCell.self, forCellReuseIdentifier: AcquisitionOrderCell.reuseIdentifier) tableView.refreshControl = refreshControl + loadingIndicator.hidesWhenStopped = true + loadingIndicator.color = AppColor.primary + footerSpinner.hidesWhenStopped = true + footerSpinner.color = AppColor.primary + footerLabel.font = .systemFont(ofSize: 14) + footerLabel.textColor = AppColor.textTertiary + footerLabel.textAlignment = .center + footerLabel.text = "没有更多" + emptyLabel.text = "暂无相关合作订单" emptyLabel.font = .systemFont(ofSize: 14) emptyLabel.textColor = AppColor.textTertiary @@ -51,6 +63,7 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD view.addSubview(tabBarView) view.addSubview(searchBar) view.addSubview(tableView) + view.addSubview(loadingIndicator) view.addSubview(emptyLabel) } @@ -67,6 +80,9 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD make.top.equalTo(searchBar.snp.bottom) make.leading.trailing.bottom.equalToSuperview() } + loadingIndicator.snp.makeConstraints { make in + make.center.equalTo(tableView) + } emptyLabel.snp.makeConstraints { make in make.center.equalTo(tableView) } @@ -114,10 +130,7 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD } @objc private func refreshTriggered() { - Task { - await viewModel.refreshCurrentTab(api: orderAPI) - refreshControl.endRefreshing() - } + Task { await viewModel.refreshCurrentTab(api: orderAPI) } } private func applyViewModel() { @@ -130,10 +143,42 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD searchBar.textField.text = viewModel.searchText let isEmpty = viewModel.selectedTab == 0 ? viewModel.leads.isEmpty : viewModel.orders.isEmpty - emptyLabel.isHidden = !isEmpty || viewModel.isRefreshing + let isLoading = viewModel.isRefreshing && isEmpty + + if viewModel.isRefreshing { + if !refreshControl.isRefreshing { refreshControl.beginRefreshing() } + } else { + refreshControl.endRefreshing() + } + + if isLoading { + loadingIndicator.startAnimating() + emptyLabel.isHidden = true + tableView.tableFooterView = nil + } else { + loadingIndicator.stopAnimating() + emptyLabel.isHidden = !isEmpty + tableView.tableFooterView = tableFooterView(isEmpty: isEmpty) + } tableView.reloadData() } + private func tableFooterView(isEmpty: Bool) -> UIView? { + guard !isEmpty else { return nil } + if viewModel.isLoadingMore { + let container = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 44)) + footerSpinner.center = CGPoint(x: container.bounds.midX, y: container.bounds.midY) + footerSpinner.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin] + container.addSubview(footerSpinner) + footerSpinner.startAnimating() + return container + } + footerSpinner.stopAnimating() + guard !viewModel.canLoadMore else { return nil } + footerLabel.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 44) + return footerLabel + } + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { viewModel.selectedTab == 0 ? viewModel.leads.count : viewModel.orders.count } diff --git a/suixinkan/UI/Home/PermissionApplyStatusViewController.swift b/suixinkan/UI/Home/PermissionApplyStatusViewController.swift index f39cb58..0936996 100644 --- a/suixinkan/UI/Home/PermissionApplyStatusViewController.swift +++ b/suixinkan/UI/Home/PermissionApplyStatusViewController.swift @@ -63,7 +63,7 @@ final class PermissionApplyStatusViewController: BaseViewController { Task { @MainActor in self?.navigateToEdit(pending: pending) } } bannerView.onApplyNewScenic = { [weak self] in - self?.showToast("功能开发中") + self?.navigationController?.pushViewController(ScenicApplicationViewController(), animated: true) } Task { await viewModel.loadPendingData(api: homeAPI) } } diff --git a/suixinkan/UI/Home/PermissionApplyViewController.swift b/suixinkan/UI/Home/PermissionApplyViewController.swift index a4dc6c9..94168e5 100644 --- a/suixinkan/UI/Home/PermissionApplyViewController.swift +++ b/suixinkan/UI/Home/PermissionApplyViewController.swift @@ -16,9 +16,19 @@ final class PermissionApplyViewController: BaseViewController { private let scrollView = UIScrollView() private let contentStack = UIStackView() private let bannerView = PermissionApplyBannerView() - private let roleSelector = PermissionFormSelectorRow(title: "角色信息", subtitle: "单选 · 请选择您需要申请权限的角色", placeholder: "请选择角色") - private let existingScenicTags = PermissionSelectedTagsView() - private let scenicSelector = PermissionFormSelectorRow(title: "新增景区", subtitle: "多选 · 请选择需要新增权限的景区", placeholder: "请选择景区") + private let roleSelector = PermissionFormSelectorRow( + title: "角色信息", + tag: "单选", + description: "请选择您需要申请权限的角色", + placeholder: "请选择角色" + ) + private let newRoleTags = PermissionSelectedTagsView() + private let scenicSelector = PermissionFormSelectorRow( + title: "选择景区", + tag: "可多选", + description: "请选择您需要申请权限的景区", + placeholder: "请选择景区" + ) private let selectedScenicTags = PermissionSelectedTagsView() private let submitButton = PermissionSubmitButton() @@ -49,7 +59,7 @@ final class PermissionApplyViewController: BaseViewController { let card = UIView() card.backgroundColor = .white card.layer.cornerRadius = 8 - let cardStack = UIStackView(arrangedSubviews: [roleSelector, existingScenicTags, scenicSelector, selectedScenicTags]) + let cardStack = UIStackView(arrangedSubviews: [roleSelector, newRoleTags, scenicSelector, selectedScenicTags]) cardStack.axis = .vertical cardStack.spacing = 20 card.addSubview(cardStack) @@ -89,7 +99,7 @@ final class PermissionApplyViewController: BaseViewController { } bannerView.onApplyNewScenic = { [weak self] in - self?.showToast("功能开发中") + self?.navigationController?.pushViewController(ScenicApplicationViewController(), animated: true) } roleSelector.onTap = { [weak self] in self?.presentRolePicker() } scenicSelector.onTap = { [weak self] in @@ -112,12 +122,11 @@ final class PermissionApplyViewController: BaseViewController { private func applyViewModel() { if viewModel.selectedRoleName.isEmpty { roleSelector.setValue("请选择角色", isPlaceholder: true) - existingScenicTags.isHidden = true + newRoleTags.isHidden = true } else { roleSelector.setValue(viewModel.selectedRoleName, isPlaceholder: false) - if let roleId = viewModel.selectedRoleId { - let names = viewModel.existingScenics(for: roleId).map(\.name) - existingScenicTags.apply(title: "已开通景区", names: names, onRemove: nil) + newRoleTags.apply(title: "新增角色", names: [viewModel.selectedRoleName]) { [weak self] _ in + self?.viewModel.clearRoleSelection() } } @@ -125,9 +134,15 @@ final class PermissionApplyViewController: BaseViewController { scenicSelector.setValue("请选择景区", isPlaceholder: true) selectedScenicTags.isHidden = true } else { - scenicSelector.setValue("已选 \(viewModel.selectedScenicNames.count) 个景区", isPlaceholder: false) + let displayText: String + if viewModel.selectedScenicNames.count == 1 { + displayText = viewModel.selectedScenicNames[0] + } else { + displayText = "已选中\(viewModel.selectedScenicNames.count)个景区" + } + scenicSelector.setValue(displayText, isPlaceholder: false) selectedScenicTags.apply( - title: "待申请景区", + title: "新增景区", names: viewModel.selectedScenicNames ) { [weak self] index in guard let self else { return } @@ -186,7 +201,7 @@ final class PermissionApplyViewController: BaseViewController { self?.viewModel.toggleScenic(scenic) }) } - alert.addAction(UIAlertAction(title: "完成", style: .cancel)) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) present(alert, animated: true) } diff --git a/suixinkan/UI/Home/ScenicSelectionViewController.swift b/suixinkan/UI/Home/ScenicSelectionViewController.swift index 6cc4c59..1c458aa 100644 --- a/suixinkan/UI/Home/ScenicSelectionViewController.swift +++ b/suixinkan/UI/Home/ScenicSelectionViewController.swift @@ -57,6 +57,8 @@ final class ScenicSelectionViewController: BaseViewController, UITableViewDataSo headerStack.spacing = 16 headerStack.isLayoutMarginsRelativeArrangement = true headerStack.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16) + headerStack.setContentHuggingPriority(.required, for: .vertical) + headerStack.setContentCompressionResistancePriority(.required, for: .vertical) view.addSubview(headerStack) view.addSubview(tableView) @@ -112,7 +114,7 @@ final class ScenicSelectionViewController: BaseViewController, UITableViewDataSo } private func applyViewModel() { - locationBar.apply(locationText: viewModel.currentLocationText) + locationBar.apply(locationText: viewModel.currentLocationText, isLocating: viewModel.isLocating) emptyLabel.isHidden = !viewModel.spots.isEmpty tableView.reloadData() if viewModel.isLoading { diff --git a/suixinkan/UI/Home/Views/PermissionApplyViews.swift b/suixinkan/UI/Home/Views/PermissionApplyViews.swift index bda37b8..8017af2 100644 --- a/suixinkan/UI/Home/Views/PermissionApplyViews.swift +++ b/suixinkan/UI/Home/Views/PermissionApplyViews.swift @@ -6,7 +6,7 @@ import SnapKit import UIKit -/// 权限申请页橙色横幅(新景区申请入口暂 toast)。 +/// 权限申请页橙色横幅(跳转景区申请)。 final class PermissionApplyBannerView: UIView { var onApplyNewScenic: (() -> Void)? @@ -18,11 +18,11 @@ final class PermissionApplyBannerView: UIView { super.init(frame: frame) backgroundColor = AppColor.warningBackground - titleLabel.text = "希望开通全新景区?" + titleLabel.text = "没有找到心仪的景区" titleLabel.font = .systemFont(ofSize: 14, weight: .medium) titleLabel.textColor = AppColor.warning - applyButton.setTitle("新景区申请 ›", for: .normal) + applyButton.setTitle("申请平台开通新景区", for: .normal) applyButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium) applyButton.setTitleColor(AppColor.warning, for: .normal) applyButton.addTarget(self, action: #selector(applyTapped), for: .touchUpInside) @@ -52,50 +52,63 @@ final class PermissionApplyBannerView: UIView { } } -/// 表单字段选择行。 +/// 表单字段选择行,对齐 Android `FormField` + Selector。 final class PermissionFormSelectorRow: UIView { var onTap: (() -> Void)? private let titleLabel = UILabel() - private let subtitleLabel = UILabel() + private let tagLabel = UILabel() + private let descriptionLabel = UILabel() private let valueLabel = UILabel() private let chevron = UIImageView(image: UIImage(systemName: "chevron.down")) - init(title: String, subtitle: String, placeholder: String) { + init(title: String, tag: String, description: String, placeholder: String) { super.init(frame: .zero) titleLabel.text = title titleLabel.font = .systemFont(ofSize: 16, weight: .medium) titleLabel.textColor = AppColor.textPrimary - subtitleLabel.text = subtitle - subtitleLabel.font = .systemFont(ofSize: 12) - subtitleLabel.textColor = AppColor.textTertiary + tagLabel.text = tag + tagLabel.font = .systemFont(ofSize: 12) + tagLabel.textColor = AppColor.textTertiary + tagLabel.textAlignment = .right + + descriptionLabel.text = description + descriptionLabel.font = .systemFont(ofSize: 12) + descriptionLabel.textColor = UIColor(hex: 0xB3B8C2) + descriptionLabel.numberOfLines = 0 valueLabel.text = placeholder valueLabel.font = .systemFont(ofSize: 14) - valueLabel.textColor = AppColor.textTertiary + valueLabel.textColor = UIColor(hex: 0xB3B8C2) - chevron.tintColor = AppColor.textTertiary + chevron.tintColor = UIColor(hex: 0xB3B8C2) chevron.contentMode = .scaleAspectFit let tap = UITapGestureRecognizer(target: self, action: #selector(rowTapped)) addGestureRecognizer(tap) addSubview(titleLabel) - addSubview(subtitleLabel) + addSubview(tagLabel) + addSubview(descriptionLabel) addSubview(valueLabel) addSubview(chevron) titleLabel.snp.makeConstraints { make in make.top.leading.equalToSuperview() } - subtitleLabel.snp.makeConstraints { make in + tagLabel.snp.makeConstraints { make in + make.centerY.equalTo(titleLabel) + make.trailing.equalToSuperview() + make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8) + } + descriptionLabel.snp.makeConstraints { make in make.top.equalTo(titleLabel.snp.bottom).offset(4) - make.leading.equalToSuperview() + make.leading.trailing.equalToSuperview() } valueLabel.snp.makeConstraints { make in - make.top.equalTo(subtitleLabel.snp.bottom).offset(12) + make.top.equalTo(descriptionLabel.snp.bottom).offset(8) make.leading.bottom.equalToSuperview() make.trailing.lessThanOrEqualTo(chevron.snp.leading).offset(-8) } @@ -113,7 +126,7 @@ final class PermissionFormSelectorRow: UIView { func setValue(_ text: String, isPlaceholder: Bool) { valueLabel.text = text - valueLabel.textColor = isPlaceholder ? AppColor.textTertiary : AppColor.textPrimary + valueLabel.textColor = isPlaceholder ? UIColor(hex: 0xB3B8C2) : AppColor.textPrimary } @objc private func rowTapped() { @@ -129,8 +142,8 @@ final class PermissionSelectedTagsView: UIView { override init(frame: CGRect) { super.init(frame: frame) - titleLabel.font = .systemFont(ofSize: 14, weight: .medium) - titleLabel.textColor = AppColor.textPrimary + titleLabel.font = .systemFont(ofSize: 16, weight: .medium) + titleLabel.textColor = UIColor(hex: 0x111827) stackView.axis = .vertical stackView.spacing = 8 addSubview(titleLabel) @@ -229,7 +242,7 @@ final class PermissionSubmitButton: UIButton { override init(frame: CGRect) { super.init(frame: frame) - setTitle("提交申请", for: .normal) + setTitle("提交审核", for: .normal) setTitleColor(.white, for: .normal) titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) backgroundColor = AppColor.primary @@ -247,6 +260,6 @@ final class PermissionSubmitButton: UIButton { func setSubmitting(_ submitting: Bool) { isEnabled = !submitting alpha = submitting ? 0.6 : 1 - setTitle(submitting ? "提交中..." : "提交申请", for: .normal) + setTitle(submitting ? "提交中..." : "提交审核", for: .normal) } } diff --git a/suixinkan/UI/Home/Views/ScenicSelectionViews.swift b/suixinkan/UI/Home/Views/ScenicSelectionViews.swift index 1fd4363..3b81792 100644 --- a/suixinkan/UI/Home/Views/ScenicSelectionViews.swift +++ b/suixinkan/UI/Home/Views/ScenicSelectionViews.swift @@ -41,8 +41,10 @@ final class ScenicPermissionBannerView: UIView { make.centerY.equalToSuperview() } snp.makeConstraints { make in - make.height.greaterThanOrEqualTo(44) + make.height.equalTo(44) } + setContentHuggingPriority(.required, for: .vertical) + setContentCompressionResistancePriority(.required, for: .vertical) } @available(*, unavailable) @@ -146,18 +148,20 @@ final class ScenicCurrentLocationBar: UIView { iconView.snp.makeConstraints { make in make.leading.equalToSuperview() - make.centerY.equalToSuperview() + make.centerY.equalTo(locationLabel) make.size.equalTo(16) } locationLabel.snp.makeConstraints { make in make.leading.equalTo(iconView.snp.trailing).offset(4) - make.centerY.equalToSuperview() + make.top.bottom.equalToSuperview().inset(8) make.trailing.lessThanOrEqualTo(relocateButton.snp.leading).offset(-12) } relocateButton.snp.makeConstraints { make in make.trailing.equalToSuperview() - make.centerY.equalToSuperview() + make.centerY.equalTo(locationLabel) } + setContentHuggingPriority(.required, for: .vertical) + setContentCompressionResistancePriority(.required, for: .vertical) } @available(*, unavailable) @@ -165,8 +169,10 @@ final class ScenicCurrentLocationBar: UIView { fatalError("init(coder:) has not been implemented") } - func apply(locationText: String) { - locationLabel.text = locationText + func apply(locationText: String, isLocating: Bool = false) { + locationLabel.text = isLocating ? "定位中..." : locationText + relocateButton.isEnabled = !isLocating + relocateButton.alpha = isLocating ? 0.5 : 1 } @objc private func relocateTapped() { diff --git a/suixinkan/UI/Orders/OrderSourceLeadListViewController.swift b/suixinkan/UI/Orders/OrderSourceLeadListViewController.swift new file mode 100644 index 0000000..7d37332 --- /dev/null +++ b/suixinkan/UI/Orders/OrderSourceLeadListViewController.swift @@ -0,0 +1,189 @@ +// +// OrderSourceLeadListViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 某获客员的带客单选择页,从「选择获客员」页 push 进入。 +final class OrderSourceLeadListViewController: BaseViewController, UITableViewDataSource, UITableViewDelegate { + + private let person: OrderSourcePerson + private let orderNumber: String + private let viewModel: OrderListViewModel + private let referralOrder: BoundReferralOrderEntity? + private let loadLeads: (OrderSourcePerson) async -> Void + private let onLeadBound: (OrderSourceSelection) -> Void + private let onCallPhone: (String) -> Void + private let onPreviewImages: ([String], Int) -> Void + + private let phoneRow = UIStackView() + private let phoneLabel = UILabel() + private let callButton = UIButton(type: .system) + private let tableView = UITableView(frame: .zero, style: .plain) + private let loadingIndicator = UIActivityIndicatorView(style: .medium) + private let emptyLabel = UILabel() + + init( + person: OrderSourcePerson, + orderNumber: String, + viewModel: OrderListViewModel, + referralOrder: BoundReferralOrderEntity?, + loadLeads: @escaping (OrderSourcePerson) async -> Void, + onLeadBound: @escaping (OrderSourceSelection) -> Void, + onCallPhone: @escaping (String) -> Void, + onPreviewImages: @escaping ([String], Int) -> Void + ) { + self.person = person + self.orderNumber = orderNumber + self.viewModel = viewModel + self.referralOrder = referralOrder + self.loadLeads = loadLeads + self.onLeadBound = onLeadBound + self.onCallPhone = onCallPhone + self.onPreviewImages = onPreviewImages + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "\(person.name)的带客单" + } + + override func setupUI() { + view.backgroundColor = .white + + phoneLabel.font = .systemFont(ofSize: 14) + phoneLabel.textColor = AppColor.primary + phoneLabel.text = person.phone + + callButton.backgroundColor = OrderTokens.phoneIconBackground + callButton.layer.cornerRadius = 16 + callButton.tintColor = AppColor.primary + callButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) + callButton.addTarget(self, action: #selector(callTapped), for: .touchUpInside) + + phoneRow.axis = .horizontal + phoneRow.alignment = .center + phoneRow.spacing = 10 + phoneRow.addArrangedSubview(phoneLabel) + phoneRow.addArrangedSubview(callButton) + + let phone = person.phone.trimmingCharacters(in: .whitespacesAndNewlines) + phoneRow.isHidden = phone.isEmpty + + tableView.separatorStyle = .none + tableView.backgroundColor = .white + tableView.dataSource = self + tableView.delegate = self + tableView.register(OrderSourceLeadCell.self, forCellReuseIdentifier: OrderSourceLeadCell.reuseIdentifier) + + loadingIndicator.hidesWhenStopped = true + loadingIndicator.color = AppColor.primary + + emptyLabel.font = .systemFont(ofSize: 14) + emptyLabel.textColor = AppColor.text666 + emptyLabel.textAlignment = .center + emptyLabel.numberOfLines = 0 + emptyLabel.text = "暂无可绑定带客单" + + view.addSubview(phoneRow) + view.addSubview(tableView) + view.addSubview(loadingIndicator) + view.addSubview(emptyLabel) + } + + override func setupConstraints() { + phoneRow.snp.makeConstraints { make in + make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.sm) + make.leading.equalToSuperview().inset(AppSpacing.md) + } + callButton.snp.makeConstraints { make in + make.width.height.equalTo(32) + } + tableView.snp.makeConstraints { make in + make.top.equalTo(phoneRow.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.bottom.equalToSuperview() + } + loadingIndicator.snp.makeConstraints { make in + make.center.equalTo(tableView) + } + emptyLabel.snp.makeConstraints { make in + make.center.equalTo(tableView) + make.leading.trailing.equalTo(tableView).inset(AppSpacing.lg) + } + } + + override func viewDidLoad() { + super.viewDidLoad() + applyViewModel() + Task { + await loadLeads(person) + reloadFromViewModel() + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if isMovingFromParent { + navigationController?.setNavigationBarHidden(true, animated: false) + } + } + + func reloadFromViewModel() { + applyViewModel() + } + + private func applyViewModel() { + let isLoading = viewModel.loadingOrderSourceLeadsFor == person.key + let leads = viewModel.orderSourceLeads[person.key] ?? [] + + if isLoading { + loadingIndicator.startAnimating() + tableView.isHidden = true + emptyLabel.isHidden = true + } else { + loadingIndicator.stopAnimating() + tableView.isHidden = false + emptyLabel.isHidden = !leads.isEmpty + tableView.reloadData() + } + } + + @objc private func callTapped() { + onCallPhone(person.phone) + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + viewModel.orderSourceLeads[person.key]?.count ?? 0 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: OrderSourceLeadCell.reuseIdentifier, + for: indexPath + ) as! OrderSourceLeadCell + let leads = viewModel.orderSourceLeads[person.key] ?? [] + let lead = leads[indexPath.row] + let currentSelection = viewModel.orderSourceSelection( + orderNumber: orderNumber, + referralOrder: referralOrder + ) + let selected = currentSelection?.person.key == person.key + && currentSelection?.lead.key == lead.key + cell.configure(lead: lead, selected: selected) + cell.onCallPhone = onCallPhone + cell.onImageTap = onPreviewImages + cell.onSelect = { [weak self] in + guard let self else { return } + let selection = OrderSourceSelection(person: self.person, lead: lead) + self.onLeadBound(selection) + } + return cell + } +} diff --git a/suixinkan/UI/Orders/OrderSourcePickerViewController.swift b/suixinkan/UI/Orders/OrderSourcePickerViewController.swift index de1606b..d947b7f 100644 --- a/suixinkan/UI/Orders/OrderSourcePickerViewController.swift +++ b/suixinkan/UI/Orders/OrderSourcePickerViewController.swift @@ -6,58 +6,39 @@ import SnapKit import UIKit -/// 订单来源选择 Bottom Sheet,对齐 Android `OrderSourcePicker` 弹层。 +/// 订单来源获客员选择 Bottom Sheet 首页,点击获客员后 push 带客单页。 final class OrderSourcePickerViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { - private enum MenuLevel { - case person - case lead - } - private let orderNumber: String private let viewModel: OrderListViewModel - private let onPersonSelected: (OrderSourcePerson) -> Void + private let referralOrder: BoundReferralOrderEntity? + private let loadLeads: (OrderSourcePerson) async -> Void private let onLeadBound: (OrderSourceSelection) -> Void private let onCallPhone: (String) -> Void private let onPreviewImages: ([String], Int) -> Void - private var level: MenuLevel = .person - private var pendingPerson: OrderSourcePerson? - - private let headerStack = UIStackView() private let titleLabel = UILabel() private let subtitleLabel = UILabel() - private let leadHeaderRow = UIStackView() - private let backButton = UIButton(type: .system) - private let leadTitleLabel = UILabel() - private let leadPhoneButton = UIButton(type: .system) - private let leadCallButton = UIButton(type: .system) private let tableView = UITableView(frame: .zero, style: .plain) private let loadingIndicator = UIActivityIndicatorView(style: .medium) private let emptyLabel = UILabel() - private var referralOrder: BoundReferralOrderEntity? - init( orderNumber: String, viewModel: OrderListViewModel, referralOrder: BoundReferralOrderEntity?, - onPersonSelected: @escaping (OrderSourcePerson) -> Void, + loadLeads: @escaping (OrderSourcePerson) async -> Void, onLeadBound: @escaping (OrderSourceSelection) -> Void, onCallPhone: @escaping (String) -> Void, onPreviewImages: @escaping ([String], Int) -> Void ) { self.orderNumber = orderNumber self.viewModel = viewModel - self.onPersonSelected = onPersonSelected + self.referralOrder = referralOrder + self.loadLeads = loadLeads self.onLeadBound = onLeadBound self.onCallPhone = onCallPhone self.onPreviewImages = onPreviewImages - self.referralOrder = referralOrder - self.pendingPerson = viewModel.orderSourceSelection( - orderNumber: orderNumber, - referralOrder: referralOrder - )?.person super.init(nibName: nil, bundle: nil) } @@ -75,6 +56,8 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo func reloadFromViewModel() { applyViewModel() + (navigationController?.topViewController as? OrderSourceLeadListViewController)? + .reloadFromViewModel() } private func setupUI() { @@ -87,72 +70,37 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo subtitleLabel.text = "选择人员后,再选择该人员创建的带客单" subtitleLabel.numberOfLines = 0 - backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal) - backButton.tintColor = AppColor.text333 - backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) - - leadTitleLabel.font = .systemFont(ofSize: 18, weight: .bold) - leadTitleLabel.textColor = AppColor.text333 - leadTitleLabel.numberOfLines = 2 - - leadPhoneButton.titleLabel?.font = .systemFont(ofSize: 13) - leadPhoneButton.setTitleColor(AppColor.primary, for: .normal) - leadPhoneButton.contentHorizontalAlignment = .leading - leadPhoneButton.addTarget(self, action: #selector(leadPhoneTapped), for: .touchUpInside) - - leadCallButton.backgroundColor = OrderTokens.phoneIconBackground - leadCallButton.layer.cornerRadius = 16 - leadCallButton.tintColor = AppColor.primary - leadCallButton.setImage(UIImage(systemName: "phone.fill"), for: .normal) - leadCallButton.addTarget(self, action: #selector(leadPhoneTapped), for: .touchUpInside) - - leadHeaderRow.axis = .horizontal - leadHeaderRow.alignment = .top - leadHeaderRow.spacing = 10 - leadHeaderRow.isHidden = true - let leadTextStack = UIStackView(arrangedSubviews: [leadTitleLabel, leadPhoneButton]) - leadTextStack.axis = .vertical - leadTextStack.spacing = 3 - leadHeaderRow.addArrangedSubview(backButton) - leadHeaderRow.addArrangedSubview(leadTextStack) - leadHeaderRow.addArrangedSubview(leadCallButton) - - headerStack.axis = .vertical - headerStack.spacing = 5 - headerStack.addArrangedSubview(titleLabel) - headerStack.addArrangedSubview(subtitleLabel) - headerStack.addArrangedSubview(leadHeaderRow) - tableView.separatorStyle = .none tableView.backgroundColor = .white tableView.dataSource = self tableView.delegate = self tableView.register(OrderSourcePersonCell.self, forCellReuseIdentifier: OrderSourcePersonCell.reuseIdentifier) - tableView.register(OrderSourceLeadCell.self, forCellReuseIdentifier: OrderSourceLeadCell.reuseIdentifier) loadingIndicator.hidesWhenStopped = true + loadingIndicator.color = AppColor.primary + emptyLabel.font = .systemFont(ofSize: 14) emptyLabel.textColor = AppColor.text666 emptyLabel.textAlignment = .center emptyLabel.numberOfLines = 0 + emptyLabel.text = "暂无合作获客员" - view.addSubview(headerStack) + view.addSubview(titleLabel) + view.addSubview(subtitleLabel) view.addSubview(tableView) view.addSubview(loadingIndicator) view.addSubview(emptyLabel) - headerStack.snp.makeConstraints { make in + titleLabel.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md) make.leading.trailing.equalToSuperview().inset(AppSpacing.md) } - backButton.snp.makeConstraints { make in - make.width.height.equalTo(28) - } - leadCallButton.snp.makeConstraints { make in - make.width.height.equalTo(32) + subtitleLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(5) + make.leading.trailing.equalTo(titleLabel) } tableView.snp.makeConstraints { make in - make.top.equalTo(headerStack.snp.bottom).offset(14) + make.top.equalTo(subtitleLabel.snp.bottom).offset(14) make.leading.trailing.bottom.equalToSuperview() } loadingIndicator.snp.makeConstraints { make in @@ -165,113 +113,54 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo } private func applyViewModel() { - let currentSelection = viewModel.orderSourceSelection( - orderNumber: orderNumber, - referralOrder: referralOrder - ) - titleLabel.isHidden = level == .lead - subtitleLabel.isHidden = level == .lead - leadHeaderRow.isHidden = level == .person - - if level == .lead, let person = pendingPerson { - leadTitleLabel.text = "\(person.name)创建的带客单" - let phone = person.phone.trimmingCharacters(in: .whitespacesAndNewlines) - leadPhoneButton.setTitle(phone, for: .normal) - leadPhoneButton.isHidden = phone.isEmpty - leadCallButton.isHidden = phone.isEmpty - } - - let isLoadingPeople = viewModel.isLoadingOrderSourcePeople && level == .person - let isLoadingLeads = viewModel.loadingOrderSourceLeadsFor != nil && level == .lead - if isLoadingPeople || isLoadingLeads { + if viewModel.isLoadingOrderSourcePeople { loadingIndicator.startAnimating() tableView.isHidden = true emptyLabel.isHidden = true } else { loadingIndicator.stopAnimating() tableView.isHidden = false - let isEmpty: Bool - switch level { - case .person: - isEmpty = viewModel.orderSourcePeople.isEmpty - case .lead: - isEmpty = leadsForPendingPerson.isEmpty - } + let isEmpty = viewModel.orderSourcePeople.isEmpty emptyLabel.isHidden = !isEmpty - emptyLabel.text = level == .person ? "暂无合作获客员" : "暂无可绑定带客单" tableView.reloadData() } - _ = currentSelection - } - - private var leadsForPendingPerson: [OrderSourceLead] { - guard let person = pendingPerson else { return [] } - return viewModel.orderSourceLeads[person.key] ?? [] - } - - @objc private func backTapped() { - level = .person - applyViewModel() - } - - @objc private func leadPhoneTapped() { - guard let phone = pendingPerson?.phone.trimmingCharacters(in: .whitespacesAndNewlines), !phone.isEmpty else { return } - onCallPhone(phone) } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch level { - case .person: - return viewModel.orderSourcePeople.count - case .lead: - return leadsForPendingPerson.count - } + viewModel.orderSourcePeople.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell( + withIdentifier: OrderSourcePersonCell.reuseIdentifier, + for: indexPath + ) as! OrderSourcePersonCell + let person = viewModel.orderSourcePeople[indexPath.row] let currentSelection = viewModel.orderSourceSelection( orderNumber: orderNumber, referralOrder: referralOrder ) - switch level { - case .person: - let cell = tableView.dequeueReusableCell( - withIdentifier: OrderSourcePersonCell.reuseIdentifier, - for: indexPath - ) as! OrderSourcePersonCell - let person = viewModel.orderSourcePeople[indexPath.row] - cell.configure( - person: person, - selected: currentSelection?.person.key == person.key - ) - cell.onCallPhone = onCallPhone - return cell - case .lead: - let cell = tableView.dequeueReusableCell( - withIdentifier: OrderSourceLeadCell.reuseIdentifier, - for: indexPath - ) as! OrderSourceLeadCell - let lead = leadsForPendingPerson[indexPath.row] - let selected = currentSelection?.person.key == pendingPerson?.key - && currentSelection?.lead.key == lead.key - cell.configure(lead: lead, selected: selected) - cell.onCallPhone = onCallPhone - cell.onImageTap = onPreviewImages - cell.onSelect = { [weak self] in - guard let self, let person = self.pendingPerson else { return } - let selection = OrderSourceSelection(person: person, lead: lead) - self.onLeadBound(selection) - } - return cell - } + cell.configure( + person: person, + selected: currentSelection?.person.key == person.key + ) + cell.onCallPhone = onCallPhone + return cell } func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard level == .person else { return } let person = viewModel.orderSourcePeople[indexPath.row] - pendingPerson = person - level = .lead - onPersonSelected(person) - applyViewModel() + let leadController = OrderSourceLeadListViewController( + person: person, + orderNumber: orderNumber, + viewModel: viewModel, + referralOrder: referralOrder, + loadLeads: loadLeads, + onLeadBound: onLeadBound, + onCallPhone: onCallPhone, + onPreviewImages: onPreviewImages + ) + navigationController?.setNavigationBarHidden(false, animated: true) + navigationController?.pushViewController(leadController, animated: true) } } diff --git a/suixinkan/UI/Orders/OrdersViewController.swift b/suixinkan/UI/Orders/OrdersViewController.swift index fe1a1f4..7b3877f 100644 --- a/suixinkan/UI/Orders/OrdersViewController.swift +++ b/suixinkan/UI/Orders/OrdersViewController.swift @@ -473,11 +473,9 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT orderNumber: order.orderNumber, viewModel: self.orderListViewModel, referralOrder: order.referralOrder, - onPersonSelected: { [weak self] person in + loadLeads: { [weak self] person in guard let self else { return } - Task { - await self.orderListViewModel.loadOrderSourceLeads(person: person, api: self.orderAPI) - } + await self.orderListViewModel.loadOrderSourceLeads(person: person, api: self.orderAPI) }, onLeadBound: { [weak self] selection in guard let self, let orderNumber = self.pendingOrderSourceNumber else { return } diff --git a/suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift b/suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift new file mode 100644 index 0000000..4e79365 --- /dev/null +++ b/suixinkan/UI/ScenicApplication/ScenicApplicationViewController.swift @@ -0,0 +1,338 @@ +// +// ScenicApplicationViewController.swift +// suixinkan +// + +import PhotosUI +import SnapKit +import UIKit + +/// 景区申请页,对齐 Android `ScenicApplicationScreen`。 +final class ScenicApplicationViewController: BaseViewController { + + private let viewModel: ScenicApplicationViewModel + private let homeAPI: HomeAPI + private let profileAPI: ProfileAPI + private let ossUploadService: OSSUploadService + + private let scrollView = UIScrollView() + private let outerStack = UIStackView() + private let paddedStack = UIStackView() + private let auditCard = ScenicApplicationAuditStatusView() + private let warmReminder = ScenicApplicationWarmReminderView() + private let formCard = UIView() + private let formStack = UIStackView() + + private let scenicNameField = ScenicApplicationFieldStyle.makeTextField(placeholder: "请输入景区名称") + private let scenicNameSection = ScenicApplicationFormFieldView(title: "景区名称") + private let imageSection = ScenicApplicationFormFieldView(title: "景区图片 (已添加 0/20)") + private let imageUploadView = ScenicApplicationImageUploadView() + private let provinceSelector = ScenicApplicationLocationSelector() + private let citySelector = ScenicApplicationLocationSelector() + private let cooperationStack = UIStackView() + private var cooperationOptions: [ScenicApplicationCooperationOptionView] = [] + private let remarksView = ScenicApplicationPlaceholderTextView(placeholder: "请输入备注信息") + private let remarksSection = ScenicApplicationFormFieldView(title: "备注信息 (0/50)") + + private let bottomBar = UIView() + private let agreementView = ScenicApplicationAgreementView() + private let submitButton = ScenicApplicationSubmitButton() + private let uploadProgressView = ScenicApplicationUploadProgressView() + + init( + viewModel: ScenicApplicationViewModel = ScenicApplicationViewModel(), + homeAPI: HomeAPI = NetworkServices.shared.homeAPI, + profileAPI: ProfileAPI = NetworkServices.shared.profileAPI, + ossUploadService: OSSUploadService = NetworkServices.shared.ossUploadService + ) { + self.viewModel = viewModel + self.homeAPI = homeAPI + self.profileAPI = profileAPI + self.ossUploadService = ossUploadService + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "景区申请" + } + + override func setupUI() { + outerStack.axis = .vertical + outerStack.spacing = 0 + + paddedStack.axis = .vertical + paddedStack.spacing = 12 + + formCard.backgroundColor = .white + formCard.layer.cornerRadius = 8 + formStack.axis = .vertical + formStack.spacing = 20 + formCard.addSubview(formStack) + formStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + } + + scenicNameSection.setContent(scenicNameField) + imageSection.setContent(imageUploadView) + + let locationRow = UIStackView(arrangedSubviews: [provinceSelector, citySelector]) + locationRow.axis = .horizontal + locationRow.spacing = 12 + locationRow.distribution = .fillEqually + let locationSection = ScenicApplicationFormFieldView(title: "景区位置") + locationSection.setContent(locationRow) + + cooperationStack.axis = .vertical + cooperationStack.spacing = 8 + ScenicCooperationType.allCases.forEach { type in + let option = ScenicApplicationCooperationOptionView(title: type.displayName) + option.tag = type.rawValue + option.addTarget(self, action: #selector(cooperationTypeTapped(_:)), for: .touchUpInside) + cooperationOptions.append(option) + cooperationStack.addArrangedSubview(option) + } + let cooperationSection = ScenicApplicationFormFieldView(title: "合作类型") + cooperationSection.setContent(cooperationStack) + + remarksView.text = "" + remarksSection.setContent(remarksView) + + formStack.addArrangedSubview(scenicNameSection) + formStack.addArrangedSubview(imageSection) + formStack.addArrangedSubview(locationSection) + formStack.addArrangedSubview(cooperationSection) + formStack.addArrangedSubview(remarksSection) + + paddedStack.addArrangedSubview(warmReminder) + paddedStack.addArrangedSubview(formCard) + + outerStack.addArrangedSubview(auditCard) + outerStack.addArrangedSubview(paddedStack) + auditCard.isHidden = true + + scrollView.addSubview(outerStack) + view.addSubview(scrollView) + view.addSubview(bottomBar) + view.addSubview(uploadProgressView) + + bottomBar.backgroundColor = .white + let bottomStack = UIStackView(arrangedSubviews: [agreementView, submitButton]) + bottomStack.axis = .vertical + bottomStack.spacing = 4 + bottomBar.addSubview(bottomStack) + bottomStack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 12, left: 16, bottom: 18, right: 16)) + } + + uploadProgressView.isHidden = true + uploadProgressView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + override func setupConstraints() { + scrollView.snp.makeConstraints { make in + make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) + make.bottom.equalTo(bottomBar.snp.top) + } + outerStack.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.width.equalTo(scrollView) + } + paddedStack.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(16) + } + paddedStack.layoutMargins = UIEdgeInsets(top: 12, left: 0, bottom: 16, right: 0) + paddedStack.isLayoutMarginsRelativeArrangement = true + bottomBar.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview() + make.bottom.equalTo(view.safeAreaLayoutGuide) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } + viewModel.onShowMessage = { [weak self] message in + Task { @MainActor in self?.showToast(message) } + } + viewModel.onSubmitSuccess = { [weak self] in + Task { @MainActor in self?.navigationController?.popViewController(animated: true) } + } + + scenicNameField.addTarget(self, action: #selector(scenicNameChanged), for: .editingChanged) + remarksView.onTextChange = { [weak self] text in + self?.viewModel.onRemarksChange(text) + } + provinceSelector.addTarget(self, action: #selector(provinceTapped), for: .touchUpInside) + citySelector.addTarget(self, action: #selector(cityTapped), for: .touchUpInside) + agreementView.onToggle = { [weak self] in self?.viewModel.toggleAgreement() } + submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside) + + imageUploadView.onAdd = { [weak self] in self?.pickImages() } + imageUploadView.onDelete = { [weak self] index in self?.viewModel.deleteImage(at: index) } + imageUploadView.onRetry = { [weak self] index in + guard let self else { return } + self.viewModel.retryUpload(at: index) + Task { await self.startUploadIfNeeded() } + } + + Task { await loadInitial() } + applyViewModel() + } + + private func loadInitial() async { + showLoading() + defer { hideLoading() } + await viewModel.loadInitial(profileAPI: profileAPI, homeAPI: homeAPI) + } + + private func applyViewModel() { + if viewModel.shouldShowAuditCard, let pending = viewModel.pendingApplication { + auditCard.apply(pending: pending) + auditCard.isHidden = false + } else { + auditCard.isHidden = true + } + + let editable = viewModel.isEditable + let readOnly = viewModel.isReadOnly + + scenicNameField.text = viewModel.scenicName + scenicNameField.isEnabled = editable + scenicNameField.isUserInteractionEnabled = editable && !readOnly + + imageSection.updateTitle("景区图片 (已添加 \(viewModel.selectedImages.count)/20)") + imageUploadView.apply(images: viewModel.selectedImages, isReadOnly: readOnly) + + provinceSelector.setValue(viewModel.selectedProvince, placeholder: "省份") + citySelector.setValue(viewModel.selectedCity, placeholder: "城市") + provinceSelector.isEnabled = editable + citySelector.isEnabled = editable + + cooperationOptions.forEach { option in + let type = ScenicCooperationType(rawValue: option.tag) ?? .photographer + option.isSelected = type == viewModel.selectedCooperationType + option.isEnabled = editable + } + + if remarksView.text != viewModel.remarks { + remarksView.text = viewModel.remarks + } + remarksView.isEditable = editable && !readOnly + remarksSection.updateTitle("备注信息 (\(viewModel.remarks.count)/50)") + + agreementView.isHidden = !editable + agreementView.setChecked(viewModel.isAgreed) + + submitButton.apply( + canSubmit: viewModel.canSubmit, + isSubmitting: viewModel.isSubmitting, + isReadOnly: readOnly + ) + + uploadProgressView.apply(state: viewModel.uploadDialogState) + } + + @objc private func scenicNameChanged() { + viewModel.onScenicNameChange(scenicNameField.text ?? "") + } + + @objc private func cooperationTypeTapped(_ sender: ScenicApplicationCooperationOptionView) { + guard viewModel.isEditable else { return } + guard let type = ScenicCooperationType(rawValue: sender.tag) else { return } + viewModel.onCooperationTypeSelected(type) + } + + @objc private func provinceTapped() { + guard viewModel.isEditable else { return } + presentAreaPicker(title: "选择省份", names: viewModel.provinceList.map(\.name)) { [weak self] name in + self?.viewModel.onProvinceSelected(name) + } + } + + @objc private func cityTapped() { + guard viewModel.isEditable else { return } + guard !viewModel.selectedProvince.isEmpty else { + showToast("请先选择省份") + return + } + presentAreaPicker(title: "选择城市", names: viewModel.cityList.map(\.name)) { [weak self] name in + self?.viewModel.onCitySelected(name) + } + } + + private func presentAreaPicker(title: String, names: [String], onSelect: @escaping (String) -> Void) { + let alert = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) + names.forEach { name in + alert.addAction(UIAlertAction(title: name, style: .default) { _ in onSelect(name) }) + } + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + present(alert, animated: true) + } + + @objc private func submitTapped() { + Task { + showLoading() + defer { hideLoading() } + await viewModel.submit(homeAPI: homeAPI) + } + } + + private func pickImages() { + guard viewModel.isEditable, !viewModel.isReadOnly else { return } + let remaining = 20 - viewModel.selectedImages.count + guard remaining > 0 else { + showToast("最多只能选择20张图片") + return + } + + var configuration = PHPickerConfiguration(photoLibrary: .shared()) + configuration.filter = .images + configuration.selectionLimit = remaining + let picker = PHPickerViewController(configuration: configuration) + picker.delegate = self + present(picker, animated: true) + } + + private func startUploadIfNeeded() async { + let scenicId = AppStore.shared.currentScenicId + await viewModel.processPendingUploads(uploader: ossUploadService, scenicId: scenicId) + } +} + +extension ScenicApplicationViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard !results.isEmpty else { return } + + var loadedItems = [ScenicApplicationImageItem?](repeating: nil, count: results.count) + let group = DispatchGroup() + + for (index, result) in results.enumerated() { + let provider = result.itemProvider + guard provider.canLoadObject(ofClass: UIImage.self) else { continue } + group.enter() + provider.loadObject(ofClass: UIImage.self) { object, _ in + defer { group.leave() } + guard let image = object as? UIImage, let data = image.jpegData(compressionQuality: 0.9) else { return } + let fileName = "scenic_apply_\(Int(Date().timeIntervalSince1970))_\(index).jpg" + loadedItems[index] = ScenicApplicationImageItem.fromLocal(data: data, fileName: fileName) + } + } + + group.notify(queue: .main) { [weak self] in + guard let self else { return } + let items = loadedItems.compactMap { $0 } + guard !items.isEmpty else { return } + self.viewModel.addLocalImages(items) + Task { await self.startUploadIfNeeded() } + } + } +} diff --git a/suixinkan/UI/ScenicApplication/Views/ScenicApplicationViews.swift b/suixinkan/UI/ScenicApplication/Views/ScenicApplicationViews.swift new file mode 100644 index 0000000..7d08d56 --- /dev/null +++ b/suixinkan/UI/ScenicApplication/Views/ScenicApplicationViews.swift @@ -0,0 +1,684 @@ +// +// ScenicApplicationViews.swift +// suixinkan +// + +import Kingfisher +import SnapKit +import UIKit + +/// 温馨提示卡片,对齐 Android `WarmReminderCard`。 +final class ScenicApplicationWarmReminderView: UIView { + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = AppColor.primaryBanner + layer.cornerRadius = 8 + + let titleLabel = UILabel() + titleLabel.text = "温馨提示" + titleLabel.font = .systemFont(ofSize: 16, weight: .bold) + titleLabel.textColor = .white + + let bodyLabel = UILabel() + bodyLabel.text = "如果你想入驻更多的景区,平台可以帮你跟景区官方谈合作,如果已经有的景区,只要你符合入驻的条件,即可入驻。" + bodyLabel.font = .systemFont(ofSize: 14) + bodyLabel.textColor = .white + bodyLabel.numberOfLines = 0 + + let stack = UIStackView(arrangedSubviews: [titleLabel, bodyLabel]) + stack.axis = .vertical + stack.spacing = 8 + addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// 审核状态卡片,对齐 Android `ScenicApplicationAuditStatusCard`。 +final class ScenicApplicationAuditStatusView: UIView { + + func apply(pending: ScenicApplicationPendingResponse) { + subviews.forEach { $0.removeFromSuperview() } + + let statusLabel: String + let textColor: UIColor + let background: UIColor + switch pending.status { + case 1: + statusLabel = "待审核" + textColor = AppColor.warning + background = AppColor.warningBackground + case 2: + statusLabel = "通过" + textColor = AppColor.info + background = AppColor.primaryLight + case 3, 9: + statusLabel = pending.status == 3 ? "拒绝" : "取消" + textColor = AppColor.danger + background = AppColor.dangerBackground + default: + statusLabel = "--" + textColor = AppColor.warning + background = AppColor.warningBackground + } + + backgroundColor = background + + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 8 + + stack.addArrangedSubview(makeRow(label: "审核状态", value: statusLabel, color: textColor)) + if pending.status == 2 { + stack.addArrangedSubview(makeRow(label: "审核人", value: pending.auditedBy ?? "--", color: textColor)) + stack.addArrangedSubview(makeRow(label: "审核时间", value: pending.auditedAt ?? "--", color: textColor)) + } + let note = pending.rejectReason ?? pending.auditNote ?? "--" + stack.addArrangedSubview(makeRow(label: "备注", value: note.isEmpty ? "--" : note, color: textColor)) + + addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20)) + } + } + + private func makeRow(label: String, value: String, color: UIColor) -> UIView { + let row = UIStackView() + row.axis = .horizontal + row.distribution = .equalSpacing + + let left = UILabel() + left.text = label + left.font = .systemFont(ofSize: 14) + left.textColor = color + + let right = UILabel() + right.text = value + right.font = .systemFont(ofSize: 14) + right.textColor = color + right.textAlignment = .right + right.numberOfLines = 0 + + row.addArrangedSubview(left) + row.addArrangedSubview(right) + return row + } +} + +/// 表单字段标题 + 内容容器。 +final class ScenicApplicationFormFieldView: UIView { + + private let titleLabel = UILabel() + private let contentContainer = UIView() + + init(title: String) { + super.init(frame: .zero) + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 16, weight: .medium) + titleLabel.textColor = AppColor.textPrimary + + let stack = UIStackView(arrangedSubviews: [titleLabel, contentContainer]) + stack.axis = .vertical + stack.spacing = 8 + addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setContent(_ view: UIView) { + contentContainer.subviews.forEach { $0.removeFromSuperview() } + contentContainer.addSubview(view) + view.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + func updateTitle(_ title: String) { + titleLabel.text = title + } +} + +/// 省/市选择按钮。 +final class ScenicApplicationLocationSelector: UIControl { + + private let valueLabel = UILabel() + private let chevron = UIImageView(image: UIImage(systemName: "chevron.down")) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + layer.cornerRadius = 8 + layer.borderWidth = 1 + layer.borderColor = AppColor.border.cgColor + + valueLabel.font = .systemFont(ofSize: 14) + valueLabel.textColor = AppColor.textTertiary + valueLabel.text = "请选择" + + chevron.tintColor = AppColor.textTertiary + chevron.contentMode = .scaleAspectFit + + addSubview(valueLabel) + addSubview(chevron) + valueLabel.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.centerY.equalToSuperview() + make.trailing.lessThanOrEqualTo(chevron.snp.leading).offset(-8) + } + chevron.snp.makeConstraints { make in + make.trailing.equalToSuperview().offset(-12) + make.centerY.equalToSuperview() + make.size.equalTo(16) + } + snp.makeConstraints { make in + make.height.equalTo(44) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setValue(_ value: String?, placeholder: String) { + if let value, !value.isEmpty { + valueLabel.text = value + valueLabel.textColor = AppColor.textPrimary + } else { + valueLabel.text = placeholder + valueLabel.textColor = AppColor.textTertiary + } + } + + override var isEnabled: Bool { + didSet { + alpha = isEnabled ? 1 : 0.5 + } + } +} + +/// 合作类型单选项。 +final class ScenicApplicationCooperationOptionView: UIControl { + + private let titleLabel = UILabel() + + override var isSelected: Bool { + didSet { applyStyle() } + } + + init(title: String) { + super.init(frame: .zero) + titleLabel.text = title + titleLabel.font = .systemFont(ofSize: 14) + titleLabel.numberOfLines = 0 + layer.cornerRadius = 8 + layer.borderWidth = 2 + + addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(16) + } + applyStyle() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func applyStyle() { + if isSelected { + backgroundColor = AppColor.primaryBanner + titleLabel.textColor = .white + layer.borderColor = UIColor.clear.cgColor + } else { + backgroundColor = .white + titleLabel.textColor = AppColor.textSecondary + layer.borderColor = AppColor.inputBackground.cgColor + } + } +} + +/// 横向图片上传区域。 +final class ScenicApplicationImageUploadView: UIView { + + var onAdd: (() -> Void)? + var onDelete: ((Int) -> Void)? + var onRetry: ((Int) -> Void)? + + private let scrollView = UIScrollView() + private let stackView = UIStackView() + + override init(frame: CGRect) { + super.init(frame: frame) + stackView.axis = .horizontal + stackView.spacing = 8 + stackView.alignment = .center + scrollView.showsHorizontalScrollIndicator = false + scrollView.addSubview(stackView) + addSubview(scrollView) + + scrollView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.height.equalTo(80) + } + stackView.snp.makeConstraints { make in + make.edges.equalToSuperview() + make.height.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(images: [ScenicApplicationImageItem], isReadOnly: Bool) { + stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + for (index, item) in images.enumerated() { + let cell = ScenicApplicationImageCell() + cell.apply(item: item, showDelete: !isReadOnly) + cell.onDelete = { [weak self] in self?.onDelete?(index) } + cell.onRetry = { [weak self] in self?.onRetry?(index) } + stackView.addArrangedSubview(cell) + cell.snp.makeConstraints { make in + make.size.equalTo(80) + } + } + + if images.count < 20, !isReadOnly { + let addButton = ScenicApplicationAddImageButton() + addButton.addTarget(self, action: #selector(addTapped), for: .touchUpInside) + stackView.addArrangedSubview(addButton) + addButton.snp.makeConstraints { make in + make.size.equalTo(80) + } + } + } + + @objc private func addTapped() { + onAdd?() + } +} + +private final class ScenicApplicationAddImageButton: UIControl { + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = AppColor.inputBackground + layer.cornerRadius = 8 + + let icon = UIImageView(image: UIImage(systemName: "plus")) + icon.tintColor = UIColor(hex: 0xB6BECA) + let label = UILabel() + label.text = "点击上传" + label.font = .systemFont(ofSize: 12) + label.textColor = UIColor(hex: 0xB6BECA) + + let stack = UIStackView(arrangedSubviews: [icon, label]) + stack.axis = .vertical + stack.spacing = 4 + stack.alignment = .center + stack.isUserInteractionEnabled = false + addSubview(stack) + stack.snp.makeConstraints { make in + make.center.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class ScenicApplicationImageCell: UIView { + + var onDelete: (() -> Void)? + var onRetry: (() -> Void)? + + private let imageView = UIImageView() + private let progressView = UIProgressView(progressViewStyle: .default) + private let deleteButton = UIButton(type: .system) + private let retryButton = UIButton(type: .system) + private let errorLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + layer.cornerRadius = 8 + clipsToBounds = true + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + + deleteButton.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) + deleteButton.tintColor = .white + deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) + + retryButton.setTitle("重试", for: .normal) + retryButton.titleLabel?.font = .systemFont(ofSize: 12) + retryButton.setTitleColor(.white, for: .normal) + retryButton.backgroundColor = UIColor.black.withAlphaComponent(0.5) + retryButton.layer.cornerRadius = 4 + retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) + retryButton.isHidden = true + + errorLabel.font = .systemFont(ofSize: 10) + errorLabel.textColor = .white + errorLabel.textAlignment = .center + errorLabel.backgroundColor = UIColor.black.withAlphaComponent(0.5) + errorLabel.isHidden = true + + addSubview(imageView) + addSubview(progressView) + addSubview(errorLabel) + addSubview(retryButton) + addSubview(deleteButton) + + imageView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + progressView.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview().inset(4) + } + deleteButton.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(2) + make.size.equalTo(22) + } + retryButton.snp.makeConstraints { make in + make.center.equalToSuperview() + make.width.equalTo(44) + make.height.equalTo(24) + } + errorLabel.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + make.height.equalTo(18) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(item: ScenicApplicationImageItem, showDelete: Bool) { + deleteButton.isHidden = !showDelete + progressView.isHidden = !item.isUploading + progressView.progress = Float(item.uploadProgress) / 100 + + if let data = item.localData, let image = UIImage(data: data) { + imageView.image = image + } else if !item.remoteURL.isEmpty, let url = URL(string: item.remoteURL) { + imageView.kf.setImage(with: url) + } else { + imageView.image = nil + } + + if let error = item.errorMessage, !error.isEmpty { + errorLabel.isHidden = false + errorLabel.text = "失败" + retryButton.isHidden = false + } else { + errorLabel.isHidden = true + retryButton.isHidden = true + } + } + + @objc private func deleteTapped() { + onDelete?() + } + + @objc private func retryTapped() { + onRetry?() + } +} + +/// 协议勾选行,对齐 Android `AgreementCheckbox`。 +final class ScenicApplicationAgreementView: UIView { + + var onToggle: (() -> Void)? + + private let checkbox = UIButton(type: .custom) + private var isChecked = false + + override init(frame: CGRect) { + super.init(frame: frame) + + checkbox.setImage(UIImage(systemName: "square"), for: .normal) + checkbox.setImage(UIImage(systemName: "checkmark.square.fill"), for: .selected) + checkbox.tintColor = AppColor.primaryBanner + checkbox.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside) + + let prefix = UILabel() + prefix.text = "已阅读并同意" + prefix.font = .systemFont(ofSize: 14) + prefix.textColor = AppColor.textSecondary + + let userNotice = UILabel() + userNotice.text = "《用户须知》" + userNotice.font = .systemFont(ofSize: 14) + userNotice.textColor = AppColor.primaryBanner + + let middle = UILabel() + middle.text = "与" + middle.font = .systemFont(ofSize: 14) + middle.textColor = AppColor.textSecondary + + let privacy = UILabel() + privacy.text = "《隐私政策》" + privacy.font = .systemFont(ofSize: 14) + privacy.textColor = AppColor.primaryBanner + + let textStack = UIStackView(arrangedSubviews: [prefix, userNotice, middle, privacy]) + textStack.axis = .horizontal + textStack.spacing = 2 + textStack.alignment = .center + + let row = UIStackView(arrangedSubviews: [checkbox, textStack]) + row.axis = .horizontal + row.spacing = 2 + row.alignment = .center + + let tap = UITapGestureRecognizer(target: self, action: #selector(toggleTapped)) + addGestureRecognizer(tap) + + addSubview(row) + row.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + checkbox.snp.makeConstraints { make in + make.size.equalTo(24) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setChecked(_ checked: Bool) { + isChecked = checked + checkbox.isSelected = checked + } + + @objc private func toggleTapped() { + onToggle?() + } +} + +/// 底部确认提交按钮。 +final class ScenicApplicationSubmitButton: UIButton { + + override init(frame: CGRect) { + super.init(frame: frame) + setTitle("确认提交", for: .normal) + setTitleColor(.white, for: .normal) + titleLabel?.font = .systemFont(ofSize: 16, weight: .medium) + backgroundColor = AppColor.primaryBanner + layer.cornerRadius = 8 + snp.makeConstraints { make in + make.height.equalTo(46) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(canSubmit: Bool, isSubmitting: Bool, isReadOnly: Bool) { + let enabled = !isReadOnly && canSubmit && !isSubmitting + isEnabled = enabled + alpha = enabled ? 1 : 1 + backgroundColor = enabled ? AppColor.primaryBanner : UIColor(hex: 0xE0E0E0) + setTitle(isSubmitting ? "提交中..." : "确认提交", for: .normal) + } +} + +/// 上传进度弹窗。 +final class ScenicApplicationUploadProgressView: UIView { + + private let titleLabel = UILabel() + private let progressView = UIProgressView(progressViewStyle: .default) + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor.black.withAlphaComponent(0.35) + + let card = UIView() + card.backgroundColor = .white + card.layer.cornerRadius = 12 + + titleLabel.font = .systemFont(ofSize: 16, weight: .medium) + titleLabel.textColor = AppColor.textPrimary + titleLabel.textAlignment = .center + + progressView.progressTintColor = AppColor.primaryBanner + + let stack = UIStackView(arrangedSubviews: [titleLabel, progressView]) + stack.axis = .vertical + stack.spacing = 16 + card.addSubview(stack) + stack.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(20) + } + + addSubview(card) + card.snp.makeConstraints { make in + make.center.equalToSuperview() + make.leading.trailing.equalToSuperview().inset(40) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(state: ScenicApplicationUploadDialogState?) { + isHidden = state == nil + guard let state else { return } + titleLabel.text = state.title + progressView.progress = Float(state.progress) / 100 + } +} + +/// 带 placeholder 的多行输入框。 +final class ScenicApplicationPlaceholderTextView: UITextView, UITextViewDelegate { + + private let placeholderLabel = UILabel() + + var placeholder: String { + get { placeholderLabel.text ?? "" } + set { + placeholderLabel.text = newValue + refreshPlaceholderVisibility() + } + } + + var onTextChange: ((String) -> Void)? + + override var text: String! { + didSet { refreshPlaceholderVisibility() } + } + + override var delegate: UITextViewDelegate? { + get { super.delegate } + set { + // 始终由自身处理 placeholder,外部 delegate 通过 onTextChange 接收变更。 + } + } + + init(placeholder: String) { + super.init(frame: .zero, textContainer: nil) + font = .systemFont(ofSize: 14) + textColor = AppColor.textPrimary + backgroundColor = .white + layer.cornerRadius = 8 + layer.borderWidth = 1 + layer.borderColor = AppColor.border.cgColor + textContainerInset = UIEdgeInsets(top: 10, left: 8, bottom: 10, right: 8) + super.delegate = self + + placeholderLabel.text = placeholder + placeholderLabel.font = font + placeholderLabel.textColor = AppColor.textTertiary + placeholderLabel.numberOfLines = 0 + addSubview(placeholderLabel) + placeholderLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(10) + make.leading.equalToSuperview().offset(12) + make.trailing.equalToSuperview().offset(-12) + } + snp.makeConstraints { make in + make.height.equalTo(100) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func textViewDidChange(_ textView: UITextView) { + refreshPlaceholderVisibility() + onTextChange?(textView.text ?? "") + } + + private func refreshPlaceholderVisibility() { + placeholderLabel.isHidden = !(text ?? "").isEmpty + } +} + +/// 带边框的输入框样式。 +enum ScenicApplicationFieldStyle { + static func makeTextField(placeholder: String) -> UITextField { + let field = UITextField() + field.placeholder = placeholder + field.font = .systemFont(ofSize: 14) + field.borderStyle = .none + field.backgroundColor = .white + field.layer.cornerRadius = 8 + field.layer.borderWidth = 1 + field.layer.borderColor = AppColor.border.cgColor + field.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 1)) + field.leftViewMode = .always + field.snp.makeConstraints { make in + make.height.equalTo(44) + } + return field + } +} diff --git a/suixinkanTests/PermissionApplyViewModelTests.swift b/suixinkanTests/PermissionApplyViewModelTests.swift index 0814c99..851328c 100644 --- a/suixinkanTests/PermissionApplyViewModelTests.swift +++ b/suixinkanTests/PermissionApplyViewModelTests.swift @@ -54,7 +54,7 @@ final class PermissionApplyViewModelTests: XCTestCase { viewModel.onShowMessage = { message = $0 } await viewModel.submit(api: api) - XCTAssertEqual(message, "请先选择角色") + XCTAssertEqual(message, "请选择角色") viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true)) await viewModel.submit(api: api) diff --git a/suixinkanTests/ScenicApplicationViewModelTests.swift b/suixinkanTests/ScenicApplicationViewModelTests.swift new file mode 100644 index 0000000..0b3cc3b --- /dev/null +++ b/suixinkanTests/ScenicApplicationViewModelTests.swift @@ -0,0 +1,107 @@ +// +// ScenicApplicationViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 景区申请 ViewModel 测试。 +final class ScenicApplicationViewModelTests: XCTestCase { + + func testCanSubmitRequiresRequiredFields() { + let viewModel = ScenicApplicationViewModel() + XCTAssertFalse(viewModel.canSubmit) + + viewModel.onProvinceSelected("浙江省") + viewModel.onCitySelected("杭州市") + viewModel.onRemarksChange("合作备注") + XCTAssertFalse(viewModel.canSubmit) + + viewModel.toggleAgreement() + XCTAssertTrue(viewModel.canSubmit) + } + + func testRemarksMaxLengthShowsMessage() { + let viewModel = ScenicApplicationViewModel() + var message: String? + viewModel.onShowMessage = { message = $0 } + + let longText = String(repeating: "a", count: 51) + viewModel.onRemarksChange(longText) + + XCTAssertEqual(viewModel.remarks.count, 0) + XCTAssertEqual(message, "备注信息最多50字") + } + + func testProvinceSelectionClearsCity() { + let viewModel = ScenicApplicationViewModel() + viewModel.onProvinceSelected("浙江省") + viewModel.onCitySelected("杭州市") + XCTAssertEqual(viewModel.selectedCity, "杭州市") + + viewModel.onProvinceSelected("江苏省") + XCTAssertEqual(viewModel.selectedProvince, "江苏省") + XCTAssertEqual(viewModel.selectedCity, "") + } + + func testInitWithPendingApplicationStatus1() async throws { + let pendingJSON = """ + {"code":100000,"msg":"success","data":{"items":[{"id":1,"scenic_name":"测试景区","scenic_images":"https://a.com/1.jpg,https://a.com/2.jpg","scenic_province":"浙江省","scenic_city":"杭州市","coop_type":2,"remark":"备注","status":1}]}} + """.data(using: .utf8)! + let areasJSON = """ + {"code":100000,"msg":"success","data":[{"id":1,"name":"浙江省","children":[{"id":2,"name":"杭州市"}]}]} + """.data(using: .utf8)! + let homeAPI = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [pendingJSON]))) + let profileAPI = ProfileAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [areasJSON]))) + let viewModel = ScenicApplicationViewModel() + + await viewModel.loadAreas(profileAPI: profileAPI) + await viewModel.loadPendingApplication(homeAPI: homeAPI) + + XCTAssertEqual(viewModel.scenicName, "测试景区") + XCTAssertEqual(viewModel.selectedProvince, "浙江省") + XCTAssertEqual(viewModel.selectedCity, "杭州市") + XCTAssertEqual(viewModel.selectedCooperationType, .resourceProvider) + XCTAssertEqual(viewModel.remarks, "备注") + XCTAssertEqual(viewModel.selectedImages.count, 2) + XCTAssertTrue(viewModel.isReadOnly) + XCTAssertTrue(viewModel.shouldShowAuditCard) + } + + func testSubmitValidationShowsIncompleteMessage() async { + let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: []))) + let viewModel = ScenicApplicationViewModel() + var message: String? + viewModel.onShowMessage = { message = $0 } + + await viewModel.submit(homeAPI: api) + XCTAssertEqual(message, "请完善必填信息") + } + + func testSubmitSuccessCallsCallback() async throws { + let submitJSON = """ + {"code":100000,"msg":"success","data":{}} + """.data(using: .utf8)! + let pendingJSON = """ + {"code":100000,"msg":"success","data":{"items":[]}} + """.data(using: .utf8)! + let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [submitJSON, pendingJSON]))) + let viewModel = ScenicApplicationViewModel() + viewModel.onProvinceSelected("浙江省") + viewModel.onCitySelected("杭州市") + viewModel.onRemarksChange("合作") + viewModel.toggleAgreement() + + var didSubmit = false + viewModel.onSubmitSuccess = { didSubmit = true } + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + + await viewModel.submit(homeAPI: api) + + XCTAssertTrue(didSubmit) + XCTAssertEqual(messages.first, "提交成功") + } +}