添加景区入驻申请流程并对齐 Android

This commit is contained in:
2026-07-07 11:09:27 +08:00
parent c4057537d2
commit 187ba990d4
22 changed files with 2202 additions and 216 deletions

View File

@ -39,6 +39,23 @@ final class OSSUploadService {
try await uploadFile(data: data, fileName: fileName, fileType: 2, scenicId: scenicId, moduleType: "bank_card", onProgress: onProgress)
}
/// Android `moduleType = scenic_apply`
func uploadScenicApplyImage(
data: Data,
fileName: String,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String {
try await uploadFile(
data: data,
fileName: fileName,
fileType: 2,
scenicId: scenicId,
moduleType: "scenic_apply",
onProgress: onProgress
)
}
/// Android `moduleType = task_upload`
func uploadTaskFile(
data: Data,
@ -145,6 +162,8 @@ enum OSSUploadPolicy {
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "task_upload":
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "scenic_apply":
return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
default:
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
}

View File

@ -33,7 +33,6 @@ final class CooperationAcquirerViewModel {
}
func loadAcquirers(api: OrderAPI, initial: Bool = false) async {
if initial { isRefreshing = false }
let storeId = appStore.currentStoreId
let storeIdParam = storeId > 0 ? String(storeId) : nil
do {

View File

@ -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
)
)
}
}

View File

