Add scenic selection and cooperation order flows aligned with Android.

Upgrade scenic picker with search, location sorting, permission apply/status, and blocking home dialog; add cooperation order module and order source picker improvements with unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-07 10:48:43 +08:00
parent 3c4ca7eefb
commit 2bea05b1d9
50 changed files with 5603 additions and 191 deletions

View File

@ -89,6 +89,11 @@ final class HomeViewController: BaseViewController {
)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
navigationController?.setNavigationBarHidden(true, animated: animated)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if !hasInitialized {
@ -361,18 +366,17 @@ final class HomeViewController: BaseViewController {
private func presentScenicDialog() {
let alert = UIAlertController(
title: "请选择景区",
message: "使用首页功能前需要先选择当前景区",
title: "提示",
message: "您还没有选定入驻景区,请先入驻",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "稍后", style: .cancel) { [weak self] _ in
self?.viewModel.hideScenicDialog()
self?.activeDialog = nil
alert.isModalInPresentation = true
alert.addAction(UIAlertAction(title: "关闭app", style: .default) { _ in
UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
})
alert.addAction(UIAlertAction(title: "选择", style: .default) { [weak self] _ in
self?.viewModel.hideScenicDialog()
alert.addAction(UIAlertAction(title: "入驻", style: .default) { [weak self] _ in
self?.activeDialog = nil
self?.presentScenicSelection()
self?.pushScenicSelection()
})
present(alert, animated: true)
}
@ -440,24 +444,24 @@ final class HomeViewController: BaseViewController {
}
private func presentScenicSelection() {
let scenicList = AppStore.shared.roleScenicList()
guard !scenicList.isEmpty else {
showToast("暂无可选景区")
return
}
let controller = ScenicSelectionViewController(scenicList: scenicList)
controller.onSelected = { [weak self] in
pushScenicSelection()
}
private func pushScenicSelection() {
navigationController?.setNavigationBarHidden(false, animated: true)
let controller = ScenicSelectionViewController()
controller.onSelectionComplete = { [weak self] in
guard let self else { return }
Task {
await self.viewModel.reloadIfNeeded(api: self.homeAPI)
self.viewModel.refreshLocalDisplayState()
self.viewModel.rebuildCommonMenus()
await self.viewModel.loadStoreListIfNeeded(api: self.homeAPI)
self.applyViewModel()
await self.evaluateDialogsWithDelay()
await self.viewModel.evaluateDialogs(api: self.homeAPI)
await MainActor.run { self.presentDialogsIfNeeded() }
}
}
let nav = UINavigationController(rootViewController: controller)
nav.modalPresentationStyle = .pageSheet
present(nav, animated: true)
navigationController?.pushViewController(controller, animated: true)
}
private func startCountdownTimer() {

View File

@ -0,0 +1,194 @@
//
// PermissionApplyStatusViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `PermissionApplyStatusScreen`
final class PermissionApplyStatusViewController: BaseViewController {
private let viewModel: PermissionApplyStatusViewModel
private let homeAPI: HomeAPI
private let scrollView = UIScrollView()
private let contentStack = UIStackView()
private let bannerView = PermissionApplyBannerView()
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
init(
applyCode: String,
viewModel: PermissionApplyStatusViewModel? = nil,
homeAPI: HomeAPI = NetworkServices.shared.homeAPI
) {
self.viewModel = viewModel ?? PermissionApplyStatusViewModel(applyCode: applyCode)
self.homeAPI = homeAPI
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() {
contentStack.axis = .vertical
contentStack.spacing = 12
scrollView.addSubview(contentStack)
view.addSubview(scrollView)
view.addSubview(loadingIndicator)
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16))
make.width.equalTo(scrollView).offset(-32)
}
scrollView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
}
loadingIndicator.snp.makeConstraints { make in
make.center.equalToSuperview()
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onNavigateToEdit = { [weak self] pending in
Task { @MainActor in self?.navigateToEdit(pending: pending) }
}
bannerView.onApplyNewScenic = { [weak self] in
self?.showToast("功能开发中")
}
Task { await viewModel.loadPendingData(api: homeAPI) }
}
private func applyViewModel() {
if viewModel.isLoading {
loadingIndicator.startAnimating()
scrollView.isHidden = true
return
}
loadingIndicator.stopAnimating()
scrollView.isHidden = false
contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
contentStack.addArrangedSubview(bannerView)
navigationItem.rightBarButtonItem = nil
if let pending = viewModel.pendingData, pending.status == PermissionApplyPendingStatus.rejected {
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "去编辑",
style: .plain,
target: self,
action: #selector(editTapped)
)
navigationItem.rightBarButtonItem?.tintColor = AppColor.primary
}
guard let pending = viewModel.pendingData else { return }
contentStack.addArrangedSubview(makeStatusCard(pending: pending))
contentStack.addArrangedSubview(makeSectionCard(title: "新增角色", chips: [pending.roleName]))
if !pending.scenicList.isEmpty {
contentStack.addArrangedSubview(
makeSectionCard(title: "新增景区", chips: pending.scenicList.map(\.name))
)
}
}
private func makeStatusCard(pending: RoleApplyPendingResponse) -> UIView {
let card = UIView()
card.backgroundColor = AppColor.warningBackground
card.layer.cornerRadius = 12
card.layer.borderWidth = 1
card.layer.borderColor = AppColor.warning.cgColor
let statusLabel = UILabel()
statusLabel.text = pending.statusLabel
statusLabel.font = .systemFont(ofSize: 18, weight: .medium)
statusLabel.textColor = AppColor.warning
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 8
stack.addArrangedSubview(statusLabel)
[
("提交时间:", pending.createdAt),
("审核时间:", pending.auditedAt ?? "--"),
("审核人:", pending.auditedBy ?? "--"),
("审核备注:", pending.auditNote.isEmpty ? "--" : pending.auditNote),
].forEach { label, value in
stack.addArrangedSubview(makeInfoRow(label: label, value: value))
}
card.addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
return card
}
private func makeSectionCard(title: String, chips: [String]) -> UIView {
let card = UIView()
card.backgroundColor = .white
card.layer.cornerRadius = 12
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
titleLabel.textColor = AppColor.textPrimary
let chipStack = UIStackView()
chipStack.axis = .horizontal
chipStack.spacing = 8
chips.forEach { name in
chipStack.addArrangedSubview(PermissionTagChip(title: name, showsRemove: false))
}
card.addSubview(titleLabel)
card.addSubview(chipStack)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview().inset(16)
}
chipStack.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(12)
make.leading.trailing.bottom.equalToSuperview().inset(16)
}
return card
}
private func makeInfoRow(label: String, value: String) -> UIView {
let row = UIStackView()
row.axis = .horizontal
row.distribution = .equalSpacing
let left = UILabel()
left.text = label
left.font = .systemFont(ofSize: 14)
left.textColor = AppColor.textSecondary
let right = UILabel()
right.text = value.isEmpty ? "--" : value
right.font = .systemFont(ofSize: 14)
right.textColor = AppColor.textSecondary
right.textAlignment = .right
row.addArrangedSubview(left)
row.addArrangedSubview(right)
return row
}
@objc private func editTapped() {
viewModel.navigateToEditIfRejected()
}
private func navigateToEdit(pending: RoleApplyPendingResponse) {
var controllers = navigationController?.viewControllers ?? []
controllers.removeAll { $0 is PermissionApplyStatusViewController }
let applyVC = PermissionApplyViewController(pendingData: pending)
controllers.append(applyVC)
navigationController?.setViewControllers(controllers, animated: true)
}
}

View File

@ -0,0 +1,208 @@
//
// PermissionApplyViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `PermissionApplyScreen`
final class PermissionApplyViewController: BaseViewController {
private let viewModel: PermissionApplyViewModel
private let homeAPI: HomeAPI
private let pendingData: RoleApplyPendingResponse?
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 selectedScenicTags = PermissionSelectedTagsView()
private let submitButton = PermissionSubmitButton()
init(
pendingData: RoleApplyPendingResponse? = nil,
viewModel: PermissionApplyViewModel = PermissionApplyViewModel(),
homeAPI: HomeAPI = NetworkServices.shared.homeAPI
) {
self.pendingData = pendingData
self.viewModel = viewModel
self.homeAPI = homeAPI
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() {
contentStack.axis = .vertical
contentStack.spacing = 16
let card = UIView()
card.backgroundColor = .white
card.layer.cornerRadius = 8
let cardStack = UIStackView(arrangedSubviews: [roleSelector, existingScenicTags, scenicSelector, selectedScenicTags])
cardStack.axis = .vertical
cardStack.spacing = 20
card.addSubview(cardStack)
cardStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(16)
}
contentStack.addArrangedSubview(bannerView)
contentStack.addArrangedSubview(card)
scrollView.addSubview(contentStack)
view.addSubview(scrollView)
view.addSubview(submitButton)
contentStack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16))
make.width.equalTo(scrollView).offset(-32)
}
scrollView.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.bottom.equalTo(submitButton.snp.top).offset(-12)
}
submitButton.snp.makeConstraints { make in
make.leading.trailing.equalToSuperview().inset(16)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
}
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] applyCode in
Task { @MainActor in self?.handleSubmitSuccess(applyCode: applyCode) }
}
bannerView.onApplyNewScenic = { [weak self] in
self?.showToast("功能开发中")
}
roleSelector.onTap = { [weak self] in self?.presentRolePicker() }
scenicSelector.onTap = { [weak self] in
guard let self else { return }
self.viewModel.showScenicPickerSheet()
if self.viewModel.availableScenics.isEmpty {
Task { await self.viewModel.loadAvailableScenics(api: self.homeAPI) }
}
self.presentScenicPickerIfNeeded()
}
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
if let pendingData {
viewModel.initWithPendingData(pendingData)
Task { await viewModel.loadAvailableScenics(api: homeAPI) }
}
applyViewModel()
}
private func applyViewModel() {
if viewModel.selectedRoleName.isEmpty {
roleSelector.setValue("请选择角色", isPlaceholder: true)
existingScenicTags.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)
}
}
if viewModel.selectedScenicNames.isEmpty {
scenicSelector.setValue("请选择景区", isPlaceholder: true)
selectedScenicTags.isHidden = true
} else {
scenicSelector.setValue("已选 \(viewModel.selectedScenicNames.count) 个景区", isPlaceholder: false)
selectedScenicTags.apply(
title: "待申请景区",
names: viewModel.selectedScenicNames
) { [weak self] index in
guard let self else { return }
let id = self.viewModel.selectedScenicIds[index]
let name = self.viewModel.selectedScenicNames[index]
self.viewModel.removeSelectedScenic(id: id, name: name)
}
}
submitButton.setSubmitting(viewModel.isSubmitting)
presentScenicPickerIfNeeded()
presentRolePickerIfNeeded()
}
private func presentRolePickerIfNeeded() {
guard viewModel.showRolePicker else { return }
viewModel.hideRolePickerSheet()
presentRolePicker()
}
private func presentRolePicker() {
let alert = UIAlertController(title: "选择角色", message: nil, preferredStyle: .actionSheet)
viewModel.availableRoles.forEach { role in
alert.addAction(UIAlertAction(title: role.name, style: .default) { [weak self] _ in
self?.viewModel.selectRole(role)
Task { await self?.viewModel.loadAvailableScenics(api: self?.homeAPI) }
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
private func presentScenicPickerIfNeeded() {
guard viewModel.showScenicPicker else { return }
viewModel.hideScenicPickerSheet()
presentScenicPicker()
}
private func presentScenicPicker() {
guard viewModel.selectedRoleId != nil else {
showToast("请先选择角色")
return
}
if viewModel.availableScenics.isEmpty {
Task {
await viewModel.loadAvailableScenics(api: homeAPI)
await MainActor.run { self.presentScenicPicker() }
}
return
}
let alert = UIAlertController(title: "选择景区", message: nil, preferredStyle: .actionSheet)
viewModel.availableScenics.filter { !$0.isDisabled }.forEach { scenic in
let prefix = scenic.isSelected ? "" : ""
alert.addAction(UIAlertAction(title: "\(prefix)\(scenic.name)", style: .default) { [weak self] _ in
self?.viewModel.toggleScenic(scenic)
})
}
alert.addAction(UIAlertAction(title: "完成", style: .cancel))
present(alert, animated: true)
}
@objc private func submitTapped() {
Task { await viewModel.submit(api: homeAPI) }
}
private func handleSubmitSuccess(applyCode: String) {
guard !applyCode.isEmpty else {
navigationController?.popViewController(animated: true)
return
}
var controllers = navigationController?.viewControllers ?? []
controllers.removeAll { $0 === self }
let statusVC = PermissionApplyStatusViewController(applyCode: applyCode)
controllers.append(statusVC)
navigationController?.setViewControllers(controllers, animated: true)
}
}

View File

@ -6,16 +6,23 @@
import SnapKit
import UIKit
/// 使 role-permission
final class ScenicSelectionViewController: BaseViewController {
/// Android `ScenicSelectionScreen`
final class ScenicSelectionViewController: BaseViewController, UITableViewDataSource, UITableViewDelegate {
var onSelected: (() -> Void)?
var onSelectionComplete: (() -> Void)?
private let scenicList: [ScenicInfo]
private let tableView = UITableView(frame: .zero, style: .insetGrouped)
private let viewModel: ScenicSelectionViewModel
private let homeAPI: HomeAPI
init(scenicList: [ScenicInfo]) {
self.scenicList = scenicList
private let bannerView = ScenicPermissionBannerView()
private let searchBar = ScenicSelectionSearchBar()
private let locationBar = ScenicCurrentLocationBar()
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyLabel = UILabel()
init(viewModel: ScenicSelectionViewModel = ScenicSelectionViewModel(), homeAPI: HomeAPI = NetworkServices.shared.homeAPI) {
self.viewModel = viewModel
self.homeAPI = homeAPI
super.init(nibName: nil, bundle: nil)
}
@ -25,61 +32,122 @@ final class ScenicSelectionViewController: BaseViewController {
}
override func setupNavigationBar() {
title = "选择景区"
navigationItem.leftBarButtonItem = UIBarButtonItem(
barButtonSystemItem: .close,
target: self,
action: #selector(closeTapped)
)
title = "景区选择"
navigationController?.setNavigationBarHidden(false, animated: false)
}
override func setupUI() {
view.backgroundColor = UIColor(hex: 0xF7FAFF)
view.backgroundColor = .white
tableView.backgroundColor = .clear
tableView.separatorStyle = .none
tableView.dataSource = self
tableView.delegate = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
view.addSubview(tableView)
}
tableView.register(ScenicSpotCell.self, forCellReuseIdentifier: ScenicSpotCell.reuseIdentifier)
tableView.keyboardDismissMode = .onDrag
override func setupConstraints() {
emptyLabel.text = "暂无景区"
emptyLabel.font = .systemFont(ofSize: 14)
emptyLabel.textColor = AppColor.textTertiary
emptyLabel.textAlignment = .center
emptyLabel.isHidden = true
let headerStack = UIStackView(arrangedSubviews: [bannerView, searchBar, locationBar])
headerStack.axis = .vertical
headerStack.spacing = 16
headerStack.isLayoutMarginsRelativeArrangement = true
headerStack.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16)
view.addSubview(headerStack)
view.addSubview(tableView)
view.addSubview(emptyLabel)
headerStack.snp.makeConstraints { make in
make.top.equalTo(view.safeAreaLayoutGuide)
make.leading.trailing.equalToSuperview()
}
tableView.snp.makeConstraints { make in
make.edges.equalTo(view.safeAreaLayoutGuide)
make.top.equalTo(headerStack.snp.bottom)
make.leading.trailing.bottom.equalToSuperview()
}
emptyLabel.snp.makeConstraints { make in
make.center.equalTo(tableView)
}
}
@objc private func closeTapped() {
dismiss(animated: true)
}
}
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.onSelectionComplete = { [weak self] in
Task { @MainActor in
self?.onSelectionComplete?()
self?.navigationController?.popViewController(animated: true)
}
}
viewModel.onNavigateApply = { [weak self] in
Task { @MainActor in self?.pushPermissionApply() }
}
viewModel.onNavigateApplyStatus = { [weak self] applyCode in
Task { @MainActor in self?.pushPermissionApplyStatus(applyCode: applyCode) }
}
bannerView.onApplyNow = { [weak self] in
guard let self else { return }
Task { await self.viewModel.onApplyNow(api: self.homeAPI) }
}
searchBar.onSearchTextChange = { [weak self] query in
self?.viewModel.onSearchQueryChange(query)
}
locationBar.onRelocate = { [weak self] in
guard let self else { return }
Task { await self.viewModel.startLocation() }
}
viewModel.loadFromLocal()
Task { await viewModel.startLocation() }
}
private func applyViewModel() {
locationBar.apply(locationText: viewModel.currentLocationText)
emptyLabel.isHidden = !viewModel.spots.isEmpty
tableView.reloadData()
if viewModel.isLoading {
showLoading()
} else {
hideLoading()
}
}
private func pushPermissionApply(pendingData: RoleApplyPendingResponse? = nil) {
let controller = PermissionApplyViewController(pendingData: pendingData)
navigationController?.pushViewController(controller, animated: true)
}
private func pushPermissionApplyStatus(applyCode: String) {
let controller = PermissionApplyStatusViewController(applyCode: applyCode)
navigationController?.pushViewController(controller, animated: true)
}
// MARK: - UITableView
extension ScenicSelectionViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
scenicList.count
viewModel.spots.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
var config = cell.defaultContentConfiguration()
config.text = scenicList[indexPath.row].name
cell.contentConfiguration = config
cell.accessoryType = scenicList[indexPath.row].id == AppStore.shared.currentScenicId ? .checkmark : .none
let cell = tableView.dequeueReusableCell(
withIdentifier: ScenicSpotCell.reuseIdentifier,
for: indexPath
) as! ScenicSpotCell
let spot = viewModel.spots[indexPath.row]
cell.apply(spot)
cell.onTap = { [weak self] in
self?.viewModel.selectSpot(spot)
}
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
let scenic = scenicList[indexPath.row]
AppStore.shared.currentScenicId = scenic.id
AppStore.shared.currentScenicName = scenic.name
NotificationCenter.default.post(
name: NotificationName.scenicDidChange,
object: nil,
userInfo: [
NotificationUserInfoKey.scenicId: scenic.id,
NotificationUserInfoKey.scenicName: scenic.name,
]
)
onSelected?()
dismiss(animated: true)
}
}

View File

@ -0,0 +1,252 @@
//
// PermissionApplyViews.swift
// suixinkan
//
import SnapKit
import UIKit
/// toast
final class PermissionApplyBannerView: UIView {
var onApplyNewScenic: (() -> Void)?
private let titleLabel = UILabel()
private let applyButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = AppColor.warningBackground
titleLabel.text = "希望开通全新景区?"
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = AppColor.warning
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)
addSubview(titleLabel)
addSubview(applyButton)
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
}
applyButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
}
snp.makeConstraints { make in
make.height.equalTo(44)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func applyTapped() {
onApplyNewScenic?()
}
}
///
final class PermissionFormSelectorRow: UIView {
var onTap: (() -> Void)?
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let valueLabel = UILabel()
private let chevron = UIImageView(image: UIImage(systemName: "chevron.down"))
init(title: String, subtitle: 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
valueLabel.text = placeholder
valueLabel.font = .systemFont(ofSize: 14)
valueLabel.textColor = AppColor.textTertiary
chevron.tintColor = AppColor.textTertiary
chevron.contentMode = .scaleAspectFit
let tap = UITapGestureRecognizer(target: self, action: #selector(rowTapped))
addGestureRecognizer(tap)
addSubview(titleLabel)
addSubview(subtitleLabel)
addSubview(valueLabel)
addSubview(chevron)
titleLabel.snp.makeConstraints { make in
make.top.leading.equalToSuperview()
}
subtitleLabel.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(4)
make.leading.equalToSuperview()
}
valueLabel.snp.makeConstraints { make in
make.top.equalTo(subtitleLabel.snp.bottom).offset(12)
make.leading.bottom.equalToSuperview()
make.trailing.lessThanOrEqualTo(chevron.snp.leading).offset(-8)
}
chevron.snp.makeConstraints { make in
make.trailing.equalToSuperview()
make.centerY.equalTo(valueLabel)
make.size.equalTo(14)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setValue(_ text: String, isPlaceholder: Bool) {
valueLabel.text = text
valueLabel.textColor = isPlaceholder ? AppColor.textTertiary : AppColor.textPrimary
}
@objc private func rowTapped() {
onTap?()
}
}
///
final class PermissionSelectedTagsView: UIView {
private let titleLabel = UILabel()
private let stackView = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = AppColor.textPrimary
stackView.axis = .vertical
stackView.spacing = 8
addSubview(titleLabel)
addSubview(stackView)
titleLabel.snp.makeConstraints { make in
make.top.leading.trailing.equalToSuperview()
}
stackView.snp.makeConstraints { make in
make.top.equalTo(titleLabel.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(title: String, names: [String], onRemove: ((Int) -> Void)? = nil) {
titleLabel.text = title
stackView.arrangedSubviews.forEach { $0.removeFromSuperview() }
guard !names.isEmpty else {
isHidden = true
return
}
isHidden = false
let row = UIStackView()
row.axis = .horizontal
row.spacing = 8
row.alignment = .leading
names.enumerated().forEach { index, name in
let chip = PermissionTagChip(title: name)
if let onRemove {
chip.onRemove = { onRemove(index) }
}
row.addArrangedSubview(chip)
}
stackView.addArrangedSubview(row)
}
}
/// Chip
final class PermissionTagChip: UIView {
var onRemove: (() -> Void)?
private let label = UILabel()
private let removeButton = UIButton(type: .system)
init(title: String, showsRemove: Bool = true) {
super.init(frame: .zero)
layer.cornerRadius = 4
layer.borderWidth = 1
layer.borderColor = AppColor.primary.cgColor
backgroundColor = .white
label.text = title
label.font = .systemFont(ofSize: 14)
label.textColor = AppColor.primary
removeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
removeButton.tintColor = AppColor.primary
removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside)
removeButton.isHidden = !showsRemove
addSubview(label)
addSubview(removeButton)
label.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.top.bottom.equalToSuperview().inset(8)
if showsRemove {
make.trailing.equalTo(removeButton.snp.leading).offset(-4)
} else {
make.trailing.equalToSuperview().offset(-12)
}
}
removeButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-8)
make.centerY.equalToSuperview()
make.size.equalTo(20)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func removeTapped() {
onRemove?()
}
}
///
final class PermissionSubmitButton: 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.primary
layer.cornerRadius = 8
snp.makeConstraints { make in
make.height.equalTo(48)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setSubmitting(_ submitting: Bool) {
isEnabled = !submitting
alpha = submitting ? 0.6 : 1
setTitle(submitting ? "提交中..." : "提交申请", for: .normal)
}
}

View File

@ -0,0 +1,300 @@
//
// ScenicSelectionViews.swift
// suixinkan
//
import Kingfisher
import SnapKit
import UIKit
///
final class ScenicPermissionBannerView: UIView {
var onApplyNow: (() -> Void)?
private let titleLabel = UILabel()
private let applyButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = AppColor.warningBackground
titleLabel.text = "希望开通更多景区权限"
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
titleLabel.textColor = AppColor.warning
titleLabel.numberOfLines = 2
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)
addSubview(titleLabel)
addSubview(applyButton)
titleLabel.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(16)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(applyButton.snp.leading).offset(-8)
}
applyButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-16)
make.centerY.equalToSuperview()
}
snp.makeConstraints { make in
make.height.greaterThanOrEqualTo(44)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func applyTapped() {
onApplyNow?()
}
}
///
final class ScenicSelectionSearchBar: UIView, UITextFieldDelegate {
var onSearchTextChange: ((String) -> Void)?
let textField = UITextField()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = UIColor(hex: 0xF2F4F8)
layer.cornerRadius = 12
clipsToBounds = true
let icon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
icon.tintColor = UIColor(hex: 0xB3B8C2)
icon.contentMode = .scaleAspectFit
textField.placeholder = "搜索景区"
textField.font = .systemFont(ofSize: 14)
textField.textColor = AppColor.textPrimary
textField.returnKeyType = .search
textField.delegate = self
textField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
addSubview(icon)
addSubview(textField)
icon.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(12)
make.centerY.equalToSuperview()
make.size.equalTo(18)
}
textField.snp.makeConstraints { make in
make.leading.equalTo(icon.snp.trailing).offset(8)
make.trailing.equalToSuperview().offset(-12)
make.top.bottom.equalToSuperview().inset(10)
}
snp.makeConstraints { make in
make.height.equalTo(44)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func textChanged() {
onSearchTextChange?(textField.text ?? "")
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
}
///
final class ScenicCurrentLocationBar: UIView {
var onRelocate: (() -> Void)?
private let iconView = UIImageView(image: UIImage(systemName: "location.fill"))
private let locationLabel = UILabel()
private let relocateButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
locationLabel.font = .systemFont(ofSize: 14)
locationLabel.textColor = AppColor.textPrimary
locationLabel.numberOfLines = 2
relocateButton.setTitle("重新定位", for: .normal)
relocateButton.titleLabel?.font = .systemFont(ofSize: 12)
relocateButton.setTitleColor(AppColor.primary, for: .normal)
relocateButton.backgroundColor = AppColor.primaryLight
relocateButton.layer.cornerRadius = 4
relocateButton.contentEdgeInsets = UIEdgeInsets(top: 2, left: 8, bottom: 2, right: 8)
relocateButton.addTarget(self, action: #selector(relocateTapped), for: .touchUpInside)
relocateButton.setContentHuggingPriority(.required, for: .horizontal)
relocateButton.setContentCompressionResistancePriority(.required, for: .horizontal)
addSubview(iconView)
addSubview(locationLabel)
addSubview(relocateButton)
iconView.snp.makeConstraints { make in
make.leading.equalToSuperview()
make.centerY.equalToSuperview()
make.size.equalTo(16)
}
locationLabel.snp.makeConstraints { make in
make.leading.equalTo(iconView.snp.trailing).offset(4)
make.centerY.equalToSuperview()
make.trailing.lessThanOrEqualTo(relocateButton.snp.leading).offset(-12)
}
relocateButton.snp.makeConstraints { make in
make.trailing.equalToSuperview()
make.centerY.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(locationText: String) {
locationLabel.text = locationText
}
@objc private func relocateTapped() {
onRelocate?()
}
}
/// Cell
final class ScenicSpotCell: UITableViewCell {
static let reuseIdentifier = "ScenicSpotCell"
var onTap: (() -> Void)?
private let cardView = UIView()
private let coverImageView = UIImageView()
private let nameLabel = UILabel()
private let distanceLabel = UILabel()
private let addressLabel = UILabel()
private let statusTag = UILabel()
private let selectedBorderColor = UIColor(hex: 0xE74C3C)
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
contentView.backgroundColor = .clear
cardView.backgroundColor = AppColor.pageBackgroundSoft
cardView.layer.cornerRadius = 8
cardView.layer.borderWidth = 2
cardView.layer.borderColor = UIColor(hex: 0xF4F4F4).cgColor
coverImageView.contentMode = .scaleAspectFill
coverImageView.clipsToBounds = true
coverImageView.layer.cornerRadius = 6
coverImageView.backgroundColor = UIColor(hex: 0xF0F0F0)
nameLabel.font = .systemFont(ofSize: 16, weight: .bold)
nameLabel.textColor = AppColor.textPrimary
distanceLabel.font = .systemFont(ofSize: 12)
distanceLabel.textColor = AppColor.textSecondary
addressLabel.font = .systemFont(ofSize: 12)
addressLabel.textColor = AppColor.textTertiary
addressLabel.numberOfLines = 2
statusTag.font = .systemFont(ofSize: 11, weight: .medium)
statusTag.textColor = .white
statusTag.textAlignment = .center
statusTag.layer.cornerRadius = 4
statusTag.clipsToBounds = true
statusTag.isHidden = true
contentView.addSubview(cardView)
cardView.addSubview(coverImageView)
cardView.addSubview(nameLabel)
cardView.addSubview(statusTag)
cardView.addSubview(distanceLabel)
cardView.addSubview(addressLabel)
cardView.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 0, left: 16, bottom: 12, right: 16))
}
coverImageView.snp.makeConstraints { make in
make.leading.top.bottom.equalToSuperview().inset(12)
make.size.equalTo(80)
}
nameLabel.snp.makeConstraints { make in
make.leading.equalTo(coverImageView.snp.trailing).offset(12)
make.top.equalToSuperview().offset(12)
make.trailing.lessThanOrEqualTo(statusTag.snp.leading).offset(-8)
}
statusTag.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-12)
make.centerY.equalTo(nameLabel)
make.height.equalTo(20)
}
distanceLabel.snp.makeConstraints { make in
make.leading.equalTo(nameLabel)
make.top.equalTo(nameLabel.snp.bottom).offset(6)
}
addressLabel.snp.makeConstraints { make in
make.leading.equalTo(nameLabel)
make.trailing.equalToSuperview().offset(-12)
make.top.equalTo(distanceLabel.snp.bottom).offset(4)
make.bottom.lessThanOrEqualToSuperview().offset(-12)
}
let tap = UITapGestureRecognizer(target: self, action: #selector(cardTapped))
cardView.addGestureRecognizer(tap)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(_ item: ScenicSpotDisplayItem) {
nameLabel.text = item.name
distanceLabel.text = "\(item.distanceText)km"
addressLabel.text = item.address
if let url = URL(string: item.coverImg), !item.coverImg.isEmpty {
coverImageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo"))
} else {
coverImageView.image = UIImage(systemName: "photo")
coverImageView.tintColor = AppColor.textTertiary
}
if let label = item.statusLabel {
statusTag.isHidden = false
statusTag.text = " \(label) "
switch item.openStatus {
case .nearest, .closed:
statusTag.backgroundColor = selectedBorderColor
case .none:
statusTag.backgroundColor = AppColor.textTertiary
}
} else {
statusTag.isHidden = true
}
cardView.layer.borderColor = (item.isSelected ? selectedBorderColor : UIColor(hex: 0xF4F4F4)).cgColor
}
@objc private func cardTapped() {
onTap?()
}
}