完善首页权限申请流程与队列播报体验。
对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -89,7 +89,8 @@ final class AllFunctionsViewController: BaseViewController {
|
||||
|
||||
override func setupConstraints() {
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(view.safeAreaLayoutGuide)
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.bottom.equalToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -35,10 +35,10 @@ final class HomeViewController: BaseViewController {
|
||||
collectionViewLayout: HomeCollectionLayoutBuilder.makeLayout(sections: [.commonApps])
|
||||
)
|
||||
collectionView.backgroundColor = .clear
|
||||
collectionView.alwaysBounceVertical = true
|
||||
collectionView.alwaysBounceHorizontal = false
|
||||
collectionView.showsHorizontalScrollIndicator = false
|
||||
collectionView.isDirectionalLockEnabled = true
|
||||
// collectionView.alwaysBounceVertical = true
|
||||
// collectionView.alwaysBounceHorizontal = false
|
||||
// collectionView.showsHorizontalScrollIndicator = false
|
||||
// collectionView.contentInsetAdjustmentBehavior = .never
|
||||
// 水平边距由 section.contentInsets 控制,避免 contentInset 与布局宽度不一致导致横向滑动。
|
||||
collectionView.contentInset = UIEdgeInsets(
|
||||
top: AppSpacing.sm,
|
||||
@ -56,13 +56,13 @@ final class HomeViewController: BaseViewController {
|
||||
|
||||
override func setupConstraints() {
|
||||
scenicHeaderView.snp.makeConstraints { make in
|
||||
make.top.equalTo(view.safeAreaLayoutGuide)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(AppSpacing.homeHeaderHeight)
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide.snp.top).offset(AppSpacing.homeHeaderHeight)
|
||||
}
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.top.equalTo(scenicHeaderView.snp.bottom)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide)
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,7 +73,6 @@ final class HomeViewController: BaseViewController {
|
||||
viewModel.onShowMessage = { [weak self] message in
|
||||
Task { @MainActor in self?.showToast(message) }
|
||||
}
|
||||
|
||||
scenicHeaderView.onTap = { [weak self] in self?.presentScenicSelection() }
|
||||
collectionView.delegate = self
|
||||
|
||||
@ -102,7 +101,14 @@ final class HomeViewController: BaseViewController {
|
||||
hasInitialized = true
|
||||
Task { await initializeHome() }
|
||||
} else {
|
||||
Task { await viewModel.reloadIfNeeded(api: homeAPI) }
|
||||
Task {
|
||||
let shouldReevaluateDialogs = viewModel.needsPermissionReload
|
||||
await viewModel.reloadIfNeeded(api: homeAPI)
|
||||
if shouldReevaluateDialogs {
|
||||
await viewModel.evaluateDialogs(api: homeAPI)
|
||||
applyViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
startCountdownTimer()
|
||||
}
|
||||
@ -389,20 +395,34 @@ final class HomeViewController: BaseViewController {
|
||||
}
|
||||
|
||||
private func presentPermissionDialog() {
|
||||
let alert = UIAlertController(
|
||||
title: "权限提示",
|
||||
message: "您还没有权限,请先申请权限",
|
||||
preferredStyle: .alert
|
||||
let dialog = PermissionRequiredDialogViewController(
|
||||
onExitApp: {
|
||||
UIApplication.shared.perform(#selector(NSXPCConnection.suspend))
|
||||
},
|
||||
onGoToApply: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.activeDialog = nil
|
||||
Task { await self.openPermissionApplyFlow() }
|
||||
}
|
||||
)
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
|
||||
self?.viewModel.hidePermissionDialog()
|
||||
self?.activeDialog = nil
|
||||
})
|
||||
alert.addAction(UIAlertAction(title: "去申请", style: .default) { [weak self] _ in
|
||||
self?.showToast("功能开发中")
|
||||
self?.activeDialog = nil
|
||||
})
|
||||
present(alert, animated: true)
|
||||
present(dialog, animated: true)
|
||||
}
|
||||
|
||||
private func openPermissionApplyFlow() async {
|
||||
showLoading()
|
||||
let destination = await viewModel.resolvePermissionApplyDestination(api: homeAPI)
|
||||
hideLoading()
|
||||
viewModel.markNeedsPermissionReload()
|
||||
navigationController?.setNavigationBarHidden(false, animated: true)
|
||||
switch destination {
|
||||
case .apply:
|
||||
navigationController?.pushViewController(PermissionApplyViewController(), animated: true)
|
||||
case .status(let applyCode):
|
||||
navigationController?.pushViewController(
|
||||
PermissionApplyStatusViewController(applyCode: applyCode),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentScenicDialog() {
|
||||
|
||||
@ -6,24 +6,26 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 权限申请审核状态页,对齐 Android `PermissionApplyStatusScreen`。
|
||||
/// 权限申请审核状态页,严格对齐 Android `PermissionApplyStatusScreen`。
|
||||
@MainActor
|
||||
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 existingRoleList = PermissionExistingRoleListView()
|
||||
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
|
||||
|
||||
/// 使用申请编号创建审核状态页。
|
||||
init(
|
||||
applyCode: String,
|
||||
viewModel: PermissionApplyStatusViewModel? = nil,
|
||||
homeAPI: HomeAPI = NetworkServices.shared.homeAPI
|
||||
homeAPI: HomeAPI? = nil
|
||||
) {
|
||||
self.viewModel = viewModel ?? PermissionApplyStatusViewModel(applyCode: applyCode)
|
||||
self.homeAPI = homeAPI
|
||||
self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@ -34,25 +36,23 @@ final class PermissionApplyStatusViewController: BaseViewController {
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "权限申请"
|
||||
navigationItem.backButtonDisplayMode = .minimal
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
view.backgroundColor = PermissionApplyUITokens.pageBackground
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 12
|
||||
contentStack.spacing = 0
|
||||
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()
|
||||
make.edges.equalToSuperview()
|
||||
make.width.equalTo(scrollView)
|
||||
}
|
||||
scrollView.snp.makeConstraints { make in make.edges.equalTo(view.safeAreaLayoutGuide) }
|
||||
loadingIndicator.color = PermissionApplyUITokens.selectedBlue
|
||||
loadingIndicator.snp.makeConstraints { make in make.center.equalTo(view.safeAreaLayoutGuide) }
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
@ -65,9 +65,13 @@ final class PermissionApplyStatusViewController: BaseViewController {
|
||||
bannerView.onApplyNewScenic = { [weak self] in
|
||||
self?.navigationController?.pushViewController(ScenicApplicationViewController(), animated: true)
|
||||
}
|
||||
existingRoleList.onShowAllScenics = { [weak self] roleName, scenics in
|
||||
self?.viewModel.showScenicList(roleName: roleName, scenics: scenics)
|
||||
}
|
||||
Task { await viewModel.loadPendingData(api: homeAPI) }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
if viewModel.isLoading {
|
||||
loadingIndicator.startAnimating()
|
||||
@ -76,29 +80,44 @@ final class PermissionApplyStatusViewController: BaseViewController {
|
||||
}
|
||||
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
|
||||
}
|
||||
contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||
|
||||
contentStack.addArrangedSubview(bannerView)
|
||||
guard let pending = viewModel.pendingData else { return }
|
||||
|
||||
contentStack.addArrangedSubview(makeStatusCard(pending: pending))
|
||||
contentStack.addArrangedSubview(makeSectionCard(title: "新增角色", chips: [pending.roleName]))
|
||||
if pending.status == PermissionApplyPendingStatus.rejected {
|
||||
let editItem = UIBarButtonItem(title: "去编辑", style: .plain, target: self, action: #selector(editTapped))
|
||||
editItem.tintColor = AppColor.primary
|
||||
editItem.setTitleTextAttributes([.font: UIFont.systemFont(ofSize: 14, weight: .medium)], for: .normal)
|
||||
navigationItem.rightBarButtonItem = editItem
|
||||
}
|
||||
|
||||
contentStack.addArrangedSubview(
|
||||
insetContainer(makeStatusCard(pending: pending), insets: UIEdgeInsets(top: 16, left: 16, bottom: 8, right: 16))
|
||||
)
|
||||
|
||||
let roleSection = PermissionStaticSectionView()
|
||||
roleSection.apply(title: "新增角色", names: [pending.roleName])
|
||||
contentStack.addArrangedSubview(
|
||||
insetContainer(roleSection, insets: UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16))
|
||||
)
|
||||
|
||||
if !pending.scenicList.isEmpty {
|
||||
let scenicSection = PermissionStaticSectionView()
|
||||
scenicSection.apply(title: "新增景区", names: pending.scenicList.map(\.name))
|
||||
contentStack.addArrangedSubview(
|
||||
makeSectionCard(title: "新增景区", chips: pending.scenicList.map(\.name))
|
||||
insetContainer(scenicSection, insets: UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16))
|
||||
)
|
||||
}
|
||||
|
||||
if !viewModel.rolePermissionList.isEmpty {
|
||||
existingRoleList.apply(rolePermissions: viewModel.rolePermissionList)
|
||||
contentStack.addArrangedSubview(
|
||||
insetContainer(existingRoleList, insets: UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16))
|
||||
)
|
||||
}
|
||||
presentScenicListIfNeeded()
|
||||
}
|
||||
|
||||
private func makeStatusCard(pending: RoleApplyPendingResponse) -> UIView {
|
||||
@ -111,75 +130,66 @@ final class PermissionApplyStatusViewController: BaseViewController {
|
||||
let statusLabel = UILabel()
|
||||
statusLabel.text = pending.statusLabel
|
||||
statusLabel.font = .systemFont(ofSize: 18, weight: .medium)
|
||||
statusLabel.textColor = AppColor.warning
|
||||
statusLabel.textColor = UIColor(hex: 0xFF6B00)
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 8
|
||||
stack.addArrangedSubview(statusLabel)
|
||||
stack.setCustomSpacing(12, after: statusLabel)
|
||||
[
|
||||
("提交时间:", pending.createdAt),
|
||||
("审核时间:", pending.auditedAt ?? "--"),
|
||||
("审核人:", pending.auditedBy ?? "--"),
|
||||
("审核备注:", pending.auditNote.isEmpty ? "--" : pending.auditNote),
|
||||
].forEach { label, value in
|
||||
stack.addArrangedSubview(makeInfoRow(label: label, value: value))
|
||||
}
|
||||
].forEach { stack.addArrangedSubview(makeInfoRow(label: $0.0, value: $0.1)) }
|
||||
|
||||
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)
|
||||
}
|
||||
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
|
||||
return card
|
||||
}
|
||||
|
||||
private func makeInfoRow(label: String, value: String) -> UIView {
|
||||
let row = UIStackView()
|
||||
row.axis = .horizontal
|
||||
row.distribution = .equalSpacing
|
||||
row.alignment = .top
|
||||
row.spacing = 8
|
||||
let left = UILabel()
|
||||
left.text = label
|
||||
left.font = .systemFont(ofSize: 14)
|
||||
left.textColor = AppColor.textSecondary
|
||||
left.textColor = UIColor(hex: 0x4B5563)
|
||||
left.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
let right = UILabel()
|
||||
right.text = value.isEmpty ? "--" : value
|
||||
right.font = .systemFont(ofSize: 14)
|
||||
right.textColor = AppColor.textSecondary
|
||||
right.textColor = UIColor(hex: 0x4B5563)
|
||||
right.textAlignment = .right
|
||||
right.numberOfLines = 0
|
||||
row.addArrangedSubview(left)
|
||||
row.addArrangedSubview(right)
|
||||
right.snp.makeConstraints { make in make.width.lessThanOrEqualTo(220) }
|
||||
return row
|
||||
}
|
||||
|
||||
private func insetContainer(_ content: UIView, insets: UIEdgeInsets) -> UIView {
|
||||
let container = UIView()
|
||||
container.addSubview(content)
|
||||
content.snp.makeConstraints { make in make.edges.equalToSuperview().inset(insets) }
|
||||
return container
|
||||
}
|
||||
|
||||
private func presentScenicListIfNeeded() {
|
||||
guard viewModel.showScenicListDialog, presentedViewController == nil else { return }
|
||||
present(
|
||||
PermissionScenicListDialogViewController(
|
||||
roleName: viewModel.dialogRoleName,
|
||||
scenics: viewModel.dialogScenicList,
|
||||
onDismiss: { [weak self] in self?.viewModel.hideScenicList() }
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func editTapped() {
|
||||
viewModel.navigateToEditIfRejected()
|
||||
}
|
||||
@ -187,8 +197,7 @@ final class PermissionApplyStatusViewController: BaseViewController {
|
||||
private func navigateToEdit(pending: RoleApplyPendingResponse) {
|
||||
var controllers = navigationController?.viewControllers ?? []
|
||||
controllers.removeAll { $0 is PermissionApplyStatusViewController }
|
||||
let applyVC = PermissionApplyViewController(pendingData: pending)
|
||||
controllers.append(applyVC)
|
||||
controllers.append(PermissionApplyViewController(pendingData: pending))
|
||||
navigationController?.setViewControllers(controllers, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,16 +6,17 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 权限申请页,对齐 Android `PermissionApplyScreen`。
|
||||
/// 权限申请页,严格对齐 Android `PermissionApplyScreen`。
|
||||
@MainActor
|
||||
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 scrollView = UIScrollView()
|
||||
private let cardView = UIView()
|
||||
private let roleSelector = PermissionFormSelectorRow(
|
||||
title: "角色信息",
|
||||
tag: "单选",
|
||||
@ -30,16 +31,22 @@ final class PermissionApplyViewController: BaseViewController {
|
||||
placeholder: "请选择景区"
|
||||
)
|
||||
private let selectedScenicTags = PermissionSelectedTagsView()
|
||||
private let existingRoleList = PermissionExistingRoleListView()
|
||||
private let bottomBar = UIView()
|
||||
private let submitButton = PermissionSubmitButton()
|
||||
|
||||
private weak var roleDropdown: PermissionDropdownViewController?
|
||||
private weak var scenicDropdown: PermissionDropdownViewController?
|
||||
|
||||
/// 创建新增或驳回编辑状态的权限申请页。
|
||||
init(
|
||||
pendingData: RoleApplyPendingResponse? = nil,
|
||||
viewModel: PermissionApplyViewModel = PermissionApplyViewModel(),
|
||||
homeAPI: HomeAPI = NetworkServices.shared.homeAPI
|
||||
homeAPI: HomeAPI? = nil
|
||||
) {
|
||||
self.pendingData = pendingData
|
||||
self.viewModel = viewModel
|
||||
self.homeAPI = homeAPI
|
||||
self.homeAPI = homeAPI ?? NetworkServices.shared.homeAPI
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@ -50,41 +57,60 @@ final class PermissionApplyViewController: BaseViewController {
|
||||
|
||||
override func setupNavigationBar() {
|
||||
title = "申请权限"
|
||||
navigationItem.backButtonDisplayMode = .minimal
|
||||
}
|
||||
|
||||
override func setupUI() {
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 16
|
||||
view.backgroundColor = PermissionApplyUITokens.pageBackground
|
||||
scrollView.keyboardDismissMode = .onDrag
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 8
|
||||
bottomBar.backgroundColor = .white
|
||||
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 8
|
||||
let cardStack = UIStackView(arrangedSubviews: [roleSelector, newRoleTags, scenicSelector, selectedScenicTags])
|
||||
cardStack.axis = .vertical
|
||||
cardStack.spacing = 20
|
||||
card.addSubview(cardStack)
|
||||
cardStack.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
}
|
||||
let roleSection = UIStackView(arrangedSubviews: [roleSelector, newRoleTags])
|
||||
roleSection.axis = .vertical
|
||||
roleSection.spacing = 12
|
||||
|
||||
contentStack.addArrangedSubview(bannerView)
|
||||
contentStack.addArrangedSubview(card)
|
||||
scrollView.addSubview(contentStack)
|
||||
let scenicSection = UIStackView(arrangedSubviews: [scenicSelector, selectedScenicTags, existingRoleList])
|
||||
scenicSection.axis = .vertical
|
||||
scenicSection.spacing = 12
|
||||
|
||||
let formStack = UIStackView(arrangedSubviews: [roleSection, scenicSection])
|
||||
formStack.axis = .vertical
|
||||
formStack.spacing = 20
|
||||
|
||||
view.addSubview(bannerView)
|
||||
view.addSubview(scrollView)
|
||||
view.addSubview(submitButton)
|
||||
scrollView.addSubview(cardView)
|
||||
cardView.addSubview(formStack)
|
||||
view.addSubview(bottomBar)
|
||||
bottomBar.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
|
||||
bannerView.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
|
||||
make.bottom.equalTo(submitButton.snp.top).offset(-12)
|
||||
}
|
||||
bottomBar.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
submitButton.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(16)
|
||||
make.leading.trailing.equalToSuperview().inset(16)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
|
||||
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-16)
|
||||
}
|
||||
scrollView.snp.makeConstraints { make in
|
||||
make.top.equalTo(bannerView.snp.bottom)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalTo(bottomBar.snp.top)
|
||||
}
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.edges.equalToSuperview().inset(16)
|
||||
make.width.equalTo(scrollView).offset(-32)
|
||||
}
|
||||
formStack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(16) }
|
||||
|
||||
newRoleTags.isHidden = true
|
||||
selectedScenicTags.isHidden = true
|
||||
existingRoleList.isHidden = true
|
||||
}
|
||||
|
||||
override func bindActions() {
|
||||
@ -94,21 +120,23 @@ final class PermissionApplyViewController: BaseViewController {
|
||||
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) }
|
||||
viewModel.onSubmitSuccess = { [weak self] in
|
||||
Task { @MainActor in self?.handleSubmitSuccess() }
|
||||
}
|
||||
|
||||
bannerView.onApplyNewScenic = { [weak self] in
|
||||
self?.navigationController?.pushViewController(ScenicApplicationViewController(), animated: true)
|
||||
}
|
||||
roleSelector.onTap = { [weak self] in self?.presentRolePicker() }
|
||||
roleSelector.onTap = { [weak self] in self?.viewModel.showRolePickerSheet() }
|
||||
scenicSelector.onTap = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.viewModel.showScenicPickerSheet()
|
||||
if self.viewModel.availableScenics.isEmpty {
|
||||
if self.viewModel.showScenicPicker, self.viewModel.availableScenics.isEmpty {
|
||||
Task { await self.viewModel.loadAvailableScenics(api: self.homeAPI) }
|
||||
}
|
||||
self.presentScenicPickerIfNeeded()
|
||||
}
|
||||
existingRoleList.onShowAllScenics = { [weak self] roleName, scenics in
|
||||
self?.viewModel.showScenicList(roleName: roleName, scenics: scenics)
|
||||
}
|
||||
submitButton.addTarget(self, action: #selector(submitTapped), for: .touchUpInside)
|
||||
|
||||
@ -119,105 +147,112 @@ final class PermissionApplyViewController: BaseViewController {
|
||||
applyViewModel()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func applyViewModel() {
|
||||
if viewModel.selectedRoleName.isEmpty {
|
||||
roleSelector.setValue("请选择角色", isPlaceholder: true)
|
||||
newRoleTags.isHidden = true
|
||||
} else {
|
||||
roleSelector.setValue(viewModel.selectedRoleName, isPlaceholder: false)
|
||||
newRoleTags.apply(title: "新增角色", names: [viewModel.selectedRoleName]) { [weak self] _ in
|
||||
self?.viewModel.clearRoleSelection()
|
||||
}
|
||||
}
|
||||
newRoleTags.apply(title: "新增角色", names: viewModel.selectedRoleName.isEmpty ? [] : [viewModel.selectedRoleName]) {
|
||||
[weak self] _ in self?.viewModel.clearRoleSelection()
|
||||
}
|
||||
|
||||
if viewModel.selectedScenicNames.isEmpty {
|
||||
scenicSelector.setValue("请选择景区", isPlaceholder: true)
|
||||
selectedScenicTags.isHidden = true
|
||||
} else {
|
||||
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: "新增景区",
|
||||
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)
|
||||
}
|
||||
let scenicValue: String
|
||||
switch viewModel.selectedScenicNames.count {
|
||||
case 0: scenicValue = "请选择景区"
|
||||
case 1: scenicValue = viewModel.selectedScenicNames[0]
|
||||
default: scenicValue = "已选中\(viewModel.selectedScenicNames.count)个景区"
|
||||
}
|
||||
scenicSelector.setValue(scenicValue, isPlaceholder: viewModel.selectedScenicNames.isEmpty)
|
||||
selectedScenicTags.apply(title: "新增景区", names: viewModel.selectedScenicNames) { [weak self] index in
|
||||
guard let self,
|
||||
self.viewModel.selectedScenicIds.indices.contains(index),
|
||||
self.viewModel.selectedScenicNames.indices.contains(index) else { return }
|
||||
self.viewModel.removeSelectedScenic(
|
||||
id: self.viewModel.selectedScenicIds[index],
|
||||
name: self.viewModel.selectedScenicNames[index]
|
||||
)
|
||||
}
|
||||
existingRoleList.apply(rolePermissions: viewModel.rolePermissionList)
|
||||
submitButton.applyState(canSubmit: viewModel.canSubmit, submitting: viewModel.isSubmitting)
|
||||
|
||||
submitButton.setSubmitting(viewModel.isSubmitting)
|
||||
presentScenicPickerIfNeeded()
|
||||
if viewModel.isSubmitting { showLoading() } else { hideLoading() }
|
||||
scenicDropdown?.updateScenics(viewModel.filteredScenics, isLoading: viewModel.isLoadingScenics)
|
||||
presentRolePickerIfNeeded()
|
||||
presentScenicPickerIfNeeded()
|
||||
presentDeletionIfNeeded()
|
||||
presentScenicListIfNeeded()
|
||||
}
|
||||
|
||||
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) }
|
||||
})
|
||||
guard viewModel.showRolePicker, roleDropdown == nil, presentedViewController == nil else { return }
|
||||
let dropdown = PermissionDropdownViewController(
|
||||
anchor: roleSelector.selectorControl,
|
||||
mode: .roles(viewModel.availableRoles)
|
||||
)
|
||||
dropdown.onRoleSelect = { [weak self] role in self?.viewModel.selectRole(role) }
|
||||
dropdown.onDismiss = { [weak self, weak dropdown] in
|
||||
self?.viewModel.hideRolePickerSheet()
|
||||
if self?.roleDropdown === dropdown { self?.roleDropdown = nil }
|
||||
}
|
||||
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
|
||||
present(alert, animated: true)
|
||||
roleDropdown = dropdown
|
||||
present(dropdown, animated: true)
|
||||
}
|
||||
|
||||
private func presentScenicPickerIfNeeded() {
|
||||
guard viewModel.showScenicPicker else { return }
|
||||
viewModel.hideScenicPickerSheet()
|
||||
presentScenicPicker()
|
||||
guard viewModel.showScenicPicker, scenicDropdown == nil, presentedViewController == nil else { return }
|
||||
let dropdown = PermissionDropdownViewController(
|
||||
anchor: scenicSelector.selectorControl,
|
||||
mode: .scenics(viewModel.filteredScenics, isLoading: viewModel.isLoadingScenics)
|
||||
)
|
||||
dropdown.onScenicToggle = { [weak self] scenic in self?.viewModel.toggleScenic(scenic) }
|
||||
dropdown.onSearchChange = { [weak self] query in self?.viewModel.updateScenicSearchQuery(query) }
|
||||
dropdown.onDismiss = { [weak self, weak dropdown] in
|
||||
self?.viewModel.hideScenicPickerSheet()
|
||||
if self?.scenicDropdown === dropdown { self?.scenicDropdown = nil }
|
||||
}
|
||||
scenicDropdown = dropdown
|
||||
present(dropdown, animated: true)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
private func presentDeletionIfNeeded() {
|
||||
guard let target = viewModel.pendingDeletion, presentedViewController == nil else { return }
|
||||
present(
|
||||
PermissionDeleteDialogViewController(
|
||||
target: target,
|
||||
onCancel: { [weak self] in self?.viewModel.cancelDeletion() },
|
||||
onConfirm: { [weak self] in self?.viewModel.confirmDeletion() }
|
||||
),
|
||||
animated: true
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
private func presentScenicListIfNeeded() {
|
||||
guard viewModel.showScenicListDialog, presentedViewController == nil else { return }
|
||||
present(
|
||||
PermissionScenicListDialogViewController(
|
||||
roleName: viewModel.dialogRoleName,
|
||||
scenics: viewModel.dialogScenicList,
|
||||
onDismiss: { [weak self] in self?.viewModel.hideScenicList() }
|
||||
),
|
||||
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)
|
||||
private func handleSubmitSuccess() {
|
||||
hideLoading()
|
||||
guard let navigationController else {
|
||||
showToast("提交成功")
|
||||
return
|
||||
}
|
||||
var controllers = navigationController?.viewControllers ?? []
|
||||
controllers.removeAll { $0 === self }
|
||||
let statusVC = PermissionApplyStatusViewController(applyCode: applyCode)
|
||||
controllers.append(statusVC)
|
||||
navigationController?.setViewControllers(controllers, animated: true)
|
||||
navigationController.popViewController(animated: true)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak navigationController] in
|
||||
(navigationController?.topViewController as? BaseViewController)?.showToast("提交成功")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,11 +52,15 @@ final class ScenicSelectionViewController: BaseViewController, UITableViewDataSo
|
||||
emptyLabel.textAlignment = .center
|
||||
emptyLabel.isHidden = true
|
||||
|
||||
let headerStack = UIStackView(arrangedSubviews: [bannerView, searchBar, locationBar])
|
||||
let contentStack = UIStackView(arrangedSubviews: [searchBar, locationBar])
|
||||
contentStack.axis = .vertical
|
||||
contentStack.spacing = 16
|
||||
contentStack.isLayoutMarginsRelativeArrangement = true
|
||||
contentStack.layoutMargins = UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16)
|
||||
|
||||
let headerStack = UIStackView(arrangedSubviews: [bannerView, contentStack])
|
||||
headerStack.axis = .vertical
|
||||
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)
|
||||
|
||||
|
||||
@ -19,20 +19,23 @@ final class HomeScenicHeaderView: UIView {
|
||||
backgroundColor = AppColor.cardBackground
|
||||
titleLabel.font = .app(.subtitle)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
arrowView.tintColor = AppColor.textSecondary
|
||||
arrowView.contentMode = .scaleAspectFit
|
||||
arrowView.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(arrowView)
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(AppSpacing.screenHorizontalInset)
|
||||
make.centerY.equalToSuperview()
|
||||
make.trailing.lessThanOrEqualTo(arrowView.snp.leading).offset(-AppSpacing.xs)
|
||||
make.leading.equalTo(safeAreaLayoutGuide).offset(AppSpacing.screenHorizontalInset)
|
||||
make.centerY.equalTo(safeAreaLayoutGuide)
|
||||
}
|
||||
arrowView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-AppSpacing.screenHorizontalInset)
|
||||
make.centerY.equalToSuperview()
|
||||
make.leading.equalTo(titleLabel.snp.trailing).offset(AppSpacing.xs)
|
||||
make.trailing.lessThanOrEqualTo(safeAreaLayoutGuide).offset(-AppSpacing.screenHorizontalInset)
|
||||
make.centerY.equalTo(safeAreaLayoutGuide)
|
||||
make.width.height.equalTo(16)
|
||||
}
|
||||
|
||||
|
||||
@ -34,7 +34,9 @@ final class HomeWorkStatusCardView: UIView {
|
||||
|
||||
onlineButton.titleLabel?.font = .app(.captionMedium)
|
||||
onlineButton.layer.cornerRadius = AppRadius.sm
|
||||
onlineButton.contentEdgeInsets = UIEdgeInsets(top: 7, left: 14, bottom: 7, right: 14)
|
||||
onlineButton.setConfigurationContentInsets(
|
||||
NSDirectionalEdgeInsets(top: 7, leading: 14, bottom: 7, trailing: 14)
|
||||
)
|
||||
onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside)
|
||||
|
||||
countdownStackView.axis = .horizontal
|
||||
|
||||
@ -6,11 +6,26 @@
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 权限申请页橙色横幅(跳转景区申请)。
|
||||
/// 权限申请模块视觉常量,严格对应 Android Compose 中的尺寸和颜色。
|
||||
enum PermissionApplyUITokens {
|
||||
static let pageBackground = UIColor(hex: 0xF5F5F5)
|
||||
static let selectorBorder = UIColor(hex: 0xE0E3EA)
|
||||
static let placeholder = UIColor(hex: 0xB3B8C2)
|
||||
static let text = UIColor(hex: 0x111827)
|
||||
static let existingBackground = UIColor(hex: 0xF8F9FB)
|
||||
static let selectedBackground = UIColor(hex: 0xF4F9FF)
|
||||
static let selectedBlue = UIColor(hex: 0x1677FF)
|
||||
static let existingGreen = UIColor(hex: 0x52C41A)
|
||||
static let existingGreenBackground = UIColor(hex: 0xE6F7E6)
|
||||
static let disabledButton = UIColor(hex: 0xE0E0E0)
|
||||
}
|
||||
|
||||
/// 权限申请页顶部“申请平台开通新景区”横幅。
|
||||
final class PermissionApplyBannerView: UIView {
|
||||
|
||||
var onApplyNewScenic: (() -> Void)?
|
||||
|
||||
private let iconView = UIImageView()
|
||||
private let titleLabel = UILabel()
|
||||
private let applyButton = UIButton(type: .system)
|
||||
|
||||
@ -18,28 +33,50 @@ final class PermissionApplyBannerView: UIView {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = AppColor.warningBackground
|
||||
|
||||
iconView.image = UIImage(named: "permission_open_scenic")
|
||||
?? UIImage(systemName: "building.2.fill")?.withTintColor(AppColor.warning, renderingMode: .alwaysOriginal)
|
||||
iconView.contentMode = .scaleAspectFit
|
||||
iconView.isAccessibilityElement = false
|
||||
|
||||
titleLabel.text = "没有找到心仪的景区"
|
||||
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
titleLabel.textColor = AppColor.warning
|
||||
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
applyButton.setTitle("申请平台开通新景区", for: .normal)
|
||||
applyButton.titleLabel?.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
applyButton.setTitleColor(AppColor.warning, for: .normal)
|
||||
var configuration = UIButton.Configuration.plain()
|
||||
configuration.title = "申请平台开通新景区"
|
||||
configuration.image = UIImage(named: "permission_more_scenic")
|
||||
?? UIImage(systemName: "chevron.right")
|
||||
configuration.imagePlacement = .trailing
|
||||
configuration.imagePadding = 2
|
||||
configuration.contentInsets = .zero
|
||||
configuration.baseForegroundColor = AppColor.warning
|
||||
configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
|
||||
var outgoing = incoming
|
||||
outgoing.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
return outgoing
|
||||
}
|
||||
applyButton.configuration = configuration
|
||||
applyButton.accessibilityLabel = "申请平台开通新景区"
|
||||
applyButton.addTarget(self, action: #selector(applyTapped), for: .touchUpInside)
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(applyButton)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
[iconView, titleLabel, applyButton].forEach(addSubview)
|
||||
iconView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalTo(iconView.snp.trailing)
|
||||
make.centerY.equalToSuperview()
|
||||
make.trailing.lessThanOrEqualTo(applyButton.snp.leading).offset(-12)
|
||||
}
|
||||
applyButton.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.height.greaterThanOrEqualTo(44)
|
||||
}
|
||||
snp.makeConstraints { make in
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
snp.makeConstraints { make in make.height.equalTo(48) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
@ -52,7 +89,7 @@ final class PermissionApplyBannerView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 表单字段选择行,对齐 Android `FormField` + Selector。
|
||||
/// Android 表单标题、说明和锚定选择框组合视图。
|
||||
final class PermissionFormSelectorRow: UIView {
|
||||
|
||||
var onTap: (() -> Void)?
|
||||
@ -60,6 +97,7 @@ final class PermissionFormSelectorRow: UIView {
|
||||
private let titleLabel = UILabel()
|
||||
private let tagLabel = UILabel()
|
||||
private let descriptionLabel = UILabel()
|
||||
let selectorControl = UIControl()
|
||||
private let valueLabel = UILabel()
|
||||
private let chevron = UIImageView(image: UIImage(systemName: "chevron.down"))
|
||||
|
||||
@ -67,7 +105,7 @@ final class PermissionFormSelectorRow: UIView {
|
||||
super.init(frame: .zero)
|
||||
titleLabel.text = title
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
titleLabel.textColor = AppColor.textPrimary
|
||||
titleLabel.textColor = UIColor(hex: 0x333333)
|
||||
|
||||
tagLabel.text = tag
|
||||
tagLabel.font = .systemFont(ofSize: 12)
|
||||
@ -76,28 +114,28 @@ final class PermissionFormSelectorRow: UIView {
|
||||
|
||||
descriptionLabel.text = description
|
||||
descriptionLabel.font = .systemFont(ofSize: 12)
|
||||
descriptionLabel.textColor = UIColor(hex: 0xB3B8C2)
|
||||
descriptionLabel.textColor = PermissionApplyUITokens.placeholder
|
||||
descriptionLabel.numberOfLines = 0
|
||||
|
||||
selectorControl.backgroundColor = .white
|
||||
selectorControl.layer.cornerRadius = 12
|
||||
selectorControl.layer.borderWidth = 1
|
||||
selectorControl.layer.borderColor = PermissionApplyUITokens.selectorBorder.cgColor
|
||||
selectorControl.accessibilityLabel = title
|
||||
selectorControl.addTarget(self, action: #selector(rowTapped), for: .touchUpInside)
|
||||
|
||||
valueLabel.text = placeholder
|
||||
valueLabel.font = .systemFont(ofSize: 14)
|
||||
valueLabel.textColor = UIColor(hex: 0xB3B8C2)
|
||||
valueLabel.textColor = PermissionApplyUITokens.placeholder
|
||||
valueLabel.lineBreakMode = .byTruncatingTail
|
||||
|
||||
chevron.tintColor = UIColor(hex: 0xB3B8C2)
|
||||
chevron.tintColor = PermissionApplyUITokens.placeholder
|
||||
chevron.contentMode = .scaleAspectFit
|
||||
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(rowTapped))
|
||||
addGestureRecognizer(tap)
|
||||
[titleLabel, tagLabel, descriptionLabel, selectorControl].forEach(addSubview)
|
||||
[valueLabel, chevron].forEach(selectorControl.addSubview)
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(tagLabel)
|
||||
addSubview(descriptionLabel)
|
||||
addSubview(valueLabel)
|
||||
addSubview(chevron)
|
||||
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview()
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in make.top.leading.equalToSuperview() }
|
||||
tagLabel.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(titleLabel)
|
||||
make.trailing.equalToSuperview()
|
||||
@ -107,121 +145,18 @@ final class PermissionFormSelectorRow: UIView {
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||
make.leading.trailing.equalToSuperview()
|
||||
}
|
||||
valueLabel.snp.makeConstraints { make in
|
||||
selectorControl.snp.makeConstraints { make in
|
||||
make.top.equalTo(descriptionLabel.snp.bottom).offset(8)
|
||||
make.leading.bottom.equalToSuperview()
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.height.equalTo(46)
|
||||
}
|
||||
valueLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.centerY.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 ? UIColor(hex: 0xB3B8C2) : 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: 16, weight: .medium)
|
||||
titleLabel.textColor = UIColor(hex: 0x111827)
|
||||
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.trailing.equalToSuperview().offset(-16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
@ -232,23 +167,156 @@ final class PermissionTagChip: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@objc private func removeTapped() {
|
||||
onRemove?()
|
||||
/// 更新选择框显示值。
|
||||
func setValue(_ text: String, isPlaceholder: Bool) {
|
||||
valueLabel.text = text
|
||||
valueLabel.textColor = isPlaceholder ? PermissionApplyUITokens.placeholder : PermissionApplyUITokens.text
|
||||
selectorControl.accessibilityValue = text
|
||||
}
|
||||
|
||||
@objc private func rowTapped() {
|
||||
onTap?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部主按钮。
|
||||
final class PermissionSubmitButton: UIButton {
|
||||
/// 权限标签的展示样式。
|
||||
enum PermissionChipStyle: Hashable {
|
||||
case selectedRemovable
|
||||
case selectedStatic
|
||||
case existingGreen
|
||||
}
|
||||
|
||||
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)
|
||||
/// Diffable 标签网格中的稳定条目。
|
||||
struct PermissionChipItem: Hashable {
|
||||
let id: String
|
||||
let title: String
|
||||
}
|
||||
|
||||
/// Android `FlowRow` 的标签左对齐与换行坐标计算器。
|
||||
struct PermissionChipLayoutMetrics {
|
||||
static let itemHeight: CGFloat = 44
|
||||
static let interitemSpacing: CGFloat = 12
|
||||
static let lineSpacing: CGFloat = 2
|
||||
|
||||
/// 按容器宽度为标签生成从左到右、超宽换行的坐标。
|
||||
static func makeFrames(itemWidths: [CGFloat], containerWidth: CGFloat) -> [CGRect] {
|
||||
guard containerWidth > 0 else { return [] }
|
||||
var x: CGFloat = 0
|
||||
var y: CGFloat = 0
|
||||
return itemWidths.map { rawWidth in
|
||||
let width = min(containerWidth, max(44, rawWidth))
|
||||
if x > 0, x + width > containerWidth + 0.5 {
|
||||
x = 0
|
||||
y += itemHeight + lineSpacing
|
||||
}
|
||||
let frame = CGRect(x: x, y: y, width: width, height: itemHeight)
|
||||
x += width + interitemSpacing
|
||||
return frame
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 确定性计算左对齐换行坐标,等价于 Android `FlowRow`。
|
||||
private final class PermissionChipFlowLayout: UICollectionViewLayout {
|
||||
|
||||
var itemWidthProvider: ((IndexPath) -> CGFloat)?
|
||||
private var cachedAttributes: [IndexPath: UICollectionViewLayoutAttributes] = [:]
|
||||
private var contentHeight: CGFloat = 1
|
||||
|
||||
override func prepare() {
|
||||
super.prepare()
|
||||
guard let collectionView, collectionView.bounds.width > 0 else {
|
||||
cachedAttributes = [:]
|
||||
contentHeight = 1
|
||||
return
|
||||
}
|
||||
|
||||
cachedAttributes = [:]
|
||||
let availableWidth = collectionView.bounds.width
|
||||
var indexPaths: [IndexPath] = []
|
||||
var itemWidths: [CGFloat] = []
|
||||
for section in 0..<collectionView.numberOfSections {
|
||||
for item in 0..<collectionView.numberOfItems(inSection: section) {
|
||||
let indexPath = IndexPath(item: item, section: section)
|
||||
indexPaths.append(indexPath)
|
||||
itemWidths.append(itemWidthProvider?(indexPath) ?? 44)
|
||||
}
|
||||
}
|
||||
let frames = PermissionChipLayoutMetrics.makeFrames(
|
||||
itemWidths: itemWidths,
|
||||
containerWidth: availableWidth
|
||||
)
|
||||
for (index, indexPath) in indexPaths.enumerated() {
|
||||
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
|
||||
attributes.frame = frames[index]
|
||||
cachedAttributes[indexPath] = attributes
|
||||
}
|
||||
contentHeight = frames.last?.maxY ?? 1
|
||||
}
|
||||
|
||||
override var collectionViewContentSize: CGSize {
|
||||
CGSize(width: collectionView?.bounds.width ?? 0, height: contentHeight)
|
||||
}
|
||||
|
||||
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
|
||||
cachedAttributes.values.filter { $0.frame.intersects(rect) }
|
||||
}
|
||||
|
||||
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
|
||||
cachedAttributes[indexPath]
|
||||
}
|
||||
|
||||
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
|
||||
guard let collectionView else { return true }
|
||||
return abs(collectionView.bounds.width - newBounds.width) > 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// 自适应换行的权限标签网格。
|
||||
final class PermissionChipCollectionView: UIView {
|
||||
|
||||
private enum Section { case main }
|
||||
|
||||
var onRemove: ((Int) -> Void)?
|
||||
|
||||
private let style: PermissionChipStyle
|
||||
private let layout: PermissionChipFlowLayout
|
||||
private let collectionView: UICollectionView
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, PermissionChipItem>!
|
||||
private var items: [PermissionChipItem] = []
|
||||
private var heightConstraint: Constraint?
|
||||
|
||||
init(style: PermissionChipStyle) {
|
||||
self.style = style
|
||||
let layout = PermissionChipFlowLayout()
|
||||
self.layout = layout
|
||||
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||
super.init(frame: .zero)
|
||||
|
||||
collectionView.backgroundColor = .clear
|
||||
collectionView.isScrollEnabled = false
|
||||
collectionView.register(PermissionChipCell.self, forCellWithReuseIdentifier: PermissionChipCell.reuseIdentifier)
|
||||
addSubview(collectionView)
|
||||
collectionView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
snp.makeConstraints { make in heightConstraint = make.height.equalTo(1).constraint }
|
||||
|
||||
dataSource = UICollectionViewDiffableDataSource<Section, PermissionChipItem>(
|
||||
collectionView: collectionView
|
||||
) { [weak self] collectionView, indexPath, item in
|
||||
guard let self,
|
||||
let cell = collectionView.dequeueReusableCell(
|
||||
withReuseIdentifier: PermissionChipCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as? PermissionChipCell else { return UICollectionViewCell() }
|
||||
cell.apply(title: item.title, style: self.style)
|
||||
cell.onRemove = { [weak self] in
|
||||
guard let self, let currentIndex = self.items.firstIndex(of: item) else { return }
|
||||
self.onRemove?(currentIndex)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
layout.itemWidthProvider = { [weak self] indexPath in
|
||||
self?.itemWidth(at: indexPath) ?? 44
|
||||
}
|
||||
}
|
||||
|
||||
@ -257,9 +325,189 @@ final class PermissionSubmitButton: UIButton {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func setSubmitting(_ submitting: Bool) {
|
||||
isEnabled = !submitting
|
||||
alpha = submitting ? 0.6 : 1
|
||||
setTitle(submitting ? "提交中..." : "提交审核", for: .normal)
|
||||
/// 使用 Diffable snapshot 更新标签。
|
||||
func apply(titles: [String]) {
|
||||
var occurrences: [String: Int] = [:]
|
||||
items = titles.map { title in
|
||||
let occurrence = occurrences[title, default: 0]
|
||||
occurrences[title] = occurrence + 1
|
||||
return PermissionChipItem(id: "\(title)#\(occurrence)", title: title)
|
||||
}
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, PermissionChipItem>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items)
|
||||
dataSource.apply(snapshot, animatingDifferences: true) { [weak self] in
|
||||
self?.updateHeight()
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
updateHeight()
|
||||
}
|
||||
|
||||
private func updateHeight() {
|
||||
collectionView.collectionViewLayout.invalidateLayout()
|
||||
collectionView.layoutIfNeeded()
|
||||
let height = max(1, collectionView.collectionViewLayout.collectionViewContentSize.height)
|
||||
heightConstraint?.update(offset: height)
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
private func itemWidth(at indexPath: IndexPath) -> CGFloat {
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return 44 }
|
||||
let textWidth = ceil((item.title as NSString).size(withAttributes: [
|
||||
.font: UIFont.systemFont(ofSize: 14),
|
||||
]).width)
|
||||
let horizontalChrome: CGFloat = style == .selectedRemovable ? 42 : 24
|
||||
return textWidth + horizontalChrome
|
||||
}
|
||||
}
|
||||
|
||||
/// 带标题背景的“新增角色/新增景区”区域。
|
||||
final class PermissionSelectedTagsView: UIView {
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let chips = PermissionChipCollectionView(style: .selectedRemovable)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = PermissionApplyUITokens.selectedBackground
|
||||
layer.cornerRadius = 12
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
titleLabel.textColor = PermissionApplyUITokens.text
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(chips)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(12)
|
||||
}
|
||||
chips.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(12)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(12)
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
isHidden = names.isEmpty
|
||||
chips.onRemove = onRemove
|
||||
chips.apply(titles: names)
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限申请页底部主按钮。
|
||||
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)
|
||||
layer.cornerRadius = 8
|
||||
accessibilityLabel = "提交审核"
|
||||
snp.makeConstraints { make in make.height.equalTo(48) }
|
||||
applyState(canSubmit: false, submitting: false)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 更新可提交与提交中状态。
|
||||
func applyState(canSubmit: Bool, submitting: Bool) {
|
||||
isEnabled = canSubmit && !submitting
|
||||
backgroundColor = isEnabled ? PermissionApplyUITokens.selectedBlue : PermissionApplyUITokens.disabledButton
|
||||
setTitle(submitting ? "提交中..." : "提交审核", for: .normal)
|
||||
accessibilityValue = submitting ? "提交中" : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限标签 CollectionView 单元格。
|
||||
private final class PermissionChipCell: UICollectionViewCell {
|
||||
|
||||
static let reuseIdentifier = "PermissionChipCell"
|
||||
|
||||
var onRemove: (() -> Void)?
|
||||
|
||||
private let chipBackground = UIView()
|
||||
private let label = UILabel()
|
||||
private let removeButton = UIButton(type: .system)
|
||||
private let removeImageView = UIImageView(image: UIImage(systemName: "xmark"))
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
label.font = .systemFont(ofSize: 14)
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
removeButton.addTarget(self, action: #selector(removeTapped), for: .touchUpInside)
|
||||
removeButton.accessibilityLabel = "删除"
|
||||
removeImageView.contentMode = .scaleAspectFit
|
||||
removeImageView.isUserInteractionEnabled = false
|
||||
contentView.addSubview(chipBackground)
|
||||
chipBackground.addSubview(label)
|
||||
chipBackground.addSubview(removeImageView)
|
||||
contentView.addSubview(removeButton)
|
||||
|
||||
chipBackground.snp.makeConstraints { make in
|
||||
make.leading.trailing.centerY.equalToSuperview()
|
||||
make.height.equalTo(34)
|
||||
}
|
||||
removeButton.snp.makeConstraints { make in
|
||||
make.trailing.centerY.equalToSuperview()
|
||||
make.size.equalTo(44)
|
||||
}
|
||||
removeImageView.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-10)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(14)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(title: String, style: PermissionChipStyle) {
|
||||
label.text = title
|
||||
chipBackground.layer.cornerRadius = style == .existingGreen ? 4 : 8
|
||||
chipBackground.layer.borderWidth = 1
|
||||
switch style {
|
||||
case .selectedRemovable, .selectedStatic:
|
||||
chipBackground.backgroundColor = style == .selectedRemovable
|
||||
? PermissionApplyUITokens.selectedBackground : .white
|
||||
chipBackground.layer.borderColor = PermissionApplyUITokens.selectedBlue.cgColor
|
||||
label.textColor = PermissionApplyUITokens.selectedBlue
|
||||
removeImageView.tintColor = PermissionApplyUITokens.selectedBlue
|
||||
case .existingGreen:
|
||||
chipBackground.backgroundColor = PermissionApplyUITokens.existingGreenBackground
|
||||
chipBackground.layer.borderColor = PermissionApplyUITokens.existingGreen.cgColor
|
||||
label.textColor = PermissionApplyUITokens.existingGreen
|
||||
removeImageView.tintColor = PermissionApplyUITokens.existingGreen
|
||||
}
|
||||
let removable = style == .selectedRemovable
|
||||
removeButton.isHidden = !removable
|
||||
removeImageView.isHidden = !removable
|
||||
label.snp.remakeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(12)
|
||||
make.centerY.equalToSuperview()
|
||||
if removable {
|
||||
make.trailing.equalTo(removeImageView.snp.leading).offset(-6)
|
||||
} else {
|
||||
make.trailing.equalToSuperview().offset(-12)
|
||||
}
|
||||
}
|
||||
accessibilityLabel = title
|
||||
}
|
||||
|
||||
@objc private func removeTapped() {
|
||||
onRemove?()
|
||||
}
|
||||
}
|
||||
|
||||
360
suixinkan/UI/Home/Views/PermissionDropdownViewController.swift
Normal file
360
suixinkan/UI/Home/Views/PermissionDropdownViewController.swift
Normal file
@ -0,0 +1,360 @@
|
||||
//
|
||||
// PermissionDropdownViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 权限申请下拉菜单的展示模式。
|
||||
enum PermissionDropdownMode {
|
||||
case roles([RoleOption])
|
||||
case scenics([ScenicOption], isLoading: Bool)
|
||||
}
|
||||
|
||||
/// 锚定在表单选择框下方的 Android 风格权限下拉菜单。
|
||||
final class PermissionDropdownViewController: UIViewController, UITableViewDelegate, UITextFieldDelegate {
|
||||
|
||||
private enum Section { case main }
|
||||
|
||||
fileprivate enum Item: Hashable {
|
||||
case role(RoleOption)
|
||||
case scenic(ScenicOption)
|
||||
case loading
|
||||
case empty(String)
|
||||
}
|
||||
|
||||
var onDismiss: (() -> Void)?
|
||||
var onRoleSelect: ((RoleOption) -> Void)?
|
||||
var onScenicToggle: ((ScenicOption) -> Void)?
|
||||
var onSearchChange: ((String) -> Void)?
|
||||
|
||||
private let anchorFrame: CGRect
|
||||
private var mode: PermissionDropdownMode
|
||||
private var allScenics: [ScenicOption] = []
|
||||
private var query = ""
|
||||
private let dismissControl = UIControl()
|
||||
private let cardView = UIView()
|
||||
private let searchContainer = UIView()
|
||||
private let searchField = UITextField()
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
||||
private var cardHeightConstraint: Constraint?
|
||||
|
||||
/// 创建与指定选择框等宽的下拉菜单。
|
||||
init(anchor: UIView, mode: PermissionDropdownMode) {
|
||||
anchorFrame = anchor.convert(anchor.bounds, to: nil)
|
||||
self.mode = mode
|
||||
if case .scenics(let scenics, _) = mode {
|
||||
allScenics = scenics
|
||||
}
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .overFullScreen
|
||||
modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .clear
|
||||
dismissControl.backgroundColor = .clear
|
||||
dismissControl.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside)
|
||||
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 4
|
||||
cardView.layer.shadowColor = UIColor.black.cgColor
|
||||
cardView.layer.shadowOpacity = 0.14
|
||||
cardView.layer.shadowRadius = 8
|
||||
cardView.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||
cardView.clipsToBounds = false
|
||||
|
||||
searchContainer.backgroundColor = .white
|
||||
searchContainer.layer.cornerRadius = 8
|
||||
searchContainer.layer.borderWidth = 1
|
||||
searchContainer.layer.borderColor = PermissionApplyUITokens.selectorBorder.cgColor
|
||||
|
||||
let searchIcon = UIImageView(image: UIImage(systemName: "magnifyingglass"))
|
||||
searchIcon.tintColor = AppColor.textTertiary
|
||||
searchIcon.contentMode = .scaleAspectFit
|
||||
searchField.placeholder = "搜索景区名称"
|
||||
searchField.font = .systemFont(ofSize: 14)
|
||||
searchField.textColor = PermissionApplyUITokens.text
|
||||
searchField.returnKeyType = .search
|
||||
searchField.clearButtonMode = .whileEditing
|
||||
searchField.delegate = self
|
||||
searchField.addTarget(self, action: #selector(searchChanged), for: .editingChanged)
|
||||
searchField.accessibilityLabel = "搜索景区名称"
|
||||
|
||||
tableView.backgroundColor = .white
|
||||
tableView.separatorStyle = .none
|
||||
tableView.keyboardDismissMode = .onDrag
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 52
|
||||
tableView.delegate = self
|
||||
tableView.register(PermissionDropdownCell.self, forCellReuseIdentifier: PermissionDropdownCell.reuseIdentifier)
|
||||
|
||||
view.addSubview(dismissControl)
|
||||
view.addSubview(cardView)
|
||||
cardView.addSubview(searchContainer)
|
||||
searchContainer.addSubview(searchIcon)
|
||||
searchContainer.addSubview(searchField)
|
||||
cardView.addSubview(tableView)
|
||||
|
||||
dismissControl.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(anchorFrame.minX)
|
||||
make.top.equalToSuperview().offset(anchorFrame.maxY + 4)
|
||||
make.width.equalTo(anchorFrame.width)
|
||||
cardHeightConstraint = make.height.equalTo(80).constraint
|
||||
}
|
||||
searchContainer.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(8)
|
||||
make.leading.trailing.equalToSuperview().inset(12)
|
||||
make.height.equalTo(38)
|
||||
}
|
||||
searchIcon.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(12)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(18)
|
||||
}
|
||||
searchField.snp.makeConstraints { make in
|
||||
make.leading.equalTo(searchIcon.snp.trailing).offset(10)
|
||||
make.trailing.equalToSuperview().offset(-8)
|
||||
make.top.bottom.equalToSuperview()
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.top.equalTo(searchContainer.snp.bottom).offset(4)
|
||||
}
|
||||
|
||||
configureDataSource()
|
||||
applyMode(animated: false)
|
||||
}
|
||||
|
||||
/// 刷新景区数据和加载状态,同时保留下拉菜单及搜索词。
|
||||
func updateScenics(_ scenics: [ScenicOption], isLoading: Bool) {
|
||||
allScenics = scenics
|
||||
mode = .scenics(scenics, isLoading: isLoading)
|
||||
guard isViewLoaded else { return }
|
||||
applyMode(animated: false)
|
||||
}
|
||||
|
||||
private func configureDataSource() {
|
||||
dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView) {
|
||||
tableView, indexPath, item in
|
||||
guard let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: PermissionDropdownCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as? PermissionDropdownCell else { return UITableViewCell() }
|
||||
cell.apply(item: item)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
private func applyMode(animated: Bool) {
|
||||
var items: [Item]
|
||||
switch mode {
|
||||
case .roles(let roles):
|
||||
searchContainer.isHidden = true
|
||||
tableView.snp.remakeConstraints { make in make.edges.equalToSuperview() }
|
||||
items = roles.map(Item.role)
|
||||
if items.isEmpty { items = [.empty("暂无可用角色")] }
|
||||
case .scenics(_, let isLoading):
|
||||
searchContainer.isHidden = isLoading
|
||||
if isLoading {
|
||||
tableView.snp.remakeConstraints { make in make.edges.equalToSuperview() }
|
||||
items = [.loading]
|
||||
} else {
|
||||
tableView.snp.remakeConstraints { make in
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
make.top.equalTo(searchContainer.snp.bottom).offset(4)
|
||||
}
|
||||
let filtered = filteredScenics()
|
||||
items = filtered.map(Item.scenic)
|
||||
if items.isEmpty {
|
||||
items = [.empty(query.isEmpty ? "暂无可用景区" : "未找到相关景区")]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(items)
|
||||
let completion: () -> Void = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.updateCardHeight(itemCount: items.count)
|
||||
}
|
||||
if animated {
|
||||
dataSource.apply(snapshot, animatingDifferences: true, completion: completion)
|
||||
} else {
|
||||
UIView.performWithoutAnimation {
|
||||
dataSource.apply(snapshot, animatingDifferences: false, completion: completion)
|
||||
tableView.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func filteredScenics() -> [ScenicOption] {
|
||||
let value = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { return allScenics }
|
||||
return allScenics.filter { $0.name.localizedCaseInsensitiveContains(value) }
|
||||
}
|
||||
|
||||
private func updateCardHeight(itemCount: Int) {
|
||||
tableView.layoutIfNeeded()
|
||||
let hasSearch = !searchContainer.isHidden
|
||||
let searchHeight: CGFloat = hasSearch ? 58 : 0
|
||||
let estimatedListHeight = max(64, min(262, tableView.contentSize.height > 0 ? tableView.contentSize.height : CGFloat(itemCount) * 52))
|
||||
cardHeightConstraint?.update(offset: min(320, searchHeight + estimatedListHeight))
|
||||
view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: false)
|
||||
guard let item = dataSource.itemIdentifier(for: indexPath) else { return }
|
||||
switch item {
|
||||
case .role(let role):
|
||||
onRoleSelect?(role)
|
||||
dismissMenu(notify: false)
|
||||
case .scenic(let scenic):
|
||||
guard !scenic.isDisabled else { return }
|
||||
onScenicToggle?(scenic)
|
||||
if let index = allScenics.firstIndex(where: { $0.id == scenic.id }) {
|
||||
allScenics[index].isSelected.toggle()
|
||||
}
|
||||
mode = .scenics(allScenics, isLoading: false)
|
||||
applyMode(animated: false)
|
||||
case .loading, .empty:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func searchChanged() {
|
||||
query = searchField.text ?? ""
|
||||
onSearchChange?(query)
|
||||
applyMode(animated: false)
|
||||
}
|
||||
|
||||
@objc private func dismissTapped() {
|
||||
dismissMenu(notify: true)
|
||||
}
|
||||
|
||||
private func dismissMenu(notify: Bool) {
|
||||
searchField.resignFirstResponder()
|
||||
dismiss(animated: true) { [weak self] in
|
||||
if notify { self?.onDismiss?() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限下拉菜单中的角色、景区、加载或空状态单元格。
|
||||
private final class PermissionDropdownCell: UITableViewCell {
|
||||
|
||||
static let reuseIdentifier = "PermissionDropdownCell"
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let detailLabel = UILabel()
|
||||
private let checkCircle = UIView()
|
||||
private let checkImage = UIImageView(image: UIImage(systemName: "checkmark"))
|
||||
private let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
backgroundColor = .white
|
||||
selectionStyle = .none
|
||||
titleLabel.font = .systemFont(ofSize: 14)
|
||||
titleLabel.textColor = PermissionApplyUITokens.text
|
||||
titleLabel.numberOfLines = 1
|
||||
detailLabel.font = .systemFont(ofSize: 12)
|
||||
detailLabel.textColor = PermissionApplyUITokens.placeholder
|
||||
detailLabel.numberOfLines = 0
|
||||
checkCircle.frame = CGRect(x: 0, y: 0, width: 20, height: 20)
|
||||
checkCircle.layer.cornerRadius = 10
|
||||
checkImage.tintColor = .white
|
||||
checkImage.contentMode = .scaleAspectFit
|
||||
activityIndicator.color = PermissionApplyUITokens.selectedBlue
|
||||
|
||||
[titleLabel, detailLabel, activityIndicator].forEach(contentView.addSubview)
|
||||
accessoryView = checkCircle
|
||||
checkCircle.addSubview(checkImage)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview().offset(12)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.trailing.equalToSuperview().offset(-12)
|
||||
}
|
||||
detailLabel.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(4)
|
||||
make.leading.equalTo(titleLabel)
|
||||
make.trailing.equalToSuperview().offset(-12)
|
||||
make.bottom.equalToSuperview().offset(-10)
|
||||
}
|
||||
checkImage.snp.makeConstraints { make in make.center.equalToSuperview(); make.size.equalTo(14) }
|
||||
activityIndicator.snp.makeConstraints { make in make.center.equalToSuperview() }
|
||||
contentView.snp.makeConstraints { make in make.height.greaterThanOrEqualTo(48) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(item: PermissionDropdownViewController.Item) {
|
||||
activityIndicator.stopAnimating()
|
||||
titleLabel.textAlignment = .left
|
||||
titleLabel.font = .systemFont(ofSize: 14)
|
||||
detailLabel.isHidden = true
|
||||
checkCircle.isHidden = true
|
||||
isUserInteractionEnabled = true
|
||||
switch item {
|
||||
case .role(let role):
|
||||
titleLabel.text = role.name
|
||||
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
titleLabel.textColor = PermissionApplyUITokens.text
|
||||
detailLabel.text = role.description
|
||||
detailLabel.isHidden = role.description.isEmpty
|
||||
checkCircle.isHidden = false
|
||||
checkCircle.backgroundColor = role.isSelected
|
||||
? PermissionApplyUITokens.selectedBlue : UIColor(hex: 0xE5E7EB)
|
||||
checkImage.isHidden = !role.isSelected
|
||||
case .scenic(let scenic):
|
||||
titleLabel.text = scenic.name
|
||||
titleLabel.textColor = scenic.isDisabled ? AppColor.textTertiary : PermissionApplyUITokens.text
|
||||
checkCircle.isHidden = false
|
||||
checkCircle.backgroundColor = scenic.isDisabled && scenic.isSelected
|
||||
? AppColor.textTertiary
|
||||
: (scenic.isSelected ? PermissionApplyUITokens.selectedBlue : UIColor(hex: 0xE5E7EB))
|
||||
checkImage.isHidden = !scenic.isSelected
|
||||
isUserInteractionEnabled = !scenic.isDisabled
|
||||
case .loading:
|
||||
titleLabel.text = nil
|
||||
activityIndicator.startAnimating()
|
||||
isUserInteractionEnabled = false
|
||||
case .empty(let message):
|
||||
titleLabel.text = message
|
||||
titleLabel.textAlignment = .center
|
||||
titleLabel.textColor = AppColor.textTertiary
|
||||
isUserInteractionEnabled = false
|
||||
}
|
||||
if detailLabel.isHidden {
|
||||
titleLabel.snp.remakeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.trailing.equalToSuperview().offset(-12)
|
||||
make.centerY.equalToSuperview()
|
||||
make.top.greaterThanOrEqualToSuperview().offset(12)
|
||||
make.bottom.lessThanOrEqualToSuperview().offset(-12)
|
||||
}
|
||||
} else {
|
||||
titleLabel.snp.remakeConstraints { make in
|
||||
make.top.equalToSuperview().offset(10)
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.trailing.equalToSuperview().offset(-12)
|
||||
}
|
||||
}
|
||||
accessibilityLabel = [titleLabel.text, detailLabel.isHidden ? nil : detailLabel.text]
|
||||
.compactMap { $0 }.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
//
|
||||
// PermissionRequiredDialogViewController.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// 首页无业务权限时展示的 Android 风格强制提示弹窗。
|
||||
@MainActor
|
||||
final class PermissionRequiredDialogViewController: UIViewController {
|
||||
|
||||
private let onExitApp: () -> Void
|
||||
private let onGoToApply: () -> Void
|
||||
private let dimView = UIView()
|
||||
private let cardView = UIView()
|
||||
|
||||
/// 创建不可点击遮罩关闭的权限提示弹窗。
|
||||
init(onExitApp: @escaping () -> Void, onGoToApply: @escaping () -> Void) {
|
||||
self.onExitApp = onExitApp
|
||||
self.onGoToApply = onGoToApply
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .overFullScreen
|
||||
modalTransitionStyle = .crossDissolve
|
||||
isModalInPresentation = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .clear
|
||||
dimView.backgroundColor = UIColor.black.withAlphaComponent(0.32)
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 16
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = "提示"
|
||||
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||
titleLabel.textColor = UIColor(hex: 0x333333)
|
||||
titleLabel.textAlignment = .center
|
||||
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.text = "您还没有权限,请先申请权限"
|
||||
messageLabel.font = .systemFont(ofSize: 16)
|
||||
messageLabel.textColor = UIColor(hex: 0x4B5563)
|
||||
messageLabel.textAlignment = .center
|
||||
messageLabel.numberOfLines = 0
|
||||
|
||||
let exitButton = makeButton(
|
||||
title: "退出app",
|
||||
titleColor: UIColor(hex: 0x333333),
|
||||
backgroundColor: .white,
|
||||
borderColor: UIColor(hex: 0xE5E5E5)
|
||||
)
|
||||
exitButton.addTarget(self, action: #selector(exitTapped), for: .touchUpInside)
|
||||
|
||||
let applyButton = makeButton(
|
||||
title: "去申请",
|
||||
titleColor: .white,
|
||||
backgroundColor: AppColor.primary,
|
||||
borderColor: nil
|
||||
)
|
||||
applyButton.addTarget(self, action: #selector(applyTapped), for: .touchUpInside)
|
||||
|
||||
let buttons = UIStackView(arrangedSubviews: [exitButton, applyButton])
|
||||
buttons.axis = .horizontal
|
||||
buttons.spacing = 12
|
||||
buttons.distribution = .fillEqually
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, messageLabel, buttons])
|
||||
stack.axis = .vertical
|
||||
stack.alignment = .fill
|
||||
stack.spacing = 16
|
||||
stack.setCustomSpacing(24, after: messageLabel)
|
||||
|
||||
view.addSubview(dimView)
|
||||
view.addSubview(cardView)
|
||||
cardView.addSubview(stack)
|
||||
|
||||
dimView.snp.makeConstraints { make in make.edges.equalToSuperview() }
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
}
|
||||
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(24) }
|
||||
buttons.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
}
|
||||
|
||||
private func makeButton(
|
||||
title: String,
|
||||
titleColor: UIColor,
|
||||
backgroundColor: UIColor,
|
||||
borderColor: UIColor?
|
||||
) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(titleColor, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
button.backgroundColor = backgroundColor
|
||||
button.layer.cornerRadius = 8
|
||||
button.layer.borderWidth = borderColor == nil ? 0 : 1
|
||||
button.layer.borderColor = borderColor?.cgColor
|
||||
button.accessibilityLabel = title
|
||||
return button
|
||||
}
|
||||
|
||||
@objc private func exitTapped() {
|
||||
onExitApp()
|
||||
}
|
||||
|
||||
@objc private func applyTapped() {
|
||||
dismiss(animated: true) { [onGoToApply] in onGoToApply() }
|
||||
}
|
||||
}
|
||||
590
suixinkan/UI/Home/Views/PermissionRoleViews.swift
Normal file
590
suixinkan/UI/Home/Views/PermissionRoleViews.swift
Normal file
@ -0,0 +1,590 @@
|
||||
//
|
||||
// PermissionRoleViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import SnapKit
|
||||
import UIKit
|
||||
|
||||
/// “已有角色信息”中的稳定可展开条目。
|
||||
private struct PermissionExistingRoleItem: Hashable {
|
||||
let id: String
|
||||
let roleName: String
|
||||
let scenics: [ScenicInfo]
|
||||
var isExpanded: Bool
|
||||
}
|
||||
|
||||
/// Android 风格“已有角色信息”折叠列表。
|
||||
final class PermissionExistingRoleListView: UIView, UITableViewDelegate {
|
||||
|
||||
private enum Section { case main }
|
||||
|
||||
var onShowAllScenics: ((String, [ScenicInfo]) -> Void)?
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private var dataSource: UITableViewDiffableDataSource<Section, String>!
|
||||
private var items: [PermissionExistingRoleItem] = []
|
||||
private var tableHeightConstraint: Constraint?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = PermissionApplyUITokens.existingBackground
|
||||
layer.cornerRadius = 12
|
||||
titleLabel.text = "已有角色信息"
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
titleLabel.textColor = PermissionApplyUITokens.text
|
||||
|
||||
tableView.backgroundColor = .clear
|
||||
tableView.separatorStyle = .none
|
||||
tableView.isScrollEnabled = false
|
||||
tableView.rowHeight = UITableView.automaticDimension
|
||||
tableView.estimatedRowHeight = 52
|
||||
tableView.delegate = self
|
||||
tableView.register(PermissionExistingRoleCell.self, forCellReuseIdentifier: PermissionExistingRoleCell.reuseIdentifier)
|
||||
|
||||
addSubview(titleLabel)
|
||||
addSubview(tableView)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(12)
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(12)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(12)
|
||||
tableHeightConstraint = make.height.equalTo(1).constraint
|
||||
}
|
||||
|
||||
dataSource = UITableViewDiffableDataSource<Section, String>(tableView: tableView) {
|
||||
[weak self] tableView, indexPath, itemID in
|
||||
guard let self,
|
||||
let item = self.items.first(where: { $0.id == itemID }),
|
||||
let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: PermissionExistingRoleCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as? PermissionExistingRoleCell else { return UITableViewCell() }
|
||||
cell.apply(item: item)
|
||||
cell.onToggle = { [weak self] in self?.toggle(itemID: item.id) }
|
||||
cell.onShowAll = { [weak self] in self?.onShowAllScenics?(item.roleName, item.scenics) }
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 使用本地权限缓存构建已有角色列表。
|
||||
func apply(rolePermissions: [RolePermissionResponse]) {
|
||||
let expandedIDs = Set(items.filter(\.isExpanded).map(\.id))
|
||||
items = rolePermissions.enumerated().map { index, value in
|
||||
let id = "\(index)-\(value.role.id)-\(value.role.name)"
|
||||
return PermissionExistingRoleItem(
|
||||
id: id,
|
||||
roleName: value.role.name,
|
||||
scenics: value.scenic,
|
||||
isExpanded: expandedIDs.contains(id)
|
||||
)
|
||||
}
|
||||
isHidden = items.isEmpty
|
||||
applySnapshot(animatingExpansion: false, reconfigureExisting: true)
|
||||
}
|
||||
|
||||
private func toggle(itemID: String) {
|
||||
guard let index = items.firstIndex(where: { $0.id == itemID }) else { return }
|
||||
items[index].isExpanded.toggle()
|
||||
if let indexPath = dataSource.indexPath(for: itemID),
|
||||
let cell = tableView.cellForRow(at: indexPath) as? PermissionExistingRoleCell {
|
||||
cell.apply(item: items[index], animated: true)
|
||||
}
|
||||
applySnapshot(animatingExpansion: true, reconfigureExisting: false)
|
||||
}
|
||||
|
||||
private func applySnapshot(animatingExpansion: Bool, reconfigureExisting: Bool) {
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, String>()
|
||||
snapshot.appendSections([.main])
|
||||
let itemIDs = items.map(\.id)
|
||||
snapshot.appendItems(itemIDs)
|
||||
if reconfigureExisting {
|
||||
let currentIDs = Set(dataSource.snapshot().itemIdentifiers)
|
||||
snapshot.reconfigureItems(itemIDs.filter(currentIDs.contains))
|
||||
}
|
||||
dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in
|
||||
self?.updateHeight(animated: animatingExpansion)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateHeight(animated: Bool) {
|
||||
let layoutRoot = superview ?? self
|
||||
layoutRoot.layoutIfNeeded()
|
||||
let updates = { [self] in
|
||||
tableView.beginUpdates()
|
||||
tableView.endUpdates()
|
||||
tableView.layoutIfNeeded()
|
||||
tableHeightConstraint?.update(offset: max(1, tableView.contentSize.height))
|
||||
invalidateIntrinsicContentSize()
|
||||
layoutRoot.layoutIfNeeded()
|
||||
}
|
||||
guard animated, !UIAccessibility.isReduceMotionEnabled else {
|
||||
UIView.performWithoutAnimation(updates)
|
||||
return
|
||||
}
|
||||
UIView.animate(
|
||||
withDuration: 0.24,
|
||||
delay: 0,
|
||||
options: [.curveEaseOut, .beginFromCurrentState, .allowUserInteraction],
|
||||
animations: updates
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 已有角色折叠列表单元格。
|
||||
private final class PermissionExistingRoleCell: UITableViewCell {
|
||||
|
||||
static let reuseIdentifier = "PermissionExistingRoleCell"
|
||||
|
||||
var onToggle: (() -> Void)?
|
||||
var onShowAll: (() -> Void)?
|
||||
|
||||
private let cardView = UIView()
|
||||
private let headerControl = UIControl()
|
||||
private let titleLabel = UILabel()
|
||||
private let chevron = UIImageView()
|
||||
private let expandedContainer = UIView()
|
||||
private let flowView = PermissionSimpleFlowView()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
backgroundColor = .clear
|
||||
selectionStyle = .none
|
||||
contentView.backgroundColor = .clear
|
||||
cardView.backgroundColor = .white
|
||||
cardView.layer.cornerRadius = 8
|
||||
cardView.layer.shadowColor = UIColor.black.cgColor
|
||||
cardView.layer.shadowOpacity = 0.08
|
||||
cardView.layer.shadowRadius = 1
|
||||
cardView.layer.shadowOffset = CGSize(width: 0, height: 1)
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: 14, weight: .medium)
|
||||
titleLabel.textColor = PermissionApplyUITokens.text
|
||||
chevron.image = UIImage(systemName: "chevron.down")
|
||||
chevron.tintColor = AppColor.textTertiary
|
||||
chevron.contentMode = .scaleAspectFit
|
||||
headerControl.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside)
|
||||
expandedContainer.backgroundColor = PermissionApplyUITokens.existingBackground
|
||||
flowView.onMore = { [weak self] in self?.onShowAll?() }
|
||||
|
||||
contentView.addSubview(cardView)
|
||||
cardView.addSubview(headerControl)
|
||||
headerControl.addSubview(titleLabel)
|
||||
headerControl.addSubview(chevron)
|
||||
cardView.addSubview(expandedContainer)
|
||||
expandedContainer.addSubview(flowView)
|
||||
|
||||
cardView.snp.makeConstraints { make in
|
||||
make.top.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview()
|
||||
make.bottom.equalToSuperview().offset(-8)
|
||||
}
|
||||
headerControl.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview()
|
||||
make.height.equalTo(44)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview().offset(16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.trailing.lessThanOrEqualTo(chevron.snp.leading).offset(-8)
|
||||
}
|
||||
chevron.snp.makeConstraints { make in
|
||||
make.trailing.equalToSuperview().offset(-16)
|
||||
make.centerY.equalToSuperview()
|
||||
make.size.equalTo(20)
|
||||
}
|
||||
expandedContainer.snp.makeConstraints { make in
|
||||
make.top.equalTo(headerControl.snp.bottom)
|
||||
make.leading.trailing.bottom.equalToSuperview()
|
||||
}
|
||||
flowView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(12) }
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(item: PermissionExistingRoleItem, animated: Bool = false) {
|
||||
titleLabel.text = item.roleName
|
||||
headerControl.accessibilityLabel = item.roleName
|
||||
headerControl.accessibilityValue = item.isExpanded ? "已展开" : "已收起"
|
||||
flowView.apply(titles: item.scenics.map(\.name), showsMore: item.scenics.count > 3)
|
||||
let showsExpandedContent = item.isExpanded && !item.scenics.isEmpty
|
||||
let isExpanding = expandedContainer.isHidden && showsExpandedContent
|
||||
expandedContainer.isHidden = !showsExpandedContent
|
||||
|
||||
let updates = { [self] in
|
||||
chevron.transform = item.isExpanded
|
||||
? CGAffineTransform(rotationAngle: .pi)
|
||||
: .identity
|
||||
expandedContainer.alpha = 1
|
||||
expandedContainer.transform = .identity
|
||||
}
|
||||
guard animated, !UIAccessibility.isReduceMotionEnabled else {
|
||||
UIView.performWithoutAnimation(updates)
|
||||
return
|
||||
}
|
||||
if isExpanding {
|
||||
expandedContainer.alpha = 0
|
||||
expandedContainer.transform = CGAffineTransform(translationX: 0, y: -8)
|
||||
}
|
||||
UIView.animate(
|
||||
withDuration: 0.24,
|
||||
delay: 0,
|
||||
options: [.curveEaseOut, .beginFromCurrentState, .allowUserInteraction],
|
||||
animations: updates
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func toggleTapped() {
|
||||
onToggle?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 小规模绿色景区标签的手动换行容器。
|
||||
private final class PermissionSimpleFlowView: UIView {
|
||||
|
||||
var onMore: (() -> Void)?
|
||||
|
||||
private var itemViews: [UIView] = []
|
||||
private var lastLayoutWidth: CGFloat = 0
|
||||
|
||||
func apply(titles: [String], showsMore: Bool) {
|
||||
itemViews.forEach { $0.removeFromSuperview() }
|
||||
itemViews = []
|
||||
var values = Array(titles.prefix(3))
|
||||
if showsMore { values.append("···") }
|
||||
for value in values {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(value, for: .normal)
|
||||
button.setTitleColor(PermissionApplyUITokens.existingGreen, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 14)
|
||||
button.setConfigurationContentInsets(
|
||||
NSDirectionalEdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)
|
||||
)
|
||||
button.backgroundColor = PermissionApplyUITokens.existingGreenBackground
|
||||
button.layer.cornerRadius = 4
|
||||
button.layer.borderWidth = 1
|
||||
button.layer.borderColor = PermissionApplyUITokens.existingGreen.cgColor
|
||||
button.isUserInteractionEnabled = value == "···"
|
||||
if value == "···" {
|
||||
button.addTarget(self, action: #selector(moreTapped), for: .touchUpInside)
|
||||
button.accessibilityLabel = "查看全部景区"
|
||||
}
|
||||
addSubview(button)
|
||||
itemViews.append(button)
|
||||
}
|
||||
setNeedsLayout()
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard bounds.width > 0 else { return }
|
||||
let widthChanged = abs(bounds.width - lastLayoutWidth) > 0.5
|
||||
lastLayoutWidth = bounds.width
|
||||
var x: CGFloat = 0
|
||||
var y: CGFloat = 0
|
||||
var rowHeight: CGFloat = 0
|
||||
for view in itemViews {
|
||||
let fitted = view.sizeThatFits(CGSize(width: min(180, bounds.width), height: 34))
|
||||
let width = min(bounds.width, max(42, fitted.width))
|
||||
let height = max(34, fitted.height)
|
||||
if x > 0, x + width > bounds.width {
|
||||
x = 0
|
||||
y += rowHeight + 12
|
||||
rowHeight = 0
|
||||
}
|
||||
view.frame = CGRect(x: x, y: y, width: width, height: height)
|
||||
x += width + 12
|
||||
rowHeight = max(rowHeight, height)
|
||||
}
|
||||
if widthChanged { invalidateIntrinsicContentSize() }
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
let width = bounds.width > 0 ? bounds.width : 280
|
||||
var x: CGFloat = 0
|
||||
var totalHeight: CGFloat = 0
|
||||
var rowHeight: CGFloat = 0
|
||||
for view in itemViews {
|
||||
let fitted = view.sizeThatFits(CGSize(width: min(180, width), height: 34))
|
||||
let itemWidth = min(width, max(42, fitted.width))
|
||||
let itemHeight = max(34, fitted.height)
|
||||
if x > 0, x + itemWidth > width {
|
||||
totalHeight += rowHeight + 12
|
||||
x = 0
|
||||
rowHeight = 0
|
||||
}
|
||||
x += itemWidth + 12
|
||||
rowHeight = max(rowHeight, itemHeight)
|
||||
}
|
||||
return CGSize(width: UIView.noIntrinsicMetric, height: itemViews.isEmpty ? 0 : totalHeight + rowHeight)
|
||||
}
|
||||
|
||||
@objc private func moreTapped() {
|
||||
onMore?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 审核页“新增角色/新增景区”白色卡片。
|
||||
final class PermissionStaticSectionView: UIView {
|
||||
|
||||
private let titleLabel = UILabel()
|
||||
private let chips = PermissionChipCollectionView(style: .selectedStatic)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .white
|
||||
layer.cornerRadius = 12
|
||||
titleLabel.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
titleLabel.textColor = PermissionApplyUITokens.text
|
||||
addSubview(titleLabel)
|
||||
addSubview(chips)
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.trailing.equalToSuperview().inset(16)
|
||||
}
|
||||
chips.snp.makeConstraints { make in
|
||||
make.top.equalTo(titleLabel.snp.bottom).offset(12)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(16)
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
/// 更新标题和不可删除的蓝色标签。
|
||||
func apply(title: String, names: [String]) {
|
||||
titleLabel.text = title
|
||||
chips.apply(titles: names)
|
||||
}
|
||||
}
|
||||
|
||||
/// Android 风格角色或景区删除确认弹窗。
|
||||
final class PermissionDeleteDialogViewController: UIViewController {
|
||||
|
||||
private let target: PermissionDeletionTarget
|
||||
private let onCancel: () -> Void
|
||||
private let onConfirm: () -> Void
|
||||
|
||||
/// 创建删除确认弹窗。
|
||||
init(target: PermissionDeletionTarget, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) {
|
||||
self.target = target
|
||||
self.onCancel = onCancel
|
||||
self.onConfirm = onConfirm
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .overFullScreen
|
||||
modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor.black.withAlphaComponent(0.32)
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 16
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = target.dialogTitle
|
||||
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||
titleLabel.textColor = UIColor(hex: 0xFF0000)
|
||||
|
||||
let messageLabel = UILabel()
|
||||
messageLabel.numberOfLines = 0
|
||||
let message = NSMutableAttributedString(
|
||||
string: target.dialogMessage,
|
||||
attributes: [.font: UIFont.systemFont(ofSize: 16), .foregroundColor: UIColor(hex: 0x333333)]
|
||||
)
|
||||
let name: String
|
||||
switch target {
|
||||
case .role(let value), .scenic(_, let value): name = value
|
||||
}
|
||||
let range = (target.dialogMessage as NSString).range(of: name)
|
||||
if range.location != NSNotFound {
|
||||
message.addAttribute(.font, value: UIFont.systemFont(ofSize: 16, weight: .bold), range: range)
|
||||
}
|
||||
messageLabel.attributedText = message
|
||||
|
||||
let cancel = makeButton(title: "取消", background: UIColor(hex: 0xFFE5E5))
|
||||
let confirm = makeButton(title: "确认", background: UIColor(hex: 0xFF0000))
|
||||
cancel.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside)
|
||||
confirm.addTarget(self, action: #selector(confirmTapped), for: .touchUpInside)
|
||||
let buttons = UIStackView(arrangedSubviews: [cancel, confirm])
|
||||
buttons.axis = .horizontal
|
||||
buttons.spacing = 12
|
||||
buttons.distribution = .fillEqually
|
||||
buttons.snp.makeConstraints { make in make.height.equalTo(44) }
|
||||
|
||||
let stack = UIStackView(arrangedSubviews: [titleLabel, messageLabel, buttons])
|
||||
stack.axis = .vertical
|
||||
stack.spacing = 16
|
||||
stack.setCustomSpacing(24, after: messageLabel)
|
||||
view.addSubview(card)
|
||||
card.addSubview(stack)
|
||||
card.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
}
|
||||
stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(24) }
|
||||
}
|
||||
|
||||
private func makeButton(title: String, background: UIColor) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(.white, for: .normal)
|
||||
button.titleLabel?.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
button.backgroundColor = background
|
||||
button.layer.cornerRadius = 8
|
||||
button.accessibilityLabel = title
|
||||
return button
|
||||
}
|
||||
|
||||
@objc private func cancelTapped() {
|
||||
dismiss(animated: true) { [onCancel] in onCancel() }
|
||||
}
|
||||
|
||||
@objc private func confirmTapped() {
|
||||
dismiss(animated: true) { [onConfirm] in onConfirm() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 某个已有角色的全部景区弹窗。
|
||||
final class PermissionScenicListDialogViewController: UIViewController {
|
||||
|
||||
private enum Section { case main }
|
||||
|
||||
private let roleName: String
|
||||
private let scenics: [ScenicInfo]
|
||||
private let onDismiss: () -> Void
|
||||
private let tableView = UITableView(frame: .zero, style: .plain)
|
||||
private var dataSource: UITableViewDiffableDataSource<Section, ScenicInfo>!
|
||||
|
||||
/// 创建固定400pt列表高度的景区详情弹窗。
|
||||
init(roleName: String, scenics: [ScenicInfo], onDismiss: @escaping () -> Void) {
|
||||
self.roleName = roleName
|
||||
self.scenics = scenics
|
||||
self.onDismiss = onDismiss
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
modalPresentationStyle = .overFullScreen
|
||||
modalTransitionStyle = .crossDissolve
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor.black.withAlphaComponent(0.32)
|
||||
let card = UIView()
|
||||
card.backgroundColor = .white
|
||||
card.layer.cornerRadius = 16
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = roleName
|
||||
titleLabel.font = .systemFont(ofSize: 18, weight: .bold)
|
||||
titleLabel.textColor = UIColor(hex: 0x333333)
|
||||
let closeButton = UIButton(type: .system)
|
||||
closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
|
||||
closeButton.tintColor = AppColor.textSecondary
|
||||
closeButton.accessibilityLabel = "关闭"
|
||||
closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside)
|
||||
|
||||
tableView.separatorStyle = .none
|
||||
tableView.backgroundColor = .white
|
||||
tableView.rowHeight = 50
|
||||
tableView.register(PermissionScenicDialogCell.self, forCellReuseIdentifier: PermissionScenicDialogCell.reuseIdentifier)
|
||||
dataSource = UITableViewDiffableDataSource<Section, ScenicInfo>(tableView: tableView) {
|
||||
tableView, indexPath, scenic in
|
||||
guard let cell = tableView.dequeueReusableCell(
|
||||
withIdentifier: PermissionScenicDialogCell.reuseIdentifier,
|
||||
for: indexPath
|
||||
) as? PermissionScenicDialogCell else { return UITableViewCell() }
|
||||
cell.apply(name: scenic.name)
|
||||
return cell
|
||||
}
|
||||
|
||||
view.addSubview(card)
|
||||
[titleLabel, closeButton, tableView].forEach(card.addSubview)
|
||||
card.snp.makeConstraints { make in
|
||||
make.center.equalToSuperview()
|
||||
make.leading.trailing.equalToSuperview().inset(32)
|
||||
}
|
||||
titleLabel.snp.makeConstraints { make in
|
||||
make.top.leading.equalToSuperview().inset(24)
|
||||
make.trailing.lessThanOrEqualTo(closeButton.snp.leading).offset(-8)
|
||||
make.centerY.equalTo(closeButton)
|
||||
}
|
||||
closeButton.snp.makeConstraints { make in
|
||||
make.top.trailing.equalToSuperview().inset(16)
|
||||
make.size.equalTo(44)
|
||||
}
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.top.equalTo(closeButton.snp.bottom).offset(8)
|
||||
make.leading.trailing.bottom.equalToSuperview().inset(24)
|
||||
make.height.equalTo(400)
|
||||
}
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, ScenicInfo>()
|
||||
snapshot.appendSections([.main])
|
||||
snapshot.appendItems(scenics)
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
}
|
||||
|
||||
@objc private func closeTapped() {
|
||||
dismiss(animated: true) { [onDismiss] in onDismiss() }
|
||||
}
|
||||
}
|
||||
|
||||
/// 景区详情弹窗中的绿色标签行。
|
||||
private final class PermissionScenicDialogCell: UITableViewCell {
|
||||
|
||||
static let reuseIdentifier = "PermissionScenicDialogCell"
|
||||
private let chipLabel = UILabel()
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
selectionStyle = .none
|
||||
chipLabel.font = .systemFont(ofSize: 14)
|
||||
chipLabel.textColor = PermissionApplyUITokens.existingGreen
|
||||
chipLabel.backgroundColor = PermissionApplyUITokens.existingGreenBackground
|
||||
chipLabel.layer.cornerRadius = 4
|
||||
chipLabel.layer.borderWidth = 1
|
||||
chipLabel.layer.borderColor = PermissionApplyUITokens.existingGreen.cgColor
|
||||
chipLabel.clipsToBounds = true
|
||||
chipLabel.textAlignment = .center
|
||||
contentView.addSubview(chipLabel)
|
||||
chipLabel.snp.makeConstraints { make in
|
||||
make.leading.equalToSuperview()
|
||||
make.centerY.equalToSuperview()
|
||||
make.height.equalTo(34)
|
||||
make.trailing.lessThanOrEqualToSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func apply(name: String) {
|
||||
chipLabel.text = " \(name) "
|
||||
chipLabel.accessibilityLabel = name
|
||||
}
|
||||
}
|
||||
@ -137,7 +137,9 @@ final class ScenicCurrentLocationBar: UIView {
|
||||
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.setConfigurationContentInsets(
|
||||
NSDirectionalEdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8)
|
||||
)
|
||||
relocateButton.addTarget(self, action: #selector(relocateTapped), for: .touchUpInside)
|
||||
relocateButton.setContentHuggingPriority(.required, for: .horizontal)
|
||||
relocateButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
|
||||
Reference in New Issue
Block a user