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

对齐 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

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