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:
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user