// // HomeViewController.swift // suixinkan // import SnapKit import UIKit /// 首页工作台,展示景区头部、工作状态、位置上报卡片和常用应用网格。 final class HomeViewController: UIViewController { private let viewModel = HomeViewModel() private let commonMenuStore = HomeCommonMenuStore() private var commonURIs: [String] = [] private var isOnline = false private var reminderMinutes = 0 private var secondsUntilReport = 0 private var countdownTimer: Timer? private let minimalTopRoleIds: Set = [46, 47, 52, 53, 54] private lazy var scenicButton: UIButton = { var config = UIButton.Configuration.plain() config.baseForegroundColor = UIColor(hex: 0x333333) config.image = UIImage(systemName: "chevron.down")?.withConfiguration( UIImage.SymbolConfiguration(pointSize: AppMetrics.FontSize.subheadline, weight: .bold) ) config.imagePlacement = .trailing config.imagePadding = AppMetrics.Spacing.xSmall config.contentInsets = .zero let button = UIButton(configuration: config) button.contentHorizontalAlignment = .leading button.addTarget(self, action: #selector(scenicTapped), for: .touchUpInside) return button }() private lazy var tableView: UITableView = { let table = UITableView(frame: .zero, style: .grouped) table.backgroundColor = AppDesignUIKit.pageBackground table.separatorStyle = .none table.showsVerticalScrollIndicator = false table.dataSource = self table.delegate = self table.register(HomeMenuGridCell.self, forCellReuseIdentifier: HomeMenuGridCell.reuseID) table.register(UITableViewCell.self, forCellReuseIdentifier: "cell") return table }() override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = AppDesignUIKit.pageBackground setupTopBar() setupTableView() bindViewModel() rebuildMenus() observeContextChanges() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationController?.setNavigationBarHidden(true, animated: animated) startCountdownTimerIfNeeded() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) countdownTimer?.invalidate() countdownTimer = nil } private func setupTopBar() { let topBar = UIView() topBar.backgroundColor = .white view.addSubview(topBar) topBar.addSubview(scenicButton) topBar.snp.makeConstraints { make in make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide) make.height.equalTo(78) } scenicButton.snp.makeConstraints { make in make.leading.trailing.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal) make.centerY.equalToSuperview() } updateScenicTitle() } private func setupTableView() { view.addSubview(tableView) tableView.snp.makeConstraints { make in make.top.equalTo(view.safeAreaLayoutGuide).offset(78) make.leading.trailing.bottom.equalToSuperview() } } private func bindViewModel() { viewModel.onChange = { [weak self] in self?.tableView.reloadData() } } private func observeContextChanges() { let services = appServices services.permissionContext.onChange = { [weak self] in self?.rebuildMenus() } services.accountContext.onChange = { [weak self] in self?.updateScenicTitle() self?.tableView.reloadData() } } private func rebuildMenus() { let services = appServices viewModel.buildMenus( from: services.permissionContext.rolePermissions, currentRoleId: services.permissionContext.currentRole?.id ) commonURIs = commonMenuStore.load(menuItems: viewModel.menuItems) tableView.reloadData() } private func updateScenicTitle() { let name = appServices.accountContext.currentScenic?.name ?? "请选择景区" scenicButton.configuration?.attributedTitle = AttributedString( name, attributes: AttributeContainer([ .font: UIFont.systemFont(ofSize: AppMetrics.FontSize.title3, weight: .semibold) ]) ) } private var currentRoleId: Int? { appServices.permissionContext.currentRole?.id } private var shouldShowWorkStatus: Bool { guard let currentRoleId else { return true } return !minimalTopRoleIds.contains(currentRoleId) } private var isStoreManager: Bool { currentRoleId == 46 } private var displayMenuItems: [HomeMenuItem] { let selected = commonURIs.compactMap { menuItem(for: $0) } let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)] } private func menuItem(for uri: String) -> HomeMenuItem? { let availableURIs = Set(viewModel.menuItems.map(\.uri)) let resolvedUri = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) guard let existing = viewModel.menuItems.first(where: { $0.uri == resolvedUri }) else { return nil } return HomeMenuItem( title: HomeMenuRouter.displayTitle(for: resolvedUri, fallback: existing.title), uri: resolvedUri, iconSrc: existing.iconSrc ) } private var countdownDisplay: String { let hours = secondsUntilReport / 3_600 let minutes = (secondsUntilReport % 3_600) / 60 let seconds = secondsUntilReport % 60 return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" } private var reminderText: String { reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟" } private func startCountdownTimerIfNeeded() { countdownTimer?.invalidate() guard isOnline else { return } countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in guard let self, self.isOnline, self.secondsUntilReport > 0 else { return } self.secondsUntilReport -= 1 self.tableView.reloadSections(IndexSet(integer: 0), with: .none) } } @objc private func scenicTapped() { HomeMenuRouting.push(.scenicSelection, from: self) } @objc private func onlineTapped() { let message = isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。" let alert = UIAlertController(title: "切换在线状态", message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "取消", style: .cancel)) alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in guard let self else { return } self.isOnline.toggle() if self.isOnline { self.secondsUntilReport = 7_200 self.startCountdownTimerIfNeeded() } else { self.countdownTimer?.invalidate() } self.tableView.reloadSections(IndexSet(integer: 0), with: .none) }) present(alert, animated: true) } @objc private func reminderTapped() { let sheet = UIAlertController(title: "提前提醒时间", message: nil, preferredStyle: .actionSheet) for minute in [0, 5, 10, 15, 30] { let title = minute == 0 ? "不提醒" : "\(minute)分钟" sheet.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in self?.reminderMinutes = minute self?.tableView.reloadSections(IndexSet(integer: 0), with: .none) }) } sheet.addAction(UIAlertAction(title: "取消", style: .cancel)) present(sheet, animated: true) } @objc private func locationReportTapped() { HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报"), from: self) } @objc private func paymentTapped() { HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "payment_collection", title: "立即收款"), from: self) } @objc private func taskCreateTapped() { HomeMenuRouting.openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"), from: self) } } // MARK: - UITableView extension HomeViewController: UITableViewDataSource, UITableViewDelegate { func numberOfSections(in tableView: UITableView) -> Int { var count = 1 if shouldShowWorkStatus { count += 2 } if isStoreManager, appServices.accountContext.currentStore != nil { count += 1 } return count } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { 1 } func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { if section == tableView.numberOfSections - 1 { return "常用应用" } return nil } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { if indexPath.section == tableView.numberOfSections - 1 { let cell = tableView.dequeueReusableCell(withIdentifier: HomeMenuGridCell.reuseID, for: indexPath) as! HomeMenuGridCell cell.configure(items: displayMenuItems) { [weak self] item in self.flatMap { HomeMenuRouting.openMenu(item, from: $0) } } return cell } let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) cell.selectionStyle = .none cell.contentView.subviews.forEach { $0.removeFromSuperview() } if shouldShowWorkStatus { if indexPath.section == 0 { cell.contentView.addSubview(makeStatusCard()) } else if indexPath.section == 1 { cell.contentView.addSubview(makeLocationCard()) } else if indexPath.section == 2, isStoreManager, let store = appServices.accountContext.currentStore { cell.contentView.addSubview(makeStoreCard(store)) } else { cell.contentView.addSubview(makeQuickActionsRow()) } } else if isStoreManager, let store = appServices.accountContext.currentStore, indexPath.section == 0 { cell.contentView.addSubview(makeStoreCard(store)) } cell.contentView.subviews.first?.snp.makeConstraints { make in make.edges.equalToSuperview().inset(UIEdgeInsets( top: AppMetrics.Spacing.xxSmall, left: AppMetrics.Spacing.pageHorizontal, bottom: AppMetrics.Spacing.xxSmall, right: AppMetrics.Spacing.pageHorizontal )) } cell.backgroundColor = .clear return cell } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { if indexPath.section == tableView.numberOfSections - 1 { return 240 } if shouldShowWorkStatus { switch indexPath.section { case 0: return 100 case 1: return 148 case 2 where isStoreManager && appServices.accountContext.currentStore != nil: return 88 default: return 118 } } if isStoreManager, appServices.accountContext.currentStore != nil, indexPath.section == 0 { return 88 } return UITableView.automaticDimension } private func makeStatusCard() -> UIView { let card = makeCardView() let onlineButton = UIButton(type: .system) onlineButton.setTitle(isOnline ? "在线" : "离线", for: .normal) onlineButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.caption, weight: .medium) onlineButton.setTitleColor(isOnline ? UIColor(hex: 0xF0FDF4) : UIColor(hex: 0x7B8EAA), for: .normal) onlineButton.backgroundColor = isOnline ? UIColor(hex: 0x22C55E) : UIColor(hex: 0xF4F4F4) onlineButton.layer.cornerRadius = 4 onlineButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 12, bottom: 6, right: 12) onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside) let clockLabel = UILabel() clockLabel.text = " \(countdownDisplay)" clockLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium) clockLabel.textColor = AppDesignUIKit.primary let reminderButton = UIButton(type: .system) reminderButton.setTitle(" \(reminderText)", for: .normal) reminderButton.titleLabel?.font = .systemFont(ofSize: AppMetrics.FontSize.body, weight: .medium) reminderButton.setTitleColor(AppDesignUIKit.primary, for: .normal) reminderButton.addTarget(self, action: #selector(reminderTapped), for: .touchUpInside) let stack = UIStackView(arrangedSubviews: [onlineButton, clockLabel, reminderButton]) stack.axis = .horizontal stack.distribution = .equalSpacing stack.alignment = .center card.addSubview(stack) stack.snp.makeConstraints { make in make.edges.equalToSuperview().inset(15) } return card } private func makeLocationCard() -> UIView { let card = makeCardView() let titleLabel = UILabel() titleLabel.text = "立即上报" titleLabel.font = .systemFont(ofSize: 22, weight: .bold) titleLabel.textColor = UIColor(hex: 0x333333) let subtitleLabel = UILabel() subtitleLabel.text = "您已进入打卡范围" subtitleLabel.font = .systemFont(ofSize: 15) subtitleLabel.textColor = UIColor(hex: 0x999999) let textStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) textStack.axis = .vertical textStack.spacing = AppMetrics.Spacing.xxSmall let actionButton = UIButton(type: .system) actionButton.setImage(UIImage(systemName: "hand.tap.fill"), for: .normal) actionButton.tintColor = .white actionButton.backgroundColor = AppDesignUIKit.primary actionButton.layer.cornerRadius = 46 actionButton.addTarget(self, action: #selector(locationReportTapped), for: .touchUpInside) card.addSubview(textStack) card.addSubview(actionButton) textStack.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(15) make.trailing.lessThanOrEqualTo(actionButton.snp.leading).offset(-12) } actionButton.snp.makeConstraints { make in make.trailing.equalToSuperview().inset(15) make.centerY.equalToSuperview() make.width.height.equalTo(92) } return card } private func makeQuickActionsRow() -> UIView { let stack = UIStackView() stack.axis = .horizontal stack.spacing = AppMetrics.Spacing.small stack.distribution = .fillEqually stack.addArrangedSubview(quickActionButton(icon: "qrcode", title: "立即收款", action: #selector(paymentTapped))) stack.addArrangedSubview(quickActionButton(icon: "checklist.checked", title: "提交任务", action: #selector(taskCreateTapped))) stack.addArrangedSubview(quickActionButton( icon: isOnline ? "wifi" : "wifi.slash", title: isOnline ? "在线" : "离线", action: #selector(onlineTapped), active: isOnline )) return stack } private func quickActionButton(icon: String, title: String, action: Selector, active: Bool = false) -> UIView { let card = makeCardView() card.backgroundColor = active ? UIColor(hex: 0xE3F2FD) : .white let iconView = UIImageView(image: UIImage(systemName: icon)) iconView.tintColor = AppDesignUIKit.primary iconView.contentMode = .scaleAspectFit let label = UILabel() label.text = title label.font = .systemFont(ofSize: AppMetrics.FontSize.body) label.textAlignment = .center let stack = UIStackView(arrangedSubviews: [iconView, label]) stack.axis = .vertical stack.spacing = AppMetrics.Spacing.xSmall stack.alignment = .center card.addSubview(stack) stack.snp.makeConstraints { make in make.center.equalToSuperview() } iconView.snp.makeConstraints { make in make.height.equalTo(34) } let button = UIButton(type: .custom) button.addTarget(self, action: action, for: .touchUpInside) card.addSubview(button) button.snp.makeConstraints { make in make.edges.equalToSuperview() } card.snp.makeConstraints { make in make.height.equalTo(102) } return card } private func makeStoreCard(_ store: BusinessScope) -> UIView { let card = makeCardView() let nameLabel = UILabel() nameLabel.text = store.name nameLabel.font = .systemFont(ofSize: AppMetrics.FontSize.title2, weight: .bold) let scenicLabel = UILabel() scenicLabel.text = appServices.accountContext.currentScenic?.name scenicLabel.font = .systemFont(ofSize: AppMetrics.FontSize.subheadline) scenicLabel.textColor = UIColor(hex: 0x7B8EAA) scenicLabel.numberOfLines = 2 let statusLabel = UILabel() statusLabel.text = "营业中" statusLabel.font = .systemFont(ofSize: AppMetrics.FontSize.caption) statusLabel.textColor = UIColor(hex: 0x22C55E) statusLabel.backgroundColor = UIColor(hex: 0xF0FDF4) statusLabel.layer.cornerRadius = 4 statusLabel.clipsToBounds = true statusLabel.textAlignment = .center let textStack = UIStackView(arrangedSubviews: [nameLabel, scenicLabel]) textStack.axis = .vertical textStack.spacing = AppMetrics.Spacing.xSmall card.addSubview(textStack) card.addSubview(statusLabel) textStack.snp.makeConstraints { make in make.leading.top.bottom.equalToSuperview().inset(AppMetrics.Spacing.medium) make.trailing.lessThanOrEqualTo(statusLabel.snp.leading).offset(-8) } statusLabel.snp.makeConstraints { make in make.trailing.top.equalToSuperview().inset(AppMetrics.Spacing.medium) make.width.greaterThanOrEqualTo(52) make.height.equalTo(22) } return card } } // MARK: - Menu Grid Cell private final class HomeMenuGridCell: UITableViewCell { static let reuseID = "HomeMenuGridCell" private var onSelect: ((HomeMenuItem) -> Void)? private var items: [HomeMenuItem] = [] private lazy var collectionView: UICollectionView = { let layout = UICollectionViewFlowLayout() layout.minimumInteritemSpacing = 15 layout.minimumLineSpacing = 15 let view = UICollectionView(frame: .zero, collectionViewLayout: layout) view.backgroundColor = .clear view.isScrollEnabled = false view.dataSource = self view.delegate = self view.register(HomeMenuItemCell.self, forCellWithReuseIdentifier: HomeMenuItemCell.reuseID) return view }() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) backgroundColor = .clear selectionStyle = .none contentView.addSubview(collectionView) collectionView.snp.makeConstraints { make in make.edges.equalToSuperview().inset(AppMetrics.Spacing.pageHorizontal) make.height.equalTo(220) } } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(items: [HomeMenuItem], onSelect: @escaping (HomeMenuItem) -> Void) { self.items = items self.onSelect = onSelect collectionView.reloadData() } } extension HomeMenuGridCell: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { items.count } func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HomeMenuItemCell.reuseID, for: indexPath) as! HomeMenuItemCell cell.configure(item: items[indexPath.item]) return cell } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { onSelect?(items[indexPath.item]) } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { let width = (collectionView.bounds.width - 30) / 3 return CGSize(width: width, height: 102) } } private final class HomeMenuItemCell: UICollectionViewCell { static let reuseID = "HomeMenuItemCell" private let iconView = UIImageView() private let titleLabel = UILabel() override init(frame: CGRect) { super.init(frame: frame) contentView.backgroundColor = .white contentView.layer.cornerRadius = 8 titleLabel.font = .systemFont(ofSize: AppMetrics.FontSize.body) titleLabel.textColor = UIColor(hex: 0x4B5563) titleLabel.textAlignment = .center titleLabel.numberOfLines = 1 titleLabel.adjustsFontSizeToFitWidth = true let stack = UIStackView(arrangedSubviews: [iconView, titleLabel]) stack.axis = .vertical stack.spacing = AppMetrics.Spacing.small stack.alignment = .center contentView.addSubview(stack) stack.snp.makeConstraints { make in make.center.equalToSuperview() make.leading.trailing.equalToSuperview().inset(4) } iconView.snp.makeConstraints { make in make.width.height.equalTo(30) } iconView.contentMode = .scaleAspectFit } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func configure(item: HomeMenuItem) { titleLabel.text = item.title let symbol = UIImage(systemName: HomeIconCatalog.iconName(for: item.uri)) iconView.loadRemoteImage(urlString: item.iconSrc, contentMode: .scaleAspectFit, placeholder: symbol) iconView.tintColor = AppDesign.primary } }