From af450fb4cc71e3e0662f7b6a0adad67447b2f26f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 7 Jul 2026 09:57:41 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=A6=96=E9=A1=B5=E4=B8=BA?= =?UTF-8?q?=E5=8D=95=E4=B8=80=E9=9B=86=E5=90=88=E8=A7=86=E5=9B=BE=E5=B9=B6?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=85=A8=E9=83=A8=E5=8A=9F=E8=83=BD=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Home/Services/HomeCommonMenuStore.swift | 12 +- .../Home/Services/HomeRouteHandler.swift | 9 +- .../ViewModels/AllFunctionsViewModel.swift | 82 ++++++ .../Home/ViewModels/HomeViewModel.swift | 13 +- .../UI/Home/AllFunctionsViewController.swift | 228 ++++++++++++++ suixinkan/UI/Home/HomeCollectionModels.swift | 165 +++++++++++ suixinkan/UI/Home/HomeViewController.swift | 278 +++++++++++++----- .../UI/Home/Views/AllFunctionMenuCell.swift | 104 +++++++ .../UI/Home/Views/HomeCollectionCells.swift | 193 ++++++++++++ .../Home/Views/HomeCommonAppsGridView.swift | 132 --------- .../AllFunctionsViewModelTests.swift | 123 ++++++++ .../HomeCollectionModelsTests.swift | 53 ++++ suixinkanTests/HomeCommonMenuStoreTests.swift | 11 + suixinkanTests/HomeViewModelTests.swift | 28 +- 14 files changed, 1222 insertions(+), 209 deletions(-) create mode 100644 suixinkan/Features/Home/ViewModels/AllFunctionsViewModel.swift create mode 100644 suixinkan/UI/Home/AllFunctionsViewController.swift create mode 100644 suixinkan/UI/Home/HomeCollectionModels.swift create mode 100644 suixinkan/UI/Home/Views/AllFunctionMenuCell.swift create mode 100644 suixinkan/UI/Home/Views/HomeCollectionCells.swift delete mode 100644 suixinkan/UI/Home/Views/HomeCommonAppsGridView.swift create mode 100644 suixinkanTests/AllFunctionsViewModelTests.swift create mode 100644 suixinkanTests/HomeCollectionModelsTests.swift diff --git a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift index cfb739b..67f36b4 100644 --- a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift +++ b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift @@ -40,8 +40,18 @@ final class HomeCommonMenuStore { let saved = savedCommonURIs(accountScope: accountScope, roleCode: roleCode) let defaultURIs = Self.defaultCommonURIs(from: permissions) let commonURIs = saved.isEmpty ? defaultURIs : saved + return Self.splitMenus(from: allMenus, commonURIs: commonURIs).common + } + + /// 按常用 URI 将菜单拆分为常用与更多两组。 + static func splitMenus( + from allMenus: [HomeMenuItem], + commonURIs: [String] + ) -> (common: [HomeMenuItem], more: [HomeMenuItem]) { let commonSet = Set(commonURIs) - return allMenus.filter { commonSet.contains($0.uri) } + let common = allMenus.filter { commonSet.contains($0.uri) } + let more = allMenus.filter { !commonSet.contains($0.uri) } + return (common, more) } private func storageKey(accountScope: String, roleCode: String) -> String { diff --git a/suixinkan/Features/Home/Services/HomeRouteHandler.swift b/suixinkan/Features/Home/Services/HomeRouteHandler.swift index bad768d..226e30b 100644 --- a/suixinkan/Features/Home/Services/HomeRouteHandler.swift +++ b/suixinkan/Features/Home/Services/HomeRouteHandler.swift @@ -13,9 +13,10 @@ enum HomeRouteHandler { static func open( uri: String, from viewController: UIViewController, - storeItem: StoreItem? + storeItem: StoreItem?, + onCommonMenusChanged: (() -> Void)? = nil ) { - if uri == "pilot_controller" || uri == "more_functions" { + if uri == "pilot_controller" { showDeveloping(from: viewController) return } @@ -26,6 +27,10 @@ enum HomeRouteHandler { } switch uri { + case "more_functions": + let controller = AllFunctionsViewController() + controller.onDidCustomize = onCommonMenusChanged + viewController.navigationController?.pushViewController(controller, animated: true) case "verification_order": selectTab(.orders, from: viewController) case "photographer_stats": diff --git a/suixinkan/Features/Home/ViewModels/AllFunctionsViewModel.swift b/suixinkan/Features/Home/ViewModels/AllFunctionsViewModel.swift new file mode 100644 index 0000000..a8ea870 --- /dev/null +++ b/suixinkan/Features/Home/ViewModels/AllFunctionsViewModel.swift @@ -0,0 +1,82 @@ +// +// AllFunctionsViewModel.swift +// suixinkan +// + +import Foundation + +/// 全部功能页 ViewModel,管理常用应用与更多功能的增删与持久化。 +final class AllFunctionsViewModel { + + private(set) var commonMenus: [HomeMenuItem] = [] + private(set) var moreMenus: [HomeMenuItem] = [] + + var onStateChange: (() -> Void)? + private(set) var didCustomize = false + + private let appStore: AppStore + private let commonMenuStore: HomeCommonMenuStore + + init( + appStore: AppStore = .shared, + commonMenuStore: HomeCommonMenuStore = HomeCommonMenuStore() + ) { + self.appStore = appStore + self.commonMenuStore = commonMenuStore + } + + /// 加载全部功能并按常用配置拆分。 + func loadFunctions() { + let permissions = appStore.permissionItems() + let allMenus = HomeMenuCatalog.visibleMenus(from: permissions) + let accountScope = appStore.accountCachePrefix + let roleCode = appStore.roleCode + let savedURIs = commonMenuStore.savedCommonURIs(accountScope: accountScope, roleCode: roleCode) + let defaultURIs = HomeCommonMenuStore.defaultCommonURIs(from: permissions) + let commonURIs = savedURIs.isEmpty ? defaultURIs : savedURIs + + let split = HomeCommonMenuStore.splitMenus(from: allMenus, commonURIs: commonURIs) + commonMenus = split.common + moreMenus = split.more + + if savedURIs.isEmpty, !split.common.isEmpty { + commonMenuStore.saveCommonURIs(split.common.map(\.uri), accountScope: accountScope, roleCode: roleCode) + } + + notifyStateChange() + } + + /// 将菜单加入常用应用。 + func addToCommon(_ menu: HomeMenuItem) { + guard !commonMenus.contains(where: { $0.uri == menu.uri }) else { return } + commonMenus.append(menu) + moreMenus.removeAll { $0.uri == menu.uri } + persistCommonMenus() + didCustomize = true + notifyStateChange() + } + + /// 将菜单移出常用应用。 + func removeFromCommon(_ menu: HomeMenuItem) { + guard commonMenus.contains(where: { $0.uri == menu.uri }) else { return } + commonMenus.removeAll { $0.uri == menu.uri } + if !moreMenus.contains(where: { $0.uri == menu.uri }) { + moreMenus.append(menu) + } + persistCommonMenus() + didCustomize = true + notifyStateChange() + } + + private func persistCommonMenus() { + commonMenuStore.saveCommonURIs( + commonMenus.map(\.uri), + accountScope: appStore.accountCachePrefix, + roleCode: appStore.roleCode + ) + } + + private func notifyStateChange() { + onStateChange?() + } +} diff --git a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift index f9a6cfd..4f2d066 100644 --- a/suixinkan/Features/Home/ViewModels/HomeViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/HomeViewModel.swift @@ -223,11 +223,20 @@ final class HomeViewModel { func rebuildCommonMenus() { let permissions = appStore.permissionItems() let allMenus = HomeMenuCatalog.visibleMenus(from: permissions) + let accountScope = appStore.accountCachePrefix + let roleCode = appStore.roleCode + let savedURIs = commonMenuStore.savedCommonURIs(accountScope: accountScope, roleCode: roleCode) + + if savedURIs.isEmpty, !permissions.isEmpty { + let defaultURIs = HomeCommonMenuStore.defaultCommonURIs(from: permissions) + commonMenuStore.saveCommonURIs(defaultURIs, accountScope: accountScope, roleCode: roleCode) + } + commonMenus = commonMenuStore.commonMenus( from: allMenus, permissions: permissions, - accountScope: appStore.accountCachePrefix, - roleCode: appStore.roleCode + accountScope: accountScope, + roleCode: roleCode ) } diff --git a/suixinkan/UI/Home/AllFunctionsViewController.swift b/suixinkan/UI/Home/AllFunctionsViewController.swift new file mode 100644 index 0000000..602a730 --- /dev/null +++ b/suixinkan/UI/Home/AllFunctionsViewController.swift @@ -0,0 +1,228 @@ +// +// AllFunctionsViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 全部功能页,管理常用应用与更多功能的增删。 +final class AllFunctionsViewController: BaseViewController { + + var onDidCustomize: (() -> Void)? + + private enum AllFunctionSection: Int, CaseIterable, Hashable { + case common + case more + + var title: String { + switch self { + case .common: return "常用应用" + case .more: return "更多功能" + } + } + + var actionStyle: AllFunctionMenuCell.ActionStyle { + switch self { + case .common: return .remove + case .more: return .add + } + } + } + + private let viewModel = AllFunctionsViewModel() + private let collectionView: UICollectionView + + private var dataSource: UICollectionViewDiffableDataSource! + private var hasAppliedInitialSnapshot = false + + init() { + collectionView = UICollectionView(frame: .zero, collectionViewLayout: Self.makeLayout()) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setupNavigationBar() { + title = "全部功能" + } + + override func setupUI() { + view.backgroundColor = AppColor.pageBackground + + collectionView.backgroundColor = .clear + collectionView.alwaysBounceVertical = true + collectionView.allowsSelection = false + collectionView.contentInset = UIEdgeInsets( + top: AppSpacing.xs, + left: 0, + bottom: AppSpacing.md, + right: 0 + ) + collectionView.register( + AllFunctionMenuCell.self, + forCellWithReuseIdentifier: AllFunctionMenuCell.reuseIdentifier + ) + collectionView.register( + AllFunctionSectionHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: AllFunctionSectionHeaderView.reuseIdentifier + ) + + dataSource = makeDataSource() + view.addSubview(collectionView) + } + + override func setupConstraints() { + collectionView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + } + + override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in + self?.applyViewModel(animated: self?.hasAppliedInitialSnapshot == true) + } + } + } + + override func viewDidLoad() { + super.viewDidLoad() + viewModel.loadFunctions() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + if isMovingFromParent, viewModel.didCustomize { + onDidCustomize?() + } + } + + @MainActor + private func applyViewModel(animated: Bool) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.common, .more]) + snapshot.appendItems(viewModel.commonMenus, toSection: .common) + snapshot.appendItems(viewModel.moreMenus, toSection: .more) + dataSource.apply(snapshot, animatingDifferences: animated) + hasAppliedInitialSnapshot = true + } + + private func makeDataSource() -> UICollectionViewDiffableDataSource { + let dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, menu in + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: AllFunctionMenuCell.reuseIdentifier, + for: indexPath + ) as! AllFunctionMenuCell + guard let section = AllFunctionSection(rawValue: indexPath.section) else { return cell } + cell.apply(menu: menu, actionStyle: section.actionStyle) + cell.onActionTap = { + switch section { + case .common: + self?.viewModel.removeFromCommon(menu) + case .more: + self?.viewModel.addToCommon(menu) + } + } + return cell + } + + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + guard kind == UICollectionView.elementKindSectionHeader, + let section = AllFunctionSection(rawValue: indexPath.section) else { + return nil + } + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: AllFunctionSectionHeaderView.reuseIdentifier, + for: indexPath + ) as! AllFunctionSectionHeaderView + header.apply(title: section.title) + return header + } + + return dataSource + } + + private static func makeLayout() -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { sectionIndex, environment in + let spacing: CGFloat = 12 + let columns = 3 + let horizontalInset = AppSpacing.md + let availableWidth = environment.container.effectiveContentSize.width - horizontalInset * 2 + let totalSpacing = spacing * CGFloat(columns - 1) + let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(columns)) + + let itemSize = NSCollectionLayoutSize( + widthDimension: .absolute(itemWidth), + heightDimension: .absolute(itemWidth) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(itemWidth) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + repeatingSubitem: item, + count: columns + ) + group.interItemSpacing = .fixed(spacing) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = spacing + section.contentInsets = NSDirectionalEdgeInsets( + top: 0, + leading: horizontalInset, + bottom: sectionIndex == AllFunctionSection.common.rawValue ? AppSpacing.lg : AppSpacing.md, + trailing: horizontalInset + ) + + let headerSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(36) + ) + let header = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: headerSize, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ) + section.boundarySupplementaryItems = [header] + return section + } + } +} + +/// 全部功能页分区标题。 +private final class AllFunctionSectionHeaderView: UICollectionReusableView { + + static let reuseIdentifier = "AllFunctionSectionHeaderView" + + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + titleLabel.font = .app(.title) + titleLabel.textColor = AppColor.textPrimary + addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.leading.trailing.equalToSuperview().inset(AppSpacing.md) + make.bottom.equalToSuperview().inset(AppSpacing.xs) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(title: String) { + titleLabel.text = title + } +} diff --git a/suixinkan/UI/Home/HomeCollectionModels.swift b/suixinkan/UI/Home/HomeCollectionModels.swift new file mode 100644 index 0000000..a4ca1a1 --- /dev/null +++ b/suixinkan/UI/Home/HomeCollectionModels.swift @@ -0,0 +1,165 @@ +// +// HomeCollectionModels.swift +// suixinkan +// + +import Foundation +import UIKit + +/// 首页 CollectionView 分区标识。 +enum HomeCollectionSection: Hashable { + case workStatus + case locationReport + case quickActions + case store + case commonApps +} + +/// 首页 CollectionView 条目标识;各区块使用独立 case,避免 Diffable 去重。 +enum HomeCollectionItem: Hashable { + case workStatus + case locationReport + case quickActions + case store + case menu(HomeMenuItem) +} + +/// 首页 CollectionView 布局与 snapshot 构建工具。 +enum HomeCollectionLayoutBuilder { + + private static let menuColumns = 3 + private static let menuSpacing: CGFloat = 15 + private static let menuAspectRatio: CGFloat = 1.3 + private static let sectionSpacing = AppSpacing.sm + + /// 构建 Compositional Layout,按当前分区类型返回 section 布局。 + static func makeLayout(sections: [HomeCollectionSection]) -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { sectionIndex, environment in + guard sectionIndex < sections.count else { return nil } + switch sections[sectionIndex] { + case .workStatus: + return fullWidthSection(height: AppSpacing.formRowHeight) + case .locationReport: + return fullWidthSection(height: 100) + case .quickActions: + return fullWidthSection(height: 72) + case .store: + return fullWidthSection(height: 96) + case .commonApps: + return menuGridSection(environment: environment) + } + } + } + + /// 根据 ViewModel 状态构建 Diffable Snapshot。 + static func makeSnapshot( + showWorkBlock: Bool, + showStore: Bool, + storeItem: StoreItem?, + commonMenus: [HomeMenuItem] + ) -> NSDiffableDataSourceSnapshot { + var snapshot = NSDiffableDataSourceSnapshot() + + if showWorkBlock { + snapshot.appendSections([.workStatus, .locationReport, .quickActions]) + snapshot.appendItems([.workStatus], toSection: .workStatus) + snapshot.appendItems([.locationReport], toSection: .locationReport) + snapshot.appendItems([.quickActions], toSection: .quickActions) + } + + if showStore, storeItem != nil { + snapshot.appendSections([.store]) + snapshot.appendItems([.store], toSection: .store) + } + + let menus = commonMenus + [HomeMenuItem.moreFunctions] + snapshot.appendSections([.commonApps]) + snapshot.appendItems(menus.map { HomeCollectionItem.menu($0) }, toSection: .commonApps) + + return snapshot + } + + /// 当前 snapshot 对应的分区顺序,供 layout 使用。 + static func sectionOrder( + showWorkBlock: Bool, + showStore: Bool, + storeItem: StoreItem? + ) -> [HomeCollectionSection] { + var sections: [HomeCollectionSection] = [] + if showWorkBlock { + sections.append(contentsOf: [.workStatus, .locationReport, .quickActions]) + } + if showStore, storeItem != nil { + sections.append(.store) + } + sections.append(.commonApps) + return sections + } + + private static let horizontalInset = AppSpacing.screenHorizontalInset + + private static func fullWidthSection(height: CGFloat) -> NSCollectionLayoutSection { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(height) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(height) + ) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = NSDirectionalEdgeInsets( + top: 0, + leading: horizontalInset, + bottom: sectionSpacing, + trailing: horizontalInset + ) + return section + } + + private static func menuGridSection(environment: NSCollectionLayoutEnvironment) -> NSCollectionLayoutSection { + let availableWidth = environment.container.effectiveContentSize.width - horizontalInset * 2 + let totalSpacing = menuSpacing * CGFloat(menuColumns - 1) + let itemWidth = max(0, (availableWidth - totalSpacing) / CGFloat(menuColumns)) + let itemHeight = itemWidth / menuAspectRatio + + let itemSize = NSCollectionLayoutSize( + widthDimension: .absolute(itemWidth), + heightDimension: .absolute(itemHeight) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(itemHeight) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + repeatingSubitem: item, + count: menuColumns + ) + group.interItemSpacing = .fixed(menuSpacing) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = menuSpacing + section.contentInsets = NSDirectionalEdgeInsets( + top: 0, + leading: horizontalInset, + bottom: sectionSpacing, + trailing: horizontalInset + ) + + let headerSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(36) + ) + let header = NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: headerSize, + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .top + ) + section.boundarySupplementaryItems = [header] + return section + } +} diff --git a/suixinkan/UI/Home/HomeViewController.swift b/suixinkan/UI/Home/HomeViewController.swift index 1f89e49..1723149 100644 --- a/suixinkan/UI/Home/HomeViewController.swift +++ b/suixinkan/UI/Home/HomeViewController.swift @@ -13,13 +13,9 @@ final class HomeViewController: BaseViewController { private let homeAPI = NetworkServices.shared.homeAPI private let scenicHeaderView = HomeScenicHeaderView() - private let scrollView = UIScrollView() - private let contentStack = UIStackView() - private let workStatusCardView = HomeWorkStatusCardView() - private let locationReportCardView = HomeLocationReportCardView() - private let quickActionsView = HomeQuickActionsView() - private let storeCardView = HomeStoreCardView() - private let commonAppsGridView = HomeCommonAppsGridView() + private var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + private var currentSections: [HomeCollectionSection] = [] private var hasInitialized = false private var countdownTimer: Timer? @@ -32,19 +28,28 @@ final class HomeViewController: BaseViewController { override func setupUI() { view.backgroundColor = AppColor.pageBackground - contentStack.axis = .vertical - contentStack.spacing = AppSpacing.sm + collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: HomeCollectionLayoutBuilder.makeLayout(sections: [.commonApps]) + ) + collectionView.backgroundColor = .clear + collectionView.alwaysBounceVertical = true + collectionView.alwaysBounceHorizontal = false + collectionView.showsHorizontalScrollIndicator = false + collectionView.isDirectionalLockEnabled = true + // 水平边距由 section.contentInsets 控制,避免 contentInset 与布局宽度不一致导致横向滑动。 + collectionView.contentInset = UIEdgeInsets( + top: AppSpacing.sm, + left: 0, + bottom: AppSpacing.md, + right: 0 + ) + registerCollectionCells() - scrollView.showsVerticalScrollIndicator = false view.addSubview(scenicHeaderView) - view.addSubview(scrollView) - scrollView.addSubview(contentStack) + view.addSubview(collectionView) - contentStack.addArrangedSubview(workStatusCardView) - contentStack.addArrangedSubview(locationReportCardView) - contentStack.addArrangedSubview(quickActionsView) - contentStack.addArrangedSubview(storeCardView) - contentStack.addArrangedSubview(commonAppsGridView) + dataSource = makeDataSource() } override func setupConstraints() { @@ -53,14 +58,10 @@ final class HomeViewController: BaseViewController { make.leading.trailing.equalToSuperview() make.height.equalTo(AppSpacing.homeHeaderHeight) } - scrollView.snp.makeConstraints { make in + collectionView.snp.makeConstraints { make in make.top.equalTo(scenicHeaderView.snp.bottom) make.leading.trailing.bottom.equalToSuperview() } - contentStack.snp.makeConstraints { make in - make.edges.equalToSuperview().inset(AppSpacing.screenHorizontalInset) - make.width.equalTo(scrollView.snp.width).offset(-AppSpacing.screenHorizontalInset * 2) - } } override func bindActions() { @@ -72,27 +73,7 @@ final class HomeViewController: BaseViewController { } scenicHeaderView.onTap = { [weak self] in self?.presentScenicSelection() } - workStatusCardView.onOnlineTap = { [weak self] in self?.viewModel.showOnlineStatusSwitchDialog() } - workStatusCardView.onReminderTap = { [weak self] in self?.presentReminderPicker() } - locationReportCardView.onReportTap = { [weak self] in - Task { await self?.viewModel.manualReportLocation(api: self?.homeAPI ?? NetworkServices.shared.homeAPI) } - } - quickActionsView.onCollectPayment = { [weak self] in - guard AppStore.shared.currentScenicId > 0 else { - self?.showToast("请先选择景区") - return - } - self?.navigationController?.pushViewController( - PaymentCollectionDetailsViewController(), - animated: true - ) - } - quickActionsView.onSubmitTask = { [weak self] in self?.showToast("功能开发中") } - quickActionsView.onToggleOnline = { [weak self] in self?.viewModel.showOnlineStatusSwitchDialog() } - commonAppsGridView.onMenuSelected = { [weak self] menu in - guard let self else { return } - HomeRouteHandler.open(uri: menu.uri, from: self, storeItem: self.viewModel.storeItem) - } + collectionView.delegate = self NotificationCenter.default.addObserver( self, @@ -129,6 +110,130 @@ final class HomeViewController: BaseViewController { NotificationCenter.default.removeObserver(self) } + private func registerCollectionCells() { + collectionView.register(HomeWorkStatusCell.self, forCellWithReuseIdentifier: HomeWorkStatusCell.reuseIdentifier) + collectionView.register( + HomeLocationReportCell.self, + forCellWithReuseIdentifier: HomeLocationReportCell.reuseIdentifier + ) + collectionView.register( + HomeQuickActionsCell.self, + forCellWithReuseIdentifier: HomeQuickActionsCell.reuseIdentifier + ) + collectionView.register(HomeStoreCell.self, forCellWithReuseIdentifier: HomeStoreCell.reuseIdentifier) + collectionView.register(HomeMenuCell.self, forCellWithReuseIdentifier: HomeMenuCell.reuseIdentifier) + collectionView.register( + HomeSectionHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: HomeSectionHeaderView.reuseIdentifier + ) + } + + private func makeDataSource() -> UICollectionViewDiffableDataSource { + let dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, item in + guard let self else { return UICollectionViewCell() } + let section = self.currentSections[indexPath.section] + switch (section, item) { + case (.workStatus, .workStatus): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeWorkStatusCell.reuseIdentifier, + for: indexPath + ) as! HomeWorkStatusCell + cell.cardView.onOnlineTap = { [weak self] in + self?.viewModel.showOnlineStatusSwitchDialog() + } + cell.cardView.onReminderTap = { [weak self] in + self?.presentReminderPicker() + } + cell.apply( + isOnline: self.viewModel.isOnline, + countdownText: self.viewModel.countdownDisplayText, + reminderMinutes: self.viewModel.reminderMinutes + ) + return cell + + case (.locationReport, .locationReport): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeLocationReportCell.reuseIdentifier, + for: indexPath + ) as! HomeLocationReportCell + cell.cardView.onReportTap = { [weak self] in + guard let self else { return } + Task { await self.viewModel.manualReportLocation(api: self.homeAPI) } + } + cell.apply( + address: self.viewModel.locationInfoText, + isReporting: self.viewModel.isLocationReporting + ) + return cell + + case (.quickActions, .quickActions): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeQuickActionsCell.reuseIdentifier, + for: indexPath + ) as! HomeQuickActionsCell + cell.cardView.onCollectPayment = { [weak self] in + guard AppStore.shared.currentScenicId > 0 else { + self?.showToast("请先选择景区") + return + } + self?.navigationController?.pushViewController( + PaymentCollectionDetailsViewController(), + animated: true + ) + } + cell.cardView.onSubmitTask = { [weak self] in + self?.showToast("功能开发中") + } + cell.cardView.onToggleOnline = { [weak self] in + self?.viewModel.showOnlineStatusSwitchDialog() + } + cell.apply(isOnline: self.viewModel.isOnline) + return cell + + case (.store, .store): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeStoreCell.reuseIdentifier, + for: indexPath + ) as! HomeStoreCell + if let store = self.viewModel.storeItem { + cell.apply(store: store) + } + return cell + + case (.commonApps, .menu(let menu)): + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: HomeMenuCell.reuseIdentifier, + for: indexPath + ) as! HomeMenuCell + cell.apply(menu: menu) + return cell + + default: + return UICollectionViewCell() + } + } + + dataSource.supplementaryViewProvider = { collectionView, kind, indexPath in + guard kind == UICollectionView.elementKindSectionHeader, + indexPath.section < self.currentSections.count, + self.currentSections[indexPath.section] == .commonApps else { + return nil + } + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: HomeSectionHeaderView.reuseIdentifier, + for: indexPath + ) as! HomeSectionHeaderView + header.apply(title: "常用应用") + return header + } + + return dataSource + } + private func initializeHome() async { showLoading() await viewModel.initialize(api: homeAPI) @@ -150,34 +255,49 @@ final class HomeViewController: BaseViewController { @MainActor private func applyViewModel() { scenicHeaderView.apply(scenicName: viewModel.currentScenicName) + let showWorkBlock = !viewModel.isMinimalTopRole - workStatusCardView.isHidden = !showWorkBlock - locationReportCardView.isHidden = !showWorkBlock - quickActionsView.isHidden = !showWorkBlock - - if showWorkBlock { - workStatusCardView.apply( - isOnline: viewModel.isOnline, - countdownText: viewModel.countdownDisplayText, - reminderMinutes: viewModel.reminderMinutes - ) - locationReportCardView.apply( - address: viewModel.locationInfoText, - isReporting: viewModel.isLocationReporting - ) - quickActionsView.apply(isOnline: viewModel.isOnline) - } - let showStore = viewModel.currentAppRole == .storeAdmin && viewModel.storeItem != nil - storeCardView.isHidden = !showStore - if showStore, let store = viewModel.storeItem { - storeCardView.apply(store: store) + let sections = HomeCollectionLayoutBuilder.sectionOrder( + showWorkBlock: showWorkBlock, + showStore: showStore, + storeItem: viewModel.storeItem + ) + + if sections != currentSections { + currentSections = sections + collectionView.setCollectionViewLayout( + HomeCollectionLayoutBuilder.makeLayout(sections: sections), + animated: false + ) } - commonAppsGridView.apply(menus: viewModel.commonMenus) + let snapshot = HomeCollectionLayoutBuilder.makeSnapshot( + showWorkBlock: showWorkBlock, + showStore: showStore, + storeItem: viewModel.storeItem, + commonMenus: viewModel.commonMenus + ) + dataSource.apply(snapshot, animatingDifferences: false) presentDialogsIfNeeded() } + private func refreshWorkStatusVisibleCell() { + guard !viewModel.isMinimalTopRole, + let sectionIndex = currentSections.firstIndex(of: .workStatus) else { + return + } + let indexPath = IndexPath(item: 0, section: sectionIndex) + guard let cell = collectionView.cellForItem(at: indexPath) as? HomeWorkStatusCell else { + return + } + cell.apply( + isOnline: viewModel.isOnline, + countdownText: viewModel.countdownDisplayText, + reminderMinutes: viewModel.reminderMinutes + ) + } + private func presentDialogsIfNeeded() { if viewModel.showPermissionDialog { presentDialog(.permission) @@ -345,13 +465,7 @@ final class HomeViewController: BaseViewController { countdownTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in guard let self else { return } self.viewModel.triggerLocationTimeoutIfNeeded() - if !self.viewModel.isMinimalTopRole { - self.workStatusCardView.apply( - isOnline: self.viewModel.isOnline, - countdownText: self.viewModel.countdownDisplayText, - reminderMinutes: self.viewModel.reminderMinutes - ) - } + self.refreshWorkStatusVisibleCell() } } @@ -372,3 +486,25 @@ final class HomeViewController: BaseViewController { } } } + +extension HomeViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard indexPath.section < currentSections.count, + currentSections[indexPath.section] == .commonApps, + let item = dataSource.itemIdentifier(for: indexPath), + case let .menu(menu) = item else { + return + } + + HomeRouteHandler.open( + uri: menu.uri, + from: self, + storeItem: viewModel.storeItem, + onCommonMenusChanged: { [weak self] in + guard let self else { return } + self.viewModel.rebuildCommonMenus() + self.applyViewModel() + } + ) + } +} diff --git a/suixinkan/UI/Home/Views/AllFunctionMenuCell.swift b/suixinkan/UI/Home/Views/AllFunctionMenuCell.swift new file mode 100644 index 0000000..d521fa6 --- /dev/null +++ b/suixinkan/UI/Home/Views/AllFunctionMenuCell.swift @@ -0,0 +1,104 @@ +// +// AllFunctionMenuCell.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 全部功能页菜单卡片,支持右上角添加或移除操作。 +final class AllFunctionMenuCell: UICollectionViewCell { + + static let reuseIdentifier = "AllFunctionMenuCell" + + enum ActionStyle { + case none + case add + case remove + } + + var onActionTap: (() -> Void)? + + private let cardView = UIView() + private let iconView = UIImageView() + private let titleLabel = UILabel() + private let actionButton = UIButton(type: .system) + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + onActionTap = nil + } + + func apply(menu: HomeMenuItem, actionStyle: ActionStyle) { + iconView.image = UIImage(systemName: menu.iconName) + titleLabel.text = menu.title + + switch actionStyle { + case .none: + actionButton.isHidden = true + case .add: + actionButton.isHidden = false + actionButton.setImage(UIImage(systemName: "plus.circle.fill"), for: .normal) + actionButton.tintColor = AppColor.primary + case .remove: + actionButton.isHidden = false + actionButton.setImage(UIImage(systemName: "minus.circle.fill"), for: .normal) + actionButton.tintColor = AppColor.danger + } + } + + private func setupUI() { + contentView.backgroundColor = .clear + + cardView.backgroundColor = .white + cardView.layer.cornerRadius = AppRadius.md + cardView.clipsToBounds = true + + iconView.tintColor = AppColor.primary + iconView.contentMode = .scaleAspectFit + + titleLabel.font = .app(.caption) + titleLabel.textColor = AppColor.textPrimary + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 2 + + actionButton.addTarget(self, action: #selector(actionTapped), for: .touchUpInside) + + contentView.addSubview(cardView) + cardView.addSubview(iconView) + cardView.addSubview(titleLabel) + cardView.addSubview(actionButton) + + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + iconView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(AppSpacing.sm) + make.centerX.equalToSuperview() + make.width.height.equalTo(32) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(iconView.snp.bottom).offset(AppSpacing.xs) + make.leading.trailing.equalToSuperview().inset(AppSpacing.xxs) + make.bottom.lessThanOrEqualToSuperview().inset(AppSpacing.xs) + } + actionButton.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(AppSpacing.xxs) + make.width.height.equalTo(20) + } + } + + @objc private func actionTapped() { + onActionTap?() + } +} diff --git a/suixinkan/UI/Home/Views/HomeCollectionCells.swift b/suixinkan/UI/Home/Views/HomeCollectionCells.swift new file mode 100644 index 0000000..191536a --- /dev/null +++ b/suixinkan/UI/Home/Views/HomeCollectionCells.swift @@ -0,0 +1,193 @@ +// +// HomeCollectionCells.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 首页在线状态卡片 cell。 +final class HomeWorkStatusCell: UICollectionViewCell { + + static let reuseIdentifier = "HomeWorkStatusCell" + + let cardView = HomeWorkStatusCardView() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(cardView) + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(isOnline: Bool, countdownText: String, reminderMinutes: Int) { + cardView.apply(isOnline: isOnline, countdownText: countdownText, reminderMinutes: reminderMinutes) + } +} + +/// 首页位置上报卡片 cell。 +final class HomeLocationReportCell: UICollectionViewCell { + + static let reuseIdentifier = "HomeLocationReportCell" + + let cardView = HomeLocationReportCardView() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(cardView) + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(address: String, isReporting: Bool) { + cardView.apply(address: address, isReporting: isReporting) + } +} + +/// 首页快捷操作 cell。 +final class HomeQuickActionsCell: UICollectionViewCell { + + static let reuseIdentifier = "HomeQuickActionsCell" + + let cardView = HomeQuickActionsView() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(cardView) + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(isOnline: Bool) { + cardView.apply(isOnline: isOnline) + } +} + +/// 首页门店信息 cell。 +final class HomeStoreCell: UICollectionViewCell { + + static let reuseIdentifier = "HomeStoreCell" + + let cardView = HomeStoreCardView() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(cardView) + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(store: StoreItem) { + cardView.apply(store: store) + } +} + +/// 首页常用应用菜单 cell。 +final class HomeMenuCell: UICollectionViewCell { + + static let reuseIdentifier = "HomeMenuCell" + + private let cardView = UIView() + private let iconView = UIImageView() + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(menu: HomeMenuItem) { + iconView.image = UIImage(systemName: menu.iconName) + titleLabel.text = menu.title + } + + private func setupUI() { + contentView.backgroundColor = .clear + + cardView.backgroundColor = .white + cardView.layer.cornerRadius = AppRadius.md + cardView.clipsToBounds = true + + iconView.tintColor = AppColor.primary + iconView.contentMode = .scaleAspectFit + + titleLabel.font = .app(.body) + titleLabel.textColor = AppColor.textSecondary + titleLabel.textAlignment = .center + titleLabel.numberOfLines = 2 + + contentView.addSubview(cardView) + cardView.addSubview(iconView) + cardView.addSubview(titleLabel) + + cardView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + iconView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(AppSpacing.sm) + make.centerX.equalToSuperview() + make.width.height.equalTo(24) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(iconView.snp.bottom).offset(AppSpacing.sm) + make.leading.trailing.equalToSuperview().inset(AppSpacing.xxs) + make.bottom.lessThanOrEqualToSuperview().inset(AppSpacing.xs) + } + } +} + +/// 首页分区标题 supplementary view。 +final class HomeSectionHeaderView: UICollectionReusableView { + + static let reuseIdentifier = "HomeSectionHeaderView" + + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + titleLabel.font = .app(.title) + titleLabel.textColor = AppColor.textPrimary + addSubview(titleLabel) + titleLabel.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview() + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func apply(title: String) { + titleLabel.text = title + } +} diff --git a/suixinkan/UI/Home/Views/HomeCommonAppsGridView.swift b/suixinkan/UI/Home/Views/HomeCommonAppsGridView.swift deleted file mode 100644 index c67fc29..0000000 --- a/suixinkan/UI/Home/Views/HomeCommonAppsGridView.swift +++ /dev/null @@ -1,132 +0,0 @@ -// -// HomeCommonAppsGridView.swift -// suixinkan -// - -import SnapKit -import UIKit - -/// 首页常用应用网格。 -final class HomeCommonAppsGridView: UIView { - - var onMenuSelected: ((HomeMenuItem) -> Void)? - - private enum Section { case main } - private enum Item: Hashable { case menu(HomeMenuItem) } - - private var menus: [HomeMenuItem] = [] - private let titleLabel = UILabel() - private lazy var collectionView: UICollectionView = { - let itemSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(0.25), - heightDimension: .absolute(88) - ) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - let groupSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1), - heightDimension: .absolute(88) - ) - let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) - let section = NSCollectionLayoutSection(group: group) - let layout = UICollectionViewCompositionalLayout(section: section) - return UICollectionView(frame: .zero, collectionViewLayout: layout) - }() - - private lazy var dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { - collectionView, indexPath, item in - let cell = collectionView.dequeueReusableCell( - withReuseIdentifier: HomeMenuCell.reuseIdentifier, - for: indexPath - ) as! HomeMenuCell - if case let .menu(menu) = item { - cell.apply(menu: menu) - } - return cell - } - - override init(frame: CGRect) { - super.init(frame: frame) - titleLabel.text = "常用应用" - titleLabel.font = .app(.title) - titleLabel.textColor = AppColor.textPrimary - - collectionView.backgroundColor = .clear - collectionView.delegate = self - collectionView.register(HomeMenuCell.self, forCellWithReuseIdentifier: HomeMenuCell.reuseIdentifier) - - addSubview(titleLabel) - addSubview(collectionView) - - titleLabel.snp.makeConstraints { make in - make.top.leading.trailing.equalToSuperview() - } - collectionView.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(AppSpacing.sm) - make.leading.trailing.bottom.equalToSuperview() - make.height.equalTo(88) - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func apply(menus: [HomeMenuItem]) { - self.menus = menus + [.moreFunctions] - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - snapshot.appendItems(self.menus.map { Item.menu($0) }) - dataSource.apply(snapshot, animatingDifferences: false) - } -} - -extension HomeCommonAppsGridView: UICollectionViewDelegate { - func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard indexPath.item < menus.count else { return } - onMenuSelected?(menus[indexPath.item]) - } -} - -/// 首页菜单 cell。 -final class HomeMenuCell: UICollectionViewCell { - - static let reuseIdentifier = "HomeMenuCell" - - private let iconView = UIImageView() - private let titleLabel = UILabel() - - override init(frame: CGRect) { - super.init(frame: frame) - iconView.tintColor = AppColor.primary - iconView.contentMode = .scaleAspectFit - - titleLabel.font = .app(.caption) - titleLabel.textColor = AppColor.textPrimary - titleLabel.textAlignment = .center - titleLabel.numberOfLines = 2 - - contentView.addSubview(iconView) - contentView.addSubview(titleLabel) - - iconView.snp.makeConstraints { make in - make.top.equalToSuperview().offset(AppSpacing.xs) - make.centerX.equalToSuperview() - make.width.height.equalTo(28) - } - titleLabel.snp.makeConstraints { make in - make.top.equalTo(iconView.snp.bottom).offset(AppSpacing.xs) - make.leading.trailing.equalToSuperview().inset(AppSpacing.xxs) - } - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func apply(menu: HomeMenuItem) { - iconView.image = UIImage(systemName: menu.iconName) - titleLabel.text = menu.title - } -} diff --git a/suixinkanTests/AllFunctionsViewModelTests.swift b/suixinkanTests/AllFunctionsViewModelTests.swift new file mode 100644 index 0000000..4630020 --- /dev/null +++ b/suixinkanTests/AllFunctionsViewModelTests.swift @@ -0,0 +1,123 @@ +// +// AllFunctionsViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 全部功能 ViewModel 测试。 +final class AllFunctionsViewModelTests: XCTestCase { + + private var defaults: UserDefaults! + private var appStore: AppStore! + private var menuStore: HomeCommonMenuStore! + + override func setUp() { + defaults = UserDefaults(suiteName: "AllFunctionsViewModelTests")! + defaults.removePersistentDomain(forName: "AllFunctionsViewModelTests") + appStore = AppStore(defaults: defaults) + menuStore = HomeCommonMenuStore(defaults: defaults) + appStore.userId = "1001" + appStore.accountType = "scenic_user" + appStore.roleCode = AppRoleCode.photographer.rawValue + } + + override func tearDown() { + defaults.removePersistentDomain(forName: "AllFunctionsViewModelTests") + super.tearDown() + } + + func testLoadFunctionsUsesDefaultFirstFourWhenNoSavedURIs() { + appStore.savePermissionItems([ + HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), + HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"), + HomePermissionItem(id: "3", name: "任务", uri: "task_management"), + HomePermissionItem(id: "4", name: "设置", uri: "system_settings"), + HomePermissionItem(id: "5", name: "消息", uri: "message_center"), + ]) + let viewModel = AllFunctionsViewModel(appStore: appStore, commonMenuStore: menuStore) + + viewModel.loadFunctions() + + XCTAssertEqual(viewModel.commonMenus.map(\.uri), [ + "wallet", "cloud_management", "task_management", "system_settings", + ]) + XCTAssertEqual(viewModel.moreMenus.map(\.uri), ["message_center"]) + XCTAssertEqual( + menuStore.savedCommonURIs( + accountScope: appStore.accountCachePrefix, + roleCode: appStore.roleCode + ), + ["wallet", "cloud_management", "task_management", "system_settings"] + ) + } + + func testLoadFunctionsUsesSavedURIsOverDefault() { + appStore.savePermissionItems([ + HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), + HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"), + HomePermissionItem(id: "3", name: "任务", uri: "task_management"), + ]) + menuStore.saveCommonURIs( + ["cloud_management"], + accountScope: appStore.accountCachePrefix, + roleCode: appStore.roleCode + ) + let viewModel = AllFunctionsViewModel(appStore: appStore, commonMenuStore: menuStore) + + viewModel.loadFunctions() + + XCTAssertEqual(viewModel.commonMenus.map(\.uri), ["cloud_management"]) + XCTAssertEqual(Set(viewModel.moreMenus.map(\.uri)), Set(["wallet", "task_management"])) + } + + func testAddToCommonMovesMenuAndPersists() { + appStore.savePermissionItems([ + HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), + HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"), + ]) + menuStore.saveCommonURIs( + ["wallet"], + accountScope: appStore.accountCachePrefix, + roleCode: appStore.roleCode + ) + let viewModel = AllFunctionsViewModel(appStore: appStore, commonMenuStore: menuStore) + viewModel.loadFunctions() + let cloudMenu = viewModel.moreMenus.first { $0.uri == "cloud_management" }! + + viewModel.addToCommon(cloudMenu) + + XCTAssertEqual(viewModel.commonMenus.map(\.uri), ["wallet", "cloud_management"]) + XCTAssertTrue(viewModel.moreMenus.isEmpty) + XCTAssertTrue(viewModel.didCustomize) + XCTAssertEqual( + menuStore.savedCommonURIs( + accountScope: appStore.accountCachePrefix, + roleCode: appStore.roleCode + ), + ["wallet", "cloud_management"] + ) + } + + func testRemoveFromCommonMovesMenuAndPersists() { + appStore.savePermissionItems([ + HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), + HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"), + ]) + menuStore.saveCommonURIs( + ["wallet", "cloud_management"], + accountScope: appStore.accountCachePrefix, + roleCode: appStore.roleCode + ) + let viewModel = AllFunctionsViewModel(appStore: appStore, commonMenuStore: menuStore) + viewModel.loadFunctions() + let walletMenu = viewModel.commonMenus.first { $0.uri == "wallet" }! + + viewModel.removeFromCommon(walletMenu) + + XCTAssertEqual(viewModel.commonMenus.map(\.uri), ["cloud_management"]) + XCTAssertEqual(viewModel.moreMenus.map(\.uri), ["wallet"]) + XCTAssertTrue(viewModel.didCustomize) + } +} diff --git a/suixinkanTests/HomeCollectionModelsTests.swift b/suixinkanTests/HomeCollectionModelsTests.swift new file mode 100644 index 0000000..3973224 --- /dev/null +++ b/suixinkanTests/HomeCollectionModelsTests.swift @@ -0,0 +1,53 @@ +// +// HomeCollectionModelsTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 首页 Collection snapshot 测试。 +final class HomeCollectionModelsTests: XCTestCase { + + func testMakeSnapshotIncludesDistinctWorkBlockItems() { + let snapshot = HomeCollectionLayoutBuilder.makeSnapshot( + showWorkBlock: true, + showStore: false, + storeItem: nil, + commonMenus: [] + ) + + XCTAssertEqual(snapshot.numberOfSections, 4) + XCTAssertEqual(snapshot.numberOfItems(inSection: .workStatus), 1) + XCTAssertEqual(snapshot.numberOfItems(inSection: .locationReport), 1) + XCTAssertEqual(snapshot.numberOfItems(inSection: .quickActions), 1) + XCTAssertEqual(snapshot.itemIdentifiers(inSection: .workStatus), [.workStatus]) + XCTAssertEqual(snapshot.itemIdentifiers(inSection: .locationReport), [.locationReport]) + XCTAssertEqual(snapshot.itemIdentifiers(inSection: .quickActions), [.quickActions]) + } + + func testMakeSnapshotOmitsWorkBlockForMinimalTopRole() { + let snapshot = HomeCollectionLayoutBuilder.makeSnapshot( + showWorkBlock: false, + showStore: false, + storeItem: nil, + commonMenus: [] + ) + + XCTAssertEqual(snapshot.sectionIdentifiers, [.commonApps]) + XCTAssertEqual(snapshot.itemIdentifiers(inSection: .commonApps).count, 1) + } + + func testMakeSnapshotIncludesStoreSectionWhenAvailable() { + let store = StoreItem(id: 1, name: "测试门店", address: "测试地址") + let snapshot = HomeCollectionLayoutBuilder.makeSnapshot( + showWorkBlock: true, + showStore: true, + storeItem: store, + commonMenus: [] + ) + + XCTAssertTrue(snapshot.sectionIdentifiers.contains(.store)) + XCTAssertEqual(snapshot.itemIdentifiers(inSection: .store), [.store]) + } +} diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift index c0ac4c0..b086431 100644 --- a/suixinkanTests/HomeCommonMenuStoreTests.swift +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -47,4 +47,15 @@ final class HomeCommonMenuStoreTests: XCTestCase { ) XCTAssertEqual(common.map(\.uri), ["cloud_management"]) } + + func testSplitMenusSeparatesCommonAndMore() { + let menus = [ + HomeMenuItem(uri: "wallet", title: "钱包", iconName: "wallet.pass"), + HomeMenuItem(uri: "cloud_management", title: "云盘", iconName: "icloud"), + HomeMenuItem(uri: "task_management", title: "任务", iconName: "checklist"), + ] + let split = HomeCommonMenuStore.splitMenus(from: menus, commonURIs: ["wallet", "task_management"]) + XCTAssertEqual(split.common.map(\.uri), ["wallet", "task_management"]) + XCTAssertEqual(split.more.map(\.uri), ["cloud_management"]) + } } diff --git a/suixinkanTests/HomeViewModelTests.swift b/suixinkanTests/HomeViewModelTests.swift index b362d40..13d21c3 100644 --- a/suixinkanTests/HomeViewModelTests.swift +++ b/suixinkanTests/HomeViewModelTests.swift @@ -11,9 +11,10 @@ import XCTest final class HomeViewModelTests: XCTestCase { private var appStore: AppStore! + private var defaults: UserDefaults! override func setUp() { - let defaults = UserDefaults(suiteName: "HomeViewModelTests")! + defaults = UserDefaults(suiteName: "HomeViewModelTests")! defaults.removePersistentDomain(forName: "HomeViewModelTests") appStore = AppStore(defaults: defaults) appStore.userId = "2001" @@ -50,6 +51,31 @@ final class HomeViewModelTests: XCTestCase { XCTAssertEqual(viewModel.commonMenus.map(\.uri), ["wallet"]) } + func testRebuildCommonMenusPersistsDefaultURIsOnFirstLoad() { + let menuStore = HomeCommonMenuStore(defaults: defaults) + appStore.savePermissionItems([ + HomePermissionItem(id: "1", name: "钱包", uri: "wallet"), + HomePermissionItem(id: "2", name: "云盘", uri: "cloud_management"), + HomePermissionItem(id: "3", name: "任务", uri: "task_management"), + HomePermissionItem(id: "4", name: "设置", uri: "system_settings"), + HomePermissionItem(id: "5", name: "消息", uri: "message_center"), + ]) + let viewModel = HomeViewModel(appStore: appStore, commonMenuStore: menuStore) + + viewModel.rebuildCommonMenus() + + XCTAssertEqual(viewModel.commonMenus.map(\.uri), [ + "wallet", "cloud_management", "task_management", "system_settings", + ]) + XCTAssertEqual( + menuStore.savedCommonURIs( + accountScope: appStore.accountCachePrefix, + roleCode: appStore.roleCode + ), + ["wallet", "cloud_management", "task_management", "system_settings"] + ) + } + func testEmptyRolePermissionShowsPermissionDialog() async throws { let responseData = try TestJSON.envelope(data: [RolePermissionResponse]()) let session = MockURLSession(responses: [responseData])