完善首页权限申请流程与队列播报体验。

对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-14 13:46:45 +08:00
parent c083f1d4b3
commit efb3925257
50 changed files with 2585 additions and 455 deletions

View File

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

View File

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

View File

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

View File

@ -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("提交成功")
}
}
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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