// // 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! 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(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() 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! /// 创建固定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(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() 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 } }