@ -163,9 +163,23 @@ final class PermissionApplyViewModel {
.flatMap(\.scenic)
}
func clearRoleSelection() {
selectedRoleId = nil
selectedRoleName = ""
selectedScenicIds = []
selectedScenicNames = []
availableScenics = []
availableRoles = availableRoles.map { role in
var copy = role
copy.isSelected = false
return copy
}
notifyStateChange()
}
func submit(api: HomeAPI) async {
guard let roleId = selectedRoleId else {
onShowMessage?("选择角色")
onShowMessage?("请选择角色")
return
}
let newScenicIds = selectedScenicIds.filter { id in

View File

@ -17,6 +17,7 @@ final class ScenicSelectionViewModel {
private(set) var spots: [ScenicSpotDisplayItem] = []
private(set) var searchQuery = ""
private(set) var currentLocationText = "最近的景区"
private(set) var isLocating = false
private(set) var isLoading = false
var onStateChange: (() -> Void)?
@ -53,7 +54,7 @@ final class ScenicSelectionViewModel {
}
func startLocation() async {
isLoading = true
isLocating = true
notifyStateChange()
do {
let snapshot = try await locationProvider.requestSnapshot()
@ -67,7 +68,7 @@ final class ScenicSelectionViewModel {
} catch {
onShowMessage?("定位失败: \(error.localizedDescription)")
}
isLoading = false
isLocating = false
notifyStateChange()
}

View File

@ -373,7 +373,7 @@ final class OrderListViewModel {
}
func loadOrderSourceLeads(person: OrderSourcePerson, api: OrderAPI) async {
guard loadingOrderSourceLeadsFor == nil else { return }
guard loadingOrderSourceLeadsFor != person.key else { return }
loadingOrderSourceLeadsFor = person.key
notifyStateChange()
defer {

View File

@ -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
}

View File

@ -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(&copy)
return copy.indices.isEmpty ? nil : copy
}
}
private func notifyStateChange() {
onStateChange?()
}
}

View File

@ -84,7 +84,11 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
override func viewDidLoad() {
super.viewDidLoad()
Task { await viewModel.loadAcquirers(api: orderAPI, initial: true) }
Task {
showLoading()
defer { hideLoading() }
await viewModel.loadAcquirers(api: orderAPI, initial: true)
}
}
@objc private func startScanBind() {
@ -105,10 +109,7 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
}
@objc private func refreshTriggered() {
Task {
await viewModel.refresh(api: orderAPI)
refreshControl.endRefreshing()
}
Task { await viewModel.refresh(api: orderAPI) }
}
private func presentScanner() {
@ -137,7 +138,12 @@ final class CooperationAcquirerViewController: BaseViewController, UITableViewDa
}
private func applyViewModel() {
emptyLabel.isHidden = !viewModel.acquirers.isEmpty
if viewModel.isRefreshing {
if !refreshControl.isRefreshing { refreshControl.beginRefreshing() }
} else {
refreshControl.endRefreshing()
}
emptyLabel.isHidden = !viewModel.acquirers.isEmpty || viewModel.isRefreshing
tableView.reloadData()
}

View File

@ -16,6 +16,9 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
private let searchBar = CooperationOrderSearchBar()
private let tableView = UITableView(frame: .zero, style: .plain)
private let refreshControl = UIRefreshControl()
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let footerSpinner = UIActivityIndicatorView(style: .medium)
private let footerLabel = UILabel()
private let emptyLabel = UILabel()
private var isApplyingTab = false
@ -42,6 +45,15 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
tableView.register(AcquisitionOrderCell.self, forCellReuseIdentifier: AcquisitionOrderCell.reuseIdentifier)
tableView.refreshControl = refreshControl
loadingIndicator.hidesWhenStopped = true
loadingIndicator.color = AppColor.primary
footerSpinner.hidesWhenStopped = true
footerSpinner.color = AppColor.primary
footerLabel.font = .systemFont(ofSize: 14)
footerLabel.textColor = AppColor.textTertiary
footerLabel.textAlignment = .center
footerLabel.text = "没有更多"
emptyLabel.text = "暂无相关合作订单"
emptyLabel.font = .systemFont(ofSize: 14)
emptyLabel.textColor = AppColor.textTertiary
@ -51,6 +63,7 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
view.addSubview(tabBarView)
view.addSubview(searchBar)
view.addSubview(tableView)
view.addSubview(loadingIndicator)
view.addSubview(emptyLabel)
}
@ -67,6 +80,9 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
make.top.equalTo(searchBar.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}
loadingIndicator.snp.makeConstraints { make in
make.center.equalTo(tableView)
}
emptyLabel.snp.makeConstraints { make in
make.center.equalTo(tableView)
}
@ -114,10 +130,7 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
}
@objc private func refreshTriggered() {
Task {
await viewModel.refreshCurrentTab(api: orderAPI)
refreshControl.endRefreshing()
}
Task { await viewModel.refreshCurrentTab(api: orderAPI) }
}
private func applyViewModel() {
@ -130,10 +143,42 @@ final class CooperationOrderListViewController: BaseViewController, UITableViewD
searchBar.textField.text = viewModel.searchText
let isEmpty = viewModel.selectedTab == 0 ? viewModel.leads.isEmpty : viewModel.orders.isEmpty
emptyLabel.isHidden = !isEmpty || viewModel.isRefreshing
let isLoading = viewModel.isRefreshing && isEmpty
if viewModel.isRefreshing {
if !refreshControl.isRefreshing { refreshControl.beginRefreshing() }
} else {
refreshControl.endRefreshing()
}
if isLoading {
loadingIndicator.startAnimating()
emptyLabel.isHidden = true
tableView.tableFooterView = nil
} else {
loadingIndicator.stopAnimating()
emptyLabel.isHidden = !isEmpty
tableView.tableFooterView = tableFooterView(isEmpty: isEmpty)
}
tableView.reloadData()
}
private func tableFooterView(isEmpty: Bool) -> UIView? {
guard !isEmpty else { return nil }
if viewModel.isLoadingMore {
let container = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 44))
footerSpinner.center = CGPoint(x: container.bounds.midX, y: container.bounds.midY)
footerSpinner.autoresizingMask = [.flexibleLeftMargin, .flexibleRightMargin]
container.addSubview(footerSpinner)
footerSpinner.startAnimating()
return container
}
footerSpinner.stopAnimating()
guard !viewModel.canLoadMore else { return nil }
footerLabel.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 44)
return footerLabel
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
viewModel.selectedTab == 0 ? viewModel.leads.count : viewModel.orders.count
}

View File

@ -63,7 +63,7 @@ final class PermissionApplyStatusViewController: BaseViewController {
Task { @MainActor in self?.navigateToEdit(pending: pending) }
}
bannerView.onApplyNewScenic = { [weak self] in
self?.showToast("功能开发中")
self?.navigationController?.pushViewController(ScenicApplicationViewController(), animated: true)
}
Task { await viewModel.loadPendingData(api: homeAPI) }
}

View File

