Files
suixinkan_uikit/suixinkan/UI/Home/Views/PermissionRoleViews.swift
汉秋 efb3925257 完善首页权限申请流程与队列播报体验。
对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:46:45 +08:00

591 lines
23 KiB
Swift

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