Add scenic application flow aligned with Android.
Implement scenic apply models, API, upload, UI, and tests; wire entry from permission apply pages and include related permission/scenic selection UI fixes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -39,6 +39,23 @@ final class OSSUploadService {
|
|||||||
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress)
|
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`。
|
/// 上传任务素材,对齐 Android `moduleType = task_upload`。
|
||||||
func uploadTaskFile(
|
func uploadTaskFile(
|
||||||
data: Data,
|
data: Data,
|
||||||
@ -145,6 +162,8 @@ enum OSSUploadPolicy {
|
|||||||
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
case "task_upload":
|
case "task_upload":
|
||||||
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
|
case "scenic_apply":
|
||||||
|
return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
default:
|
default:
|
||||||
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,7 +33,6 @@ final class CooperationAcquirerViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadAcquirers(api: OrderAPI, initial: Bool = false) async {
|
func loadAcquirers(api: OrderAPI, initial: Bool = false) async {
|
||||||
if initial { isRefreshing = false }
|
|
||||||
let storeId = appStore.currentStoreId
|
let storeId = appStore.currentStoreId
|
||||||
let storeIdParam = storeId > 0 ? String(storeId) : nil
|
let storeIdParam = storeId > 0 ? String(storeId) : nil
|
||||||
do {
|
do {
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -163,9 +163,23 @@ final class PermissionApplyViewModel {
|
|||||||
.flatMap(\.scenic)
|
.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 {
|
func submit(api: HomeAPI) async {
|
||||||
guard let roleId = selectedRoleId else {
|
guard let roleId = selectedRoleId else {
|
||||||
onShowMessage?("请先选择角色")
|
onShowMessage?("请选择角色")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let newScenicIds = selectedScenicIds.filter { id in
|
let newScenicIds = selectedScenicIds.filter { id in
|
||||||
|
|||||||
@ -17,6 +17,7 @@ final class ScenicSelectionViewModel {
|
|||||||
private(set) var spots: [ScenicSpotDisplayItem] = []
|
private(set) var spots: [ScenicSpotDisplayItem] = []
|
||||||
private(set) var searchQuery = ""
|
private(set) var searchQuery = ""
|
||||||
private(set) var currentLocationText = "最近的景区"
|
private(set) var currentLocationText = "最近的景区"
|
||||||
|
private(set) var isLocating = false
|
||||||
private(set) var isLoading = false
|
private(set) var isLoading = false
|
||||||
|
|
||||||
var onStateChange: (() -> Void)?
|
var onStateChange: (() -> Void)?
|
||||||
@ -53,7 +54,7 @@ final class ScenicSelectionViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startLocation() async {
|
func startLocation() async {
|
||||||
isLoading = true
|
isLocating = true
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
do {
|
do {
|
||||||
let snapshot = try await locationProvider.requestSnapshot()
|
let snapshot = try await locationProvider.requestSnapshot()
|
||||||
@ -67,7 +68,7 @@ final class ScenicSelectionViewModel {
|
|||||||
} catch {
|
} catch {
|
||||||
onShowMessage?("定位失败: \(error.localizedDescription)")
|
onShowMessage?("定位失败: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
isLoading = false
|
isLocating = false
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -373,7 +373,7 @@ final class OrderListViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadOrderSourceLeads(person: OrderSourcePerson, api: OrderAPI) async {
|
func loadOrderSourceLeads(person: OrderSourcePerson, api: OrderAPI) async {
|
||||||
guard loadingOrderSourceLeadsFor == nil else { return }
|
guard loadingOrderSourceLeadsFor != person.key else { return }
|
||||||
loadingOrderSourceLeadsFor = person.key
|
loadingOrderSourceLeadsFor = person.key
|
||||||
notifyStateChange()
|
notifyStateChange()
|
||||||
defer {
|
defer {
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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..<items.count).map { startIndex + $0 }
|
||||||
|
uploadBatchQueue.append(
|
||||||
|
UploadBatch(id: UUID(), indices: indices, totalCount: indices.count)
|
||||||
|
)
|
||||||
|
notifyStateChange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteImage(at index: Int) {
|
||||||
|
guard index >= 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?()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -84,7 +84,11 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
|
|||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.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() {
|
@objc private func startScanBind() {
|
||||||
@ -105,10 +109,7 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func refreshTriggered() {
|
@objc private func refreshTriggered() {
|
||||||
Task {
|
Task { await viewModel.refresh(api: orderAPI) }
|
||||||
await viewModel.refresh(api: orderAPI)
|
|
||||||
refreshControl.endRefreshing()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentScanner() {
|
private func presentScanner() {
|
||||||
@ -137,7 +138,12 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func applyViewModel() {
|
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()
|
tableView.reloadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,9 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
|
|||||||
private let searchBar = CooperationOrderSearchBar()
|
private let searchBar = CooperationOrderSearchBar()
|
||||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||||
private let refreshControl = UIRefreshControl()
|
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 let emptyLabel = UILabel()
|
||||||
private var isApplyingTab = false
|
private var isApplyingTab = false
|
||||||
|
|
||||||
@ -42,6 +45,15 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
|
|||||||
tableView.register(AcquisitionOrderCell.self, forCellReuseIdentifier: AcquisitionOrderCell.reuseIdentifier)
|
tableView.register(AcquisitionOrderCell.self, forCellReuseIdentifier: AcquisitionOrderCell.reuseIdentifier)
|
||||||
tableView.refreshControl = refreshControl
|
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.text = "暂无相关合作订单"
|
||||||
emptyLabel.font = .systemFont(ofSize: 14)
|
emptyLabel.font = .systemFont(ofSize: 14)
|
||||||
emptyLabel.textColor = AppColor.textTertiary
|
emptyLabel.textColor = AppColor.textTertiary
|
||||||
@ -51,6 +63,7 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
|
|||||||
view.addSubview(tabBarView)
|
view.addSubview(tabBarView)
|
||||||
view.addSubview(searchBar)
|
view.addSubview(searchBar)
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
|
view.addSubview(loadingIndicator)
|
||||||
view.addSubview(emptyLabel)
|
view.addSubview(emptyLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,6 +80,9 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
|
|||||||
make.top.equalTo(searchBar.snp.bottom)
|
make.top.equalTo(searchBar.snp.bottom)
|
||||||
make.leading.trailing.bottom.equalToSuperview()
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
}
|
}
|
||||||
|
loadingIndicator.snp.makeConstraints { make in
|
||||||
|
make.center.equalTo(tableView)
|
||||||
|
}
|
||||||
emptyLabel.snp.makeConstraints { make in
|
emptyLabel.snp.makeConstraints { make in
|
||||||
make.center.equalTo(tableView)
|
make.center.equalTo(tableView)
|
||||||
}
|
}
|
||||||
@ -114,10 +130,7 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func refreshTriggered() {
|
@objc private func refreshTriggered() {
|
||||||
Task {
|
Task { await viewModel.refreshCurrentTab(api: orderAPI) }
|
||||||
await viewModel.refreshCurrentTab(api: orderAPI)
|
|
||||||
refreshControl.endRefreshing()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
@ -130,10 +143,42 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
|
|||||||
searchBar.textField.text = viewModel.searchText
|
searchBar.textField.text = viewModel.searchText
|
||||||
|
|
||||||
let isEmpty = viewModel.selectedTab == 0 ? viewModel.leads.isEmpty : viewModel.orders.isEmpty
|
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()
|
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 {
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
viewModel.selectedTab == 0 ? viewModel.leads.count : viewModel.orders.count
|
viewModel.selectedTab == 0 ? viewModel.leads.count : viewModel.orders.count
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,7 +63,7 @@ final class PermissionApplyStatusViewController: BaseViewController {
|
|||||||
Task { @MainActor in self?.navigateToEdit(pending: pending) }
|
Task { @MainActor in self?.navigateToEdit(pending: pending) }
|
||||||
}
|
}
|
||||||
bannerView.onApplyNewScenic = { [weak self] in
|
bannerView.onApplyNewScenic = { [weak self] in
|
||||||
self?.showToast("功能开发中")
|
self?.navigationController?.pushViewController(ScenicApplicationViewController(), animated: true)
|
||||||
}
|
}
|
||||||
Task { await viewModel.loadPendingData(api: homeAPI) }
|
Task { await viewModel.loadPendingData(api: homeAPI) }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,9 +16,19 @@ final class PermissionApplyViewController: BaseViewController {
|
|||||||
private let scrollView = UIScrollView()
|
private let scrollView = UIScrollView()
|
||||||
private let contentStack = UIStackView()
|
private let contentStack = UIStackView()
|
||||||
private let bannerView = PermissionApplyBannerView()
|
private let bannerView = PermissionApplyBannerView()
|
||||||
private let roleSelector = PermissionFormSelectorRow(title: "角色信息", subtitle: "单选 · 请选择您需要申请权限的角色", placeholder: "请选择角色")
|
private let roleSelector = PermissionFormSelectorRow(
|
||||||
private let existingScenicTags = PermissionSelectedTagsView()
|
title: "角色信息",
|
||||||
private let scenicSelector = PermissionFormSelectorRow(title: "新增景区", subtitle: "多选 · 请选择需要新增权限的景区", placeholder: "请选择景区")
|
tag: "单选",
|
||||||
|
description: "请选择您需要申请权限的角色",
|
||||||
|
placeholder: "请选择角色"
|
||||||
|
)
|
||||||
|
private let newRoleTags = PermissionSelectedTagsView()
|
||||||
|
private let scenicSelector = PermissionFormSelectorRow(
|
||||||
|
title: "选择景区",
|
||||||
|
tag: "可多选",
|
||||||
|
description: "请选择您需要申请权限的景区",
|
||||||
|
placeholder: "请选择景区"
|
||||||
|
)
|
||||||
private let selectedScenicTags = PermissionSelectedTagsView()
|
private let selectedScenicTags = PermissionSelectedTagsView()
|
||||||
private let submitButton = PermissionSubmitButton()
|
private let submitButton = PermissionSubmitButton()
|
||||||
|
|
||||||
@ -49,7 +59,7 @@ final class PermissionApplyViewController: BaseViewController {
|
|||||||
let card = UIView()
|
let card = UIView()
|
||||||
card.backgroundColor = .white
|
card.backgroundColor = .white
|
||||||
card.layer.cornerRadius = 8
|
card.layer.cornerRadius = 8
|
||||||
let cardStack = UIStackView(arrangedSubviews: [roleSelector, existingScenicTags, scenicSelector, selectedScenicTags])
|
let cardStack = UIStackView(arrangedSubviews: [roleSelector, newRoleTags, scenicSelector, selectedScenicTags])
|
||||||
cardStack.axis = .vertical
|
cardStack.axis = .vertical
|
||||||
cardStack.spacing = 20
|
cardStack.spacing = 20
|
||||||
card.addSubview(cardStack)
|
card.addSubview(cardStack)
|
||||||
@ -89,7 +99,7 @@ final class PermissionApplyViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bannerView.onApplyNewScenic = { [weak self] in
|
bannerView.onApplyNewScenic = { [weak self] in
|
||||||
self?.showToast("功能开发中")
|
self?.navigationController?.pushViewController(ScenicApplicationViewController(), animated: true)
|
||||||
}
|
}
|
||||||
roleSelector.onTap = { [weak self] in self?.presentRolePicker() }
|
roleSelector.onTap = { [weak self] in self?.presentRolePicker() }
|
||||||
scenicSelector.onTap = { [weak self] in
|
scenicSelector.onTap = { [weak self] in
|
||||||
@ -112,12 +122,11 @@ final class PermissionApplyViewController: BaseViewController {
|
|||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
if viewModel.selectedRoleName.isEmpty {
|
if viewModel.selectedRoleName.isEmpty {
|
||||||
roleSelector.setValue("请选择角色", isPlaceholder: true)
|
roleSelector.setValue("请选择角色", isPlaceholder: true)
|
||||||
existingScenicTags.isHidden = true
|
newRoleTags.isHidden = true
|
||||||
} else {
|
} else {
|
||||||
roleSelector.setValue(viewModel.selectedRoleName, isPlaceholder: false)
|
roleSelector.setValue(viewModel.selectedRoleName, isPlaceholder: false)
|
||||||
if let roleId = viewModel.selectedRoleId {
|
newRoleTags.apply(title: "新增角色", names: [viewModel.selectedRoleName]) { [weak self] _ in
|
||||||
let names = viewModel.existingScenics(for: roleId).map(\.name)
|
self?.viewModel.clearRoleSelection()
|
||||||
existingScenicTags.apply(title: "已开通景区", names: names, onRemove: nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,9 +134,15 @@ final class PermissionApplyViewController: BaseViewController {
|
|||||||
scenicSelector.setValue("请选择景区", isPlaceholder: true)
|
scenicSelector.setValue("请选择景区", isPlaceholder: true)
|
||||||
selectedScenicTags.isHidden = true
|
selectedScenicTags.isHidden = true
|
||||||
} else {
|
} 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(
|
selectedScenicTags.apply(
|
||||||
title: "待申请景区",
|
title: "新增景区",
|
||||||
names: viewModel.selectedScenicNames
|
names: viewModel.selectedScenicNames
|
||||||
) { [weak self] index in
|
) { [weak self] index in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@ -186,7 +201,7 @@ final class PermissionApplyViewController: BaseViewController {
|
|||||||
self?.viewModel.toggleScenic(scenic)
|
self?.viewModel.toggleScenic(scenic)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
alert.addAction(UIAlertAction(title: "完成", style: .cancel))
|
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||||
present(alert, animated: true)
|
present(alert, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -57,6 +57,8 @@ final class ScenicSelectionViewController: BaseViewController, UITableViewDataSo
|
|||||||
headerStack.spacing = 16
|
headerStack.spacing = 16
|
||||||
headerStack.isLayoutMarginsRelativeArrangement = true
|
headerStack.isLayoutMarginsRelativeArrangement = true
|
||||||
headerStack.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16)
|
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(headerStack)
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
@ -112,7 +114,7 @@ final class ScenicSelectionViewController: BaseViewController, UITableViewDataSo
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
locationBar.apply(locationText: viewModel.currentLocationText)
|
locationBar.apply(locationText: viewModel.currentLocationText, isLocating: viewModel.isLocating)
|
||||||
emptyLabel.isHidden = !viewModel.spots.isEmpty
|
emptyLabel.isHidden = !viewModel.spots.isEmpty
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
if viewModel.isLoading {
|
if viewModel.isLoading {
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 权限申请页橙色横幅(新景区申请入口暂 toast)。
|
/// 权限申请页橙色横幅(跳转景区申请)。
|
||||||
final class PermissionApplyBannerView: UIView {
|
final class PermissionApplyBannerView: UIView {
|
||||||
|
|
||||||
var onApplyNewScenic: (() -> Void)?
|
var onApplyNewScenic: (() -> Void)?
|
||||||
@ -18,11 +18,11 @@ final class PermissionApplyBannerView: UIView {
|
|||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
backgroundColor = AppColor.warningBackground
|
backgroundColor = AppColor.warningBackground
|
||||||
|
|
||||||
titleLabel.text = "希望开通全新景区?"
|
titleLabel.text = "没有找到心仪的景区"
|
||||||
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
titleLabel.textColor = AppColor.warning
|
titleLabel.textColor = AppColor.warning
|
||||||
|
|
||||||
applyButton.setTitle("新景区申请 ›", for: .normal)
|
applyButton.setTitle("申请平台开通新景区", for: .normal)
|
||||||
applyButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
applyButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||||
applyButton.setTitleColor(AppColor.warning, for: .normal)
|
applyButton.setTitleColor(AppColor.warning, for: .normal)
|
||||||
applyButton.addTarget(self, action: #selector(applyTapped), for: .touchUpInside)
|
applyButton.addTarget(self, action: #selector(applyTapped), for: .touchUpInside)
|
||||||
@ -52,50 +52,63 @@ final class PermissionApplyBannerView: UIView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 表单字段选择行。
|
/// 表单字段选择行,对齐 Android `FormField` + Selector。
|
||||||
final class PermissionFormSelectorRow: UIView {
|
final class PermissionFormSelectorRow: UIView {
|
||||||
|
|
||||||
var onTap: (() -> Void)?
|
var onTap: (() -> Void)?
|
||||||
|
|
||||||
private let titleLabel = UILabel()
|
private let titleLabel = UILabel()
|
||||||
private let subtitleLabel = UILabel()
|
private let tagLabel = UILabel()
|
||||||
|
private let descriptionLabel = UILabel()
|
||||||
private let valueLabel = UILabel()
|
private let valueLabel = UILabel()
|
||||||
private let chevron = UIImageView(image: UIImage(systemName: "chevron.down"))
|
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)
|
super.init(frame: .zero)
|
||||||
titleLabel.text = title
|
titleLabel.text = title
|
||||||
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
titleLabel.textColor = AppColor.textPrimary
|
titleLabel.textColor = AppColor.textPrimary
|
||||||
|
|
||||||
subtitleLabel.text = subtitle
|
tagLabel.text = tag
|
||||||
subtitleLabel.font = .systemFont(ofSize: 12)
|
tagLabel.font = .systemFont(ofSize: 12)
|
||||||
subtitleLabel.textColor = AppColor.textTertiary
|
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.text = placeholder
|
||||||
valueLabel.font = .systemFont(ofSize: 14)
|
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
|
chevron.contentMode = .scaleAspectFit
|
||||||
|
|
||||||
let tap = UITapGestureRecognizer(target: self, action: #selector(rowTapped))
|
let tap = UITapGestureRecognizer(target: self, action: #selector(rowTapped))
|
||||||
addGestureRecognizer(tap)
|
addGestureRecognizer(tap)
|
||||||
|
|
||||||
addSubview(titleLabel)
|
addSubview(titleLabel)
|
||||||
addSubview(subtitleLabel)
|
addSubview(tagLabel)
|
||||||
|
addSubview(descriptionLabel)
|
||||||
addSubview(valueLabel)
|
addSubview(valueLabel)
|
||||||
addSubview(chevron)
|
addSubview(chevron)
|
||||||
|
|
||||||
titleLabel.snp.makeConstraints { make in
|
titleLabel.snp.makeConstraints { make in
|
||||||
make.top.leading.equalToSuperview()
|
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.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||||
make.leading.equalToSuperview()
|
make.leading.trailing.equalToSuperview()
|
||||||
}
|
}
|
||||||
valueLabel.snp.makeConstraints { make in
|
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.leading.bottom.equalToSuperview()
|
||||||
make.trailing.lessThanOrEqualTo(chevron.snp.leading).offset(-8)
|
make.trailing.lessThanOrEqualTo(chevron.snp.leading).offset(-8)
|
||||||
}
|
}
|
||||||
@ -113,7 +126,7 @@ final class PermissionFormSelectorRow: UIView {
|
|||||||
|
|
||||||
func setValue(_ text: String, isPlaceholder: Bool) {
|
func setValue(_ text: String, isPlaceholder: Bool) {
|
||||||
valueLabel.text = text
|
valueLabel.text = text
|
||||||
valueLabel.textColor = isPlaceholder ? AppColor.textTertiary : AppColor.textPrimary
|
valueLabel.textColor = isPlaceholder ? UIColor(hex: 0xB3B8C2) : AppColor.textPrimary
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func rowTapped() {
|
@objc private func rowTapped() {
|
||||||
@ -129,8 +142,8 @@ final class PermissionSelectedTagsView: UIView {
|
|||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
titleLabel.textColor = AppColor.textPrimary
|
titleLabel.textColor = UIColor(hex: 0x111827)
|
||||||
stackView.axis = .vertical
|
stackView.axis = .vertical
|
||||||
stackView.spacing = 8
|
stackView.spacing = 8
|
||||||
addSubview(titleLabel)
|
addSubview(titleLabel)
|
||||||
@ -229,7 +242,7 @@ final class PermissionSubmitButton: UIButton {
|
|||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
setTitle("提交申请", for: .normal)
|
setTitle("提交审核", for: .normal)
|
||||||
setTitleColor(.white, for: .normal)
|
setTitleColor(.white, for: .normal)
|
||||||
titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||||
backgroundColor = AppColor.primary
|
backgroundColor = AppColor.primary
|
||||||
@ -247,6 +260,6 @@ final class PermissionSubmitButton: UIButton {
|
|||||||
func setSubmitting(_ submitting: Bool) {
|
func setSubmitting(_ submitting: Bool) {
|
||||||
isEnabled = !submitting
|
isEnabled = !submitting
|
||||||
alpha = submitting ? 0.6 : 1
|
alpha = submitting ? 0.6 : 1
|
||||||
setTitle(submitting ? "提交中..." : "提交申请", for: .normal)
|
setTitle(submitting ? "提交中..." : "提交审核", for: .normal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,8 +41,10 @@ final class ScenicPermissionBannerView: UIView {
|
|||||||
make.centerY.equalToSuperview()
|
make.centerY.equalToSuperview()
|
||||||
}
|
}
|
||||||
snp.makeConstraints { make in
|
snp.makeConstraints { make in
|
||||||
make.height.greaterThanOrEqualTo(44)
|
make.height.equalTo(44)
|
||||||
}
|
}
|
||||||
|
setContentHuggingPriority(.required, for: .vertical)
|
||||||
|
setContentCompressionResistancePriority(.required, for: .vertical)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@ -146,18 +148,20 @@ final class ScenicCurrentLocationBar: UIView {
|
|||||||
|
|
||||||
iconView.snp.makeConstraints { make in
|
iconView.snp.makeConstraints { make in
|
||||||
make.leading.equalToSuperview()
|
make.leading.equalToSuperview()
|
||||||
make.centerY.equalToSuperview()
|
make.centerY.equalTo(locationLabel)
|
||||||
make.size.equalTo(16)
|
make.size.equalTo(16)
|
||||||
}
|
}
|
||||||
locationLabel.snp.makeConstraints { make in
|
locationLabel.snp.makeConstraints { make in
|
||||||
make.leading.equalTo(iconView.snp.trailing).offset(4)
|
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)
|
make.trailing.lessThanOrEqualTo(relocateButton.snp.leading).offset(-12)
|
||||||
}
|
}
|
||||||
relocateButton.snp.makeConstraints { make in
|
relocateButton.snp.makeConstraints { make in
|
||||||
make.trailing.equalToSuperview()
|
make.trailing.equalToSuperview()
|
||||||
make.centerY.equalToSuperview()
|
make.centerY.equalTo(locationLabel)
|
||||||
}
|
}
|
||||||
|
setContentHuggingPriority(.required, for: .vertical)
|
||||||
|
setContentCompressionResistancePriority(.required, for: .vertical)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
@ -165,8 +169,10 @@ final class ScenicCurrentLocationBar: UIView {
|
|||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
func apply(locationText: String) {
|
func apply(locationText: String, isLocating: Bool = false) {
|
||||||
locationLabel.text = locationText
|
locationLabel.text = isLocating ? "定位中..." : locationText
|
||||||
|
relocateButton.isEnabled = !isLocating
|
||||||
|
relocateButton.alpha = isLocating ? 0.5 : 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func relocateTapped() {
|
@objc private func relocateTapped() {
|
||||||
|
|||||||
189
suixinkan/UI/Orders/OrderSourceLeadListViewController.swift
Normal file
189
suixinkan/UI/Orders/OrderSourceLeadListViewController.swift
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,58 +6,39 @@
|
|||||||
import SnapKit
|
import SnapKit
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
/// 订单来源选择 Bottom Sheet,对齐 Android `OrderSourcePicker` 弹层。
|
/// 订单来源获客员选择 Bottom Sheet 首页,点击获客员后 push 带客单页。
|
||||||
final class OrderSourcePickerViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
final class OrderSourcePickerViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
|
||||||
|
|
||||||
private enum MenuLevel {
|
|
||||||
case person
|
|
||||||
case lead
|
|
||||||
}
|
|
||||||
|
|
||||||
private let orderNumber: String
|
private let orderNumber: String
|
||||||
private let viewModel: OrderListViewModel
|
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 onLeadBound: (OrderSourceSelection) -> Void
|
||||||
private let onCallPhone: (String) -> Void
|
private let onCallPhone: (String) -> Void
|
||||||
private let onPreviewImages: ([String], Int) -> 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 titleLabel = UILabel()
|
||||||
private let subtitleLabel = 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 tableView = UITableView(frame: .zero, style: .plain)
|
||||||
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
private let emptyLabel = UILabel()
|
private let emptyLabel = UILabel()
|
||||||
|
|
||||||
private var referralOrder: BoundReferralOrderEntity?
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
orderNumber: String,
|
orderNumber: String,
|
||||||
viewModel: OrderListViewModel,
|
viewModel: OrderListViewModel,
|
||||||
referralOrder: BoundReferralOrderEntity?,
|
referralOrder: BoundReferralOrderEntity?,
|
||||||
onPersonSelected: @escaping (OrderSourcePerson) -> Void,
|
loadLeads: @escaping (OrderSourcePerson) async -> Void,
|
||||||
onLeadBound: @escaping (OrderSourceSelection) -> Void,
|
onLeadBound: @escaping (OrderSourceSelection) -> Void,
|
||||||
onCallPhone: @escaping (String) -> Void,
|
onCallPhone: @escaping (String) -> Void,
|
||||||
onPreviewImages: @escaping ([String], Int) -> Void
|
onPreviewImages: @escaping ([String], Int) -> Void
|
||||||
) {
|
) {
|
||||||
self.orderNumber = orderNumber
|
self.orderNumber = orderNumber
|
||||||
self.viewModel = viewModel
|
self.viewModel = viewModel
|
||||||
self.onPersonSelected = onPersonSelected
|
self.referralOrder = referralOrder
|
||||||
|
self.loadLeads = loadLeads
|
||||||
self.onLeadBound = onLeadBound
|
self.onLeadBound = onLeadBound
|
||||||
self.onCallPhone = onCallPhone
|
self.onCallPhone = onCallPhone
|
||||||
self.onPreviewImages = onPreviewImages
|
self.onPreviewImages = onPreviewImages
|
||||||
self.referralOrder = referralOrder
|
|
||||||
self.pendingPerson = viewModel.orderSourceSelection(
|
|
||||||
orderNumber: orderNumber,
|
|
||||||
referralOrder: referralOrder
|
|
||||||
)?.person
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,6 +56,8 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo
|
|||||||
|
|
||||||
func reloadFromViewModel() {
|
func reloadFromViewModel() {
|
||||||
applyViewModel()
|
applyViewModel()
|
||||||
|
(navigationController?.topViewController as? OrderSourceLeadListViewController)?
|
||||||
|
.reloadFromViewModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupUI() {
|
private func setupUI() {
|
||||||
@ -87,72 +70,37 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo
|
|||||||
subtitleLabel.text = "选择人员后,再选择该人员创建的带客单"
|
subtitleLabel.text = "选择人员后,再选择该人员创建的带客单"
|
||||||
subtitleLabel.numberOfLines = 0
|
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.separatorStyle = .none
|
||||||
tableView.backgroundColor = .white
|
tableView.backgroundColor = .white
|
||||||
tableView.dataSource = self
|
tableView.dataSource = self
|
||||||
tableView.delegate = self
|
tableView.delegate = self
|
||||||
tableView.register(OrderSourcePersonCell.self, forCellReuseIdentifier: OrderSourcePersonCell.reuseIdentifier)
|
tableView.register(OrderSourcePersonCell.self, forCellReuseIdentifier: OrderSourcePersonCell.reuseIdentifier)
|
||||||
tableView.register(OrderSourceLeadCell.self, forCellReuseIdentifier: OrderSourceLeadCell.reuseIdentifier)
|
|
||||||
|
|
||||||
loadingIndicator.hidesWhenStopped = true
|
loadingIndicator.hidesWhenStopped = true
|
||||||
|
loadingIndicator.color = AppColor.primary
|
||||||
|
|
||||||
emptyLabel.font = .systemFont(ofSize: 14)
|
emptyLabel.font = .systemFont(ofSize: 14)
|
||||||
emptyLabel.textColor = AppColor.text666
|
emptyLabel.textColor = AppColor.text666
|
||||||
emptyLabel.textAlignment = .center
|
emptyLabel.textAlignment = .center
|
||||||
emptyLabel.numberOfLines = 0
|
emptyLabel.numberOfLines = 0
|
||||||
|
emptyLabel.text = "暂无合作获客员"
|
||||||
|
|
||||||
view.addSubview(headerStack)
|
view.addSubview(titleLabel)
|
||||||
|
view.addSubview(subtitleLabel)
|
||||||
view.addSubview(tableView)
|
view.addSubview(tableView)
|
||||||
view.addSubview(loadingIndicator)
|
view.addSubview(loadingIndicator)
|
||||||
view.addSubview(emptyLabel)
|
view.addSubview(emptyLabel)
|
||||||
|
|
||||||
headerStack.snp.makeConstraints { make in
|
titleLabel.snp.makeConstraints { make in
|
||||||
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md)
|
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md)
|
||||||
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
|
||||||
}
|
}
|
||||||
backButton.snp.makeConstraints { make in
|
subtitleLabel.snp.makeConstraints { make in
|
||||||
make.width.height.equalTo(28)
|
make.top.equalTo(titleLabel.snp.bottom).offset(5)
|
||||||
}
|
make.leading.trailing.equalTo(titleLabel)
|
||||||
leadCallButton.snp.makeConstraints { make in
|
|
||||||
make.width.height.equalTo(32)
|
|
||||||
}
|
}
|
||||||
tableView.snp.makeConstraints { make in
|
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()
|
make.leading.trailing.bottom.equalToSuperview()
|
||||||
}
|
}
|
||||||
loadingIndicator.snp.makeConstraints { make in
|
loadingIndicator.snp.makeConstraints { make in
|
||||||
@ -165,113 +113,54 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func applyViewModel() {
|
private func applyViewModel() {
|
||||||
let currentSelection = viewModel.orderSourceSelection(
|
if viewModel.isLoadingOrderSourcePeople {
|
||||||
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 {
|
|
||||||
loadingIndicator.startAnimating()
|
loadingIndicator.startAnimating()
|
||||||
tableView.isHidden = true
|
tableView.isHidden = true
|
||||||
emptyLabel.isHidden = true
|
emptyLabel.isHidden = true
|
||||||
} else {
|
} else {
|
||||||
loadingIndicator.stopAnimating()
|
loadingIndicator.stopAnimating()
|
||||||
tableView.isHidden = false
|
tableView.isHidden = false
|
||||||
let isEmpty: Bool
|
let isEmpty = viewModel.orderSourcePeople.isEmpty
|
||||||
switch level {
|
|
||||||
case .person:
|
|
||||||
isEmpty = viewModel.orderSourcePeople.isEmpty
|
|
||||||
case .lead:
|
|
||||||
isEmpty = leadsForPendingPerson.isEmpty
|
|
||||||
}
|
|
||||||
emptyLabel.isHidden = !isEmpty
|
emptyLabel.isHidden = !isEmpty
|
||||||
emptyLabel.text = level == .person ? "暂无合作获客员" : "暂无可绑定带客单"
|
|
||||||
tableView.reloadData()
|
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 {
|
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
switch level {
|
viewModel.orderSourcePeople.count
|
||||||
case .person:
|
|
||||||
return viewModel.orderSourcePeople.count
|
|
||||||
case .lead:
|
|
||||||
return leadsForPendingPerson.count
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
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(
|
let currentSelection = viewModel.orderSourceSelection(
|
||||||
orderNumber: orderNumber,
|
orderNumber: orderNumber,
|
||||||
referralOrder: referralOrder
|
referralOrder: referralOrder
|
||||||
)
|
)
|
||||||
switch level {
|
cell.configure(
|
||||||
case .person:
|
person: person,
|
||||||
let cell = tableView.dequeueReusableCell(
|
selected: currentSelection?.person.key == person.key
|
||||||
withIdentifier: OrderSourcePersonCell.reuseIdentifier,
|
)
|
||||||
for: indexPath
|
cell.onCallPhone = onCallPhone
|
||||||
) as! OrderSourcePersonCell
|
return cell
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||||
guard level == .person else { return }
|
|
||||||
let person = viewModel.orderSourcePeople[indexPath.row]
|
let person = viewModel.orderSourcePeople[indexPath.row]
|
||||||
pendingPerson = person
|
let leadController = OrderSourceLeadListViewController(
|
||||||
level = .lead
|
person: person,
|
||||||
onPersonSelected(person)
|
orderNumber: orderNumber,
|
||||||
applyViewModel()
|
viewModel: viewModel,
|
||||||
|
referralOrder: referralOrder,
|
||||||
|
loadLeads: loadLeads,
|
||||||
|
onLeadBound: onLeadBound,
|
||||||
|
onCallPhone: onCallPhone,
|
||||||
|
onPreviewImages: onPreviewImages
|
||||||
|
)
|
||||||
|
navigationController?.setNavigationBarHidden(false, animated: true)
|
||||||
|
navigationController?.pushViewController(leadController, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -473,11 +473,9 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
|
|||||||
orderNumber: order.orderNumber,
|
orderNumber: order.orderNumber,
|
||||||
viewModel: self.orderListViewModel,
|
viewModel: self.orderListViewModel,
|
||||||
referralOrder: order.referralOrder,
|
referralOrder: order.referralOrder,
|
||||||
onPersonSelected: { [weak self] person in
|
loadLeads: { [weak self] person in
|
||||||
guard let self else { return }
|
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
|
onLeadBound: { [weak self] selection in
|
||||||
guard let self, let orderNumber = self.pendingOrderSourceNumber else { return }
|
guard let self, let orderNumber = self.pendingOrderSourceNumber else { return }
|
||||||
|
|||||||
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -54,7 +54,7 @@ final class PermissionApplyViewModelTests: XCTestCase {
|
|||||||
viewModel.onShowMessage = { message = $0 }
|
viewModel.onShowMessage = { message = $0 }
|
||||||
|
|
||||||
await viewModel.submit(api: api)
|
await viewModel.submit(api: api)
|
||||||
XCTAssertEqual(message, "请先选择角色")
|
XCTAssertEqual(message, "请选择角色")
|
||||||
|
|
||||||
viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true))
|
viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true))
|
||||||
await viewModel.submit(api: api)
|
await viewModel.submit(api: api)
|
||||||
|
|||||||
107
suixinkanTests/ScenicApplicationViewModelTests.swift
Normal file
107
suixinkanTests/ScenicApplicationViewModelTests.swift
Normal file
@ -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, "提交成功")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user