@ -16,9 +16,19 @@ final class PermissionApplyViewController: BaseViewController {
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let bannerView = PermissionApplyBannerView()
private let roleSelector = PermissionFormSelectorRow(title: "角色信息", subtitle: "单选 · 请选择您需要申请权限的角色", placeholder: "请选择角色")
private let existingScenicTags = PermissionSelectedTagsView()
private let scenicSelector = PermissionFormSelectorRow(title: "新增景区", subtitle: "多选 · 请选择需要新增权限的景区", placeholder: "请选择景区")
private let roleSelector = PermissionFormSelectorRow(
title: "角色信息",
tag: "单选",
description: "请选择您需要申请权限的角色",
placeholder: "请选择角色"
)
private let newRoleTags = PermissionSelectedTagsView()
private let scenicSelector = PermissionFormSelectorRow(
title: "选择景区",
tag: "可多选",
description: "请选择您需要申请权限的景区",
placeholder: "请选择景区"
)
private let selectedScenicTags = PermissionSelectedTagsView()
private let submitButton = PermissionSubmitButton()
@ -49,7 +59,7 @@ final class PermissionApplyViewController: BaseViewController {
let card = UIView()
card.backgroundColor = .white
card.layer.cornerRadius = 8
let cardStack = UIStackView(arrangedSubviews: [roleSelector, existingScenicTags, scenicSelector, selectedScenicTags])
let cardStack = UIStackView(arrangedSubviews: [roleSelector, newRoleTags, scenicSelector, selectedScenicTags])
cardStack.axis = .vertical
cardStack.spacing = 20
card.addSubview(cardStack)
@ -89,7 +99,7 @@ final class PermissionApplyViewController: BaseViewController {
}
bannerView.onApplyNewScenic = { [weak self] in
self?.showToast("功能开发中")
self?.navigationController?.pushViewController(ScenicApplicationViewController(), animated: true)
}
roleSelector.onTap = { [weak self] in self?.presentRolePicker() }
scenicSelector.onTap = { [weak self] in
@ -112,12 +122,11 @@ final class PermissionApplyViewController: BaseViewController {
private func applyViewModel() {
if viewModel.selectedRoleName.isEmpty {
roleSelector.setValue("请选择角色", isPlaceholder: true)
existingScenicTags.isHidden = true
newRoleTags.isHidden = true
} else {
roleSelector.setValue(viewModel.selectedRoleName, isPlaceholder: false)
if let roleId = viewModel.selectedRoleId {
let names = viewModel.existingScenics(for: roleId).map(\.name)
existingScenicTags.apply(title: "已开通景区", names: names, onRemove: nil)
newRoleTags.apply(title: "新增角色", names: [viewModel.selectedRoleName]) { [weak self] _ in
self?.viewModel.clearRoleSelection()
}
}
@ -125,9 +134,15 @@ final class PermissionApplyViewController: BaseViewController {
scenicSelector.setValue("请选择景区", isPlaceholder: true)
selectedScenicTags.isHidden = true
} else {
scenicSelector.setValue("已选 \(viewModel.selectedScenicNames.count) 个景区", isPlaceholder: false)
let displayText: String
if viewModel.selectedScenicNames.count == 1 {
displayText = viewModel.selectedScenicNames[0]
} else {
displayText = "已选中\(viewModel.selectedScenicNames.count)个景区"
}
scenicSelector.setValue(displayText, isPlaceholder: false)
selectedScenicTags.apply(
title: "待申请景区",
title: "新增景区",
names: viewModel.selectedScenicNames
) { [weak self] index in
guard let self else { return }
@ -186,7 +201,7 @@ final class PermissionApplyViewController: BaseViewController {
self?.viewModel.toggleScenic(scenic)
})
}
alert.addAction(UIAlertAction(title: "完成", style: .cancel))
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}

View File

@ -57,6 +57,8 @@ final class ScenicSelectionViewController: BaseViewController, UITableViewDataSo
headerStack.spacing = 16
headerStack.isLayoutMarginsRelativeArrangement = true
headerStack.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16)
headerStack.setContentHuggingPriority(.required, for: .vertical)
headerStack.setContentCompressionResistancePriority(.required, for: .vertical)
view.addSubview(headerStack)
view.addSubview(tableView)
@ -112,7 +114,7 @@ final class ScenicSelectionViewController: BaseViewController, UITableViewDataSo
}
private func applyViewModel() {
locationBar.apply(locationText: viewModel.currentLocationText)
locationBar.apply(locationText: viewModel.currentLocationText, isLocating: viewModel.isLocating)
emptyLabel.isHidden = !viewModel.spots.isEmpty
tableView.reloadData()
if viewModel.isLoading {

View File

@ -6,7 +6,7 @@
import SnapKit
import UIKit
/// toast
///
final class PermissionApplyBannerView: UIView {
var onApplyNewScenic: (() -> Void)?
@ -18,11 +18,11 @@ final class PermissionApplyBannerView: UIView {
super.init(frame: frame)
backgroundColor = AppColor.warningBackground
titleLabel.text = "希望开通全新景区"
titleLabel.text = "没有找到心仪的景区"
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = AppColor.warning
applyButton.setTitle("新景区申请 ", for: .normal)
applyButton.setTitle("申请平台开通新景区", for: .normal)
applyButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
applyButton.setTitleColor(AppColor.warning, for: .normal)
applyButton.addTarget(self, action: #selector(applyTapped), for: .touchUpInside)
@ -52,50 +52,63 @@ final class PermissionApplyBannerView: UIView {
}
}
///
/// Android `FormField` + Selector
final class PermissionFormSelectorRow: UIView {
var onTap: (() -> Void)?
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let tagLabel = UILabel()
private let descriptionLabel = UILabel()
private let valueLabel = UILabel()
private let chevron = UIImageView(image: UIImage(systemName: "chevron.down"))
init(title: String, subtitle: String, placeholder: String) {
init(title: String, tag: String, description: String, placeholder: String) {
super.init(frame: .zero)
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.textColor = AppColor.textPrimary
subtitleLabel.text = subtitle
subtitleLabel.font = .systemFont(ofSize: 12)
subtitleLabel.textColor = AppColor.textTertiary
tagLabel.text = tag
tagLabel.font = .systemFont(ofSize: 12)
tagLabel.textColor = AppColor.textTertiary
tagLabel.textAlignment = .right
descriptionLabel.text = description
descriptionLabel.font = .systemFont(ofSize: 12)
descriptionLabel.textColor = UIColor(hex: 0xB3B8C2)
descriptionLabel.numberOfLines = 0
valueLabel.text = placeholder
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.textTertiary
valueLabel.textColor = UIColor(hex: 0xB3B8C2)
chevron.tintColor = AppColor.textTertiary
chevron.tintColor = UIColor(hex: 0xB3B8C2)
chevron.contentMode = .scaleAspectFit
let tap = UITapGestureRecognizer(target: self, action: #selector(rowTapped))
addGestureRecognizer(tap)
addSubview(titleLabel)
addSubview(subtitleLabel)
addSubview(tagLabel)
addSubview(descriptionLabel)
addSubview(valueLabel)
addSubview(chevron)
titleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview()
}
subtitleLabel.snp.makeConstraints { make in
tagLabel.snp.makeConstraints { make in
make.centerY.equalTo(titleLabel)
make.trailing.equalToSuperview()
make.leading.greaterThanOrEqualTo(titleLabel.snp.trailing).offset(8)
}
descriptionLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.leading.equalToSuperview()
make.leading.trailing.equalToSuperview()
}
valueLabel.snp.makeConstraints { make in
make.top.equalTo(subtitleLabel.snp.bottom).offset(12)
make.top.equalTo(descriptionLabel.snp.bottom).offset(8)
make.leading.bottom.equalToSuperview()
make.trailing.lessThanOrEqualTo(chevron.snp.leading).offset(-8)
}
@ -113,7 +126,7 @@ final class PermissionFormSelectorRow: UIView {
func setValue(_ text: String, isPlaceholder: Bool) {
valueLabel.text = text
valueLabel.textColor = isPlaceholder ? AppColor.textTertiary : AppColor.textPrimary
valueLabel.textColor = isPlaceholder ? UIColor(hex: 0xB3B8C2) : AppColor.textPrimary
}
@objc private func rowTapped() {
@ -129,8 +142,8 @@ final class PermissionSelectedTagsView: UIView {
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = AppColor.textPrimary
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.textColor = UIColor(hex: 0x111827)
stackView.axis = .vertical
stackView.spacing = 8
addSubview(titleLabel)
@ -229,7 +242,7 @@ final class PermissionSubmitButton: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
setTitle("提交申请", for: .normal)
setTitle("提交审核", for: .normal)
setTitleColor(.white, for: .normal)
titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
backgroundColor = AppColor.primary
@ -247,6 +260,6 @@ final class PermissionSubmitButton: UIButton {
func setSubmitting(_ submitting: Bool) {
isEnabled = !submitting
alpha = submitting ? 0.6 : 1
setTitle(submitting ? "提交中..." : "提交申请", for: .normal)
setTitle(submitting ? "提交中..." : "提交审核", for: .normal)
}
}

View File

@ -41,8 +41,10 @@ final class ScenicPermissionBannerView: UIView {
make.centerY.equalToSuperview()
}
snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(44)
make.height.equalTo(44)
}
setContentHuggingPriority(.required, for: .vertical)
setContentCompressionResistancePriority(.required, for: .vertical)
}
@available(*, unavailable)
@ -146,18 +148,20 @@ final class ScenicCurrentLocationBar: UIView {
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview()
make.centerY.equalToSuperview()
make.centerY.equalTo(locationLabel)
make.size.equalTo(16)
}
locationLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(4)
make.centerY.equalToSuperview()
make.top.bottom.equalToSuperview().inset(8)
make.trailing.lessThanOrEqualTo(relocateButton.snp.leading).offset(-12)
}
relocateButton.snp.makeConstraints { make in
make.trailing.equalToSuperview()
make.centerY.equalToSuperview()
make.centerY.equalTo(locationLabel)
}
setContentHuggingPriority(.required, for: .vertical)
setContentCompressionResistancePriority(.required, for: .vertical)
}
@available(*, unavailable)
@ -165,8 +169,10 @@ final class ScenicCurrentLocationBar: UIView {
fatalError("init(coder:) has not been implemented")
}
func apply(locationText: String) {
locationLabel.text = locationText
func apply(locationText: String, isLocating: Bool = false) {
locationLabel.text = isLocating ? "定位中..." : locationText
relocateButton.isEnabled = !isLocating
relocateButton.alpha = isLocating ? 0.5 : 1
}
@objc private func relocateTapped() {

View 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
}
}

View File

@ -6,58 +6,39 @@
import SnapKit
import UIKit
/// Bottom Sheet Android `OrderSourcePicker`
/// Bottom Sheet push
final class OrderSourcePickerViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
private enum MenuLevel {
case person
case lead
}
private let orderNumber: String
private let viewModel: OrderListViewModel
private let onPersonSelected: (OrderSourcePerson) -> Void
private let referralOrder: BoundReferralOrderEntity?
private let loadLeads: (OrderSourcePerson) async -> Void
private let onLeadBound: (OrderSourceSelection) -> Void
private let onCallPhone: (String) -> Void
private let onPreviewImages: ([String], Int) -> Void
private var level: MenuLevel = .person
private var pendingPerson: OrderSourcePerson?
private let headerStack = UIStackView()
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let leadHeaderRow = UIStackView()
private let backButton = UIButton(type: .system)
private let leadTitleLabel = UILabel()
private let leadPhoneButton = UIButton(type: .system)
private let leadCallButton = UIButton(type: .system)
private let tableView = UITableView(frame: .zero, style: .plain)
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let emptyLabel = UILabel()
private var referralOrder: BoundReferralOrderEntity?
init(
orderNumber: String,
viewModel: OrderListViewModel,
referralOrder: BoundReferralOrderEntity?,
onPersonSelected: @escaping (OrderSourcePerson) -> Void,
loadLeads: @escaping (OrderSourcePerson) async -> Void,
onLeadBound: @escaping (OrderSourceSelection) -> Void,
onCallPhone: @escaping (String) -> Void,
onPreviewImages: @escaping ([String], Int) -> Void
) {
self.orderNumber = orderNumber
self.viewModel = viewModel
self.onPersonSelected = onPersonSelected
self.referralOrder = referralOrder
self.loadLeads = loadLeads
self.onLeadBound = onLeadBound
self.onCallPhone = onCallPhone
self.onPreviewImages = onPreviewImages
self.referralOrder = referralOrder
self.pendingPerson = viewModel.orderSourceSelection(
orderNumber: orderNumber,
referralOrder: referralOrder
)?.person
super.init(nibName: nil, bundle: nil)
}
@ -75,6 +56,8 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo
func reloadFromViewModel() {
applyViewModel()
(navigationController?.topViewController as? OrderSourceLeadListViewController)?
.reloadFromViewModel()
}
private func setupUI() {
@ -87,72 +70,37 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo
subtitleLabel.text = "选择人员后,再选择该人员创建的带客单"
subtitleLabel.numberOfLines = 0
backButton.setImage(UIImage(systemName: "chevron.left"), for: .normal)
backButton.tintColor = AppColor.text333
backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
leadTitleLabel.font = .systemFont(ofSize: 18, weight: .bold)
leadTitleLabel.textColor = AppColor.text333
leadTitleLabel.numberOfLines = 2
leadPhoneButton.titleLabel?.font = .systemFont(ofSize: 13)
leadPhoneButton.setTitleColor(AppColor.primary, for: .normal)
leadPhoneButton.contentHorizontalAlignment = .leading
leadPhoneButton.addTarget(self, action: #selector(leadPhoneTapped), for: .touchUpInside)
leadCallButton.backgroundColor = OrderTokens.phoneIconBackground
leadCallButton.layer.cornerRadius = 16
leadCallButton.tintColor = AppColor.primary
leadCallButton.setImage(UIImage(systemName: "phone.fill"), for: .normal)
leadCallButton.addTarget(self, action: #selector(leadPhoneTapped), for: .touchUpInside)
leadHeaderRow.axis = .horizontal
leadHeaderRow.alignment = .top
leadHeaderRow.spacing = 10
leadHeaderRow.isHidden = true
let leadTextStack = UIStackView(arrangedSubviews: [leadTitleLabel, leadPhoneButton])
leadTextStack.axis = .vertical
leadTextStack.spacing = 3
leadHeaderRow.addArrangedSubview(backButton)
leadHeaderRow.addArrangedSubview(leadTextStack)
leadHeaderRow.addArrangedSubview(leadCallButton)
headerStack.axis = .vertical
headerStack.spacing = 5
headerStack.addArrangedSubview(titleLabel)
headerStack.addArrangedSubview(subtitleLabel)
headerStack.addArrangedSubview(leadHeaderRow)
tableView.separatorStyle = .none
tableView.backgroundColor = .white
tableView.dataSource = self
tableView.delegate = self
tableView.register(OrderSourcePersonCell.self, forCellReuseIdentifier: OrderSourcePersonCell.reuseIdentifier)
tableView.register(OrderSourceLeadCell.self, forCellReuseIdentifier: OrderSourceLeadCell.reuseIdentifier)
loadingIndicator.hidesWhenStopped = true
loadingIndicator.color = AppColor.primary
emptyLabel.font = .systemFont(ofSize: 14)
emptyLabel.textColor = AppColor.text666
emptyLabel.textAlignment = .center
emptyLabel.numberOfLines = 0
emptyLabel.text = "暂无合作获客员"
view.addSubview(headerStack)
view.addSubview(titleLabel)
view.addSubview(subtitleLabel)
view.addSubview(tableView)
view.addSubview(loadingIndicator)
view.addSubview(emptyLabel)
headerStack.snp.makeConstraints { make in
titleLabel.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide).offset(AppSpacing.md)
make.leading.trailing.equalToSuperview().inset(AppSpacing.md)
}
backButton.snp.makeConstraints { make in
make.width.height.equalTo(28)
}
leadCallButton.snp.makeConstraints { make in
make.width.height.equalTo(32)
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(5)
make.leading.trailing.equalTo(titleLabel)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(headerStack.snp.bottom).offset(14)
make.top.equalTo(subtitleLabel.snp.bottom).offset(14)
make.leading.trailing.bottom.equalToSuperview()
}
loadingIndicator.snp.makeConstraints { make in
@ -165,113 +113,54 @@ final class OrderSourcePickerViewController: UIViewController, UITableViewDataSo
}
private func applyViewModel() {
let currentSelection = viewModel.orderSourceSelection(
orderNumber: orderNumber,
referralOrder: referralOrder
)
titleLabel.isHidden = level == .lead
subtitleLabel.isHidden = level == .lead
leadHeaderRow.isHidden = level == .person
if level == .lead, let person = pendingPerson {
leadTitleLabel.text = "\(person.name)创建的带客单"
let phone = person.phone.trimmingCharacters(in: .whitespacesAndNewlines)
leadPhoneButton.setTitle(phone, for: .normal)
leadPhoneButton.isHidden = phone.isEmpty
leadCallButton.isHidden = phone.isEmpty
}
let isLoadingPeople = viewModel.isLoadingOrderSourcePeople && level == .person
let isLoadingLeads = viewModel.loadingOrderSourceLeadsFor != nil && level == .lead
if isLoadingPeople || isLoadingLeads {
if viewModel.isLoadingOrderSourcePeople {
loadingIndicator.startAnimating()
tableView.isHidden = true
emptyLabel.isHidden = true
} else {
loadingIndicator.stopAnimating()
tableView.isHidden = false
let isEmpty: Bool
switch level {
case .person:
isEmpty = viewModel.orderSourcePeople.isEmpty
case .lead:
isEmpty = leadsForPendingPerson.isEmpty
}
let isEmpty = viewModel.orderSourcePeople.isEmpty
emptyLabel.isHidden = !isEmpty
emptyLabel.text = level == .person ? "暂无合作获客员" : "暂无可绑定带客单"
tableView.reloadData()
}
_ = currentSelection
}
private var leadsForPendingPerson: [OrderSourceLead] {
guard let person = pendingPerson else { return [] }
return viewModel.orderSourceLeads[person.key] ?? []
}
@objc private func backTapped() {
level = .person
applyViewModel()
}
@objc private func leadPhoneTapped() {
guard let phone = pendingPerson?.phone.trimmingCharacters(in: .whitespacesAndNewlines), !phone.isEmpty else { return }
onCallPhone(phone)
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch level {
case .person:
return viewModel.orderSourcePeople.count
case .lead:
return leadsForPendingPerson.count
}
viewModel.orderSourcePeople.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: OrderSourcePersonCell.reuseIdentifier,
for: indexPath
) as! OrderSourcePersonCell
let person = viewModel.orderSourcePeople[indexPath.row]
let currentSelection = viewModel.orderSourceSelection(
orderNumber: orderNumber,
referralOrder: referralOrder
)
switch level {
case .person:
let cell = tableView.dequeueReusableCell(
withIdentifier: OrderSourcePersonCell.reuseIdentifier,
for: indexPath
) as! OrderSourcePersonCell
let person = viewModel.orderSourcePeople[indexPath.row]
cell.configure(
person: person,
selected: currentSelection?.person.key == person.key
)
cell.onCallPhone = onCallPhone
return cell
case .lead:
let cell = tableView.dequeueReusableCell(
withIdentifier: OrderSourceLeadCell.reuseIdentifier,
for: indexPath
) as! OrderSourceLeadCell
let lead = leadsForPendingPerson[indexPath.row]
let selected = currentSelection?.person.key == pendingPerson?.key
&& currentSelection?.lead.key == lead.key
cell.configure(lead: lead, selected: selected)
cell.onCallPhone = onCallPhone
cell.onImageTap = onPreviewImages
cell.onSelect = { [weak self] in
guard let self, let person = self.pendingPerson else { return }
let selection = OrderSourceSelection(person: person, lead: lead)
self.onLeadBound(selection)
}
return cell
}
cell.configure(
person: person,
selected: currentSelection?.person.key == person.key
)
cell.onCallPhone = onCallPhone
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard level == .person else { return }
let person = viewModel.orderSourcePeople[indexPath.row]
pendingPerson = person
level = .lead
onPersonSelected(person)
applyViewModel()
let leadController = OrderSourceLeadListViewController(
person: person,
orderNumber: orderNumber,
viewModel: viewModel,
referralOrder: referralOrder,
loadLeads: loadLeads,
onLeadBound: onLeadBound,
onCallPhone: onCallPhone,
onPreviewImages: onPreviewImages
)
navigationController?.setNavigationBarHidden(false, animated: true)
navigationController?.pushViewController(leadController, animated: true)
}
}

View File

@ -473,11 +473,9 @@ final class OrdersViewController: BaseViewController, UITableViewDataSource, UIT
orderNumber: order.orderNumber,
viewModel: self.orderListViewModel,
referralOrder: order.referralOrder,
onPersonSelected: { [weak self] person in
loadLeads: { [weak self] person in
guard let self else { return }
Task {
await self.orderListViewModel.loadOrderSourceLeads(person: person, api: self.orderAPI)
}
await self.orderListViewModel.loadOrderSourceLeads(person: person, api: self.orderAPI)
},
onLeadBound: { [weak self] selection in
guard let self, let orderNumber = self.pendingOrderSourceNumber else { return }

View File

@ -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() }
}
}
}

View File

@ -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
}
}

View File

@ -54,7 +54,7 @@ final class PermissionApplyViewModelTests: XCTestCase {
viewModel.onShowMessage = { message = $0 }
await viewModel.submit(api: api)
XCTAssertEqual(message, "选择角色")
XCTAssertEqual(message, "请选择角色")
viewModel.selectRole(RoleOption(id: 10, name: "摄影师", description: "", isSelected: true))
await viewModel.submit(api: api)

View 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, "")
}
}