diff --git a/suixinkan/App/State/PermissionContext.swift b/suixinkan/App/State/PermissionContext.swift index 7f8ad25..635ab90 100644 --- a/suixinkan/App/State/PermissionContext.swift +++ b/suixinkan/App/State/PermissionContext.swift @@ -74,13 +74,17 @@ final class PermissionContext: ObservableObject { /// 返回指定角色的顶层权限 URI 列表,顺序与 API 返回一致。 func topLevelPermissionURIs(for roleCode: String?) -> [String] { + topLevelPermissions(for: roleCode).map(\.uri) + } + + /// 返回指定角色的顶层权限节点,顺序与 API 返回一致。 + func topLevelPermissions(for roleCode: String?) -> [PermissionItem] { guard let roleCode, let rolePermission = matchedRolePermission(roleCode: roleCode) else { return [] } - return rolePermission.role.permission.compactMap { item in - let uri = item.uri.trimmingCharacters(in: .whitespacesAndNewlines) - return uri.isEmpty ? nil : uri + return rolePermission.role.permission.filter { item in + !item.uri.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } diff --git a/suixinkan/Features/Home/AllFunctions.md b/suixinkan/Features/Home/AllFunctions.md new file mode 100644 index 0000000..1a7aa37 --- /dev/null +++ b/suixinkan/Features/Home/AllFunctions.md @@ -0,0 +1,209 @@ +# 「全部功能」页业务逻辑 + +Android 工程对应文档:[`zhiflyfollow/docs/home/AllFunctions.md`](../../../zhiflyfollow/docs/home/AllFunctions.md) + +## 模块职责 + +「全部功能」页(`HomeMoreFunctionsView`)展示当前角色可用的功能入口,分为两个分区: + +- **常用应用**:已加入首页常用的入口,支持移除(`-`) +- **更多功能**:其余可用入口,支持添加为常用(`+`) + +常用应用的增删会同步写入本地持久化,并与首页「常用应用」网格共用同一套配置(`HomeCommonMenuStore`)。 + +本页数据构建规则与 Android `AllFunctionsViewModel` 对齐,**不**复用首页 `HomeViewModel` 的 flatten + `preferredOrder` 逻辑。 + +--- + +## 涉及文件 + +| 文件 | 职责 | +| --- | --- | +| `Views/HomeMoreFunctionsView.swift` | 页面 UI、增删常用、触发重建 | +| `Services/HomeAllFunctionsBuilder.swift` | 菜单列表构建与常用/更多拆分 | +| `Services/HomeCommonMenuStore.swift` | 常用 URI 持久化与默认生成 | +| `Services/HomeAllFunctionsDiagnostics.swift` | Debug 诊断日志 | +| `App/State/PermissionContext.swift` | 提供当前角色顶层权限 | +| `Routing/HomeMenuRouter.swift` | 点击入口后的路由解析 | + +--- + +## 数据流 + +```mermaid +flowchart TD + API[role-permission 接口] --> PC[PermissionContext] + PC --> TLP[topLevelPermissions 顶层节点] + TLP --> BUILDER[HomeAllFunctionsBuilder.build] + CMS[HomeCommonMenuStore.load] --> COMMON[commonURIs] + COMMON --> BUILDER + BUILDER --> ALL[allFunctions] + BUILDER --> CF[commonFunctions] + BUILDER --> MF[moreFunctions] + CF --> UI1[常用应用网格] + MF --> UI2[更多功能网格] +``` + +### 触发重建的时机 + +`HomeMoreFunctionsView.rebuildMenus()` 在以下情况执行: + +1. 页面首次进入(`.task`) +2. 当前角色 `roleCode` 变化 +3. `rolePermissions` 数量变化 + +用户点击 `+` / `-` 时只更新 `commonUris` 并调用 `applySnapshot`,不重新走持久化读取(除非权限上下文同时变化)。 + +--- + +## 第一步:读取顶层权限 + +数据来源:`PermissionContext.topLevelPermissions(for: roleCode)` + +- 仅取当前匹配角色的 `role.permission` **顶层数组** +- **不**递归展开 `children` 子节点 +- 顺序与 API 返回一致,不做本地重排 +- 过滤掉 URI 为空的节点 + +对应 Android:`appStore.getPermission()` 中保存的顶层权限列表(MMKV 快照)。 + +--- + +## 第二步:白名单过滤(可用入口) + +由 `HomeAllFunctionsBuilder.allMenuItems(from:)` 执行: + +``` +allFunctions = 顶层权限 + .按 API 顺序遍历 + .保留 uri ∈ HomeCommonMenuStore.androidHomeMenuURIs 的项 + .映射为 HomeMenuItem +``` + +`androidHomeMenuURIs` 与 Android `Constants.menuList` 登记 URI 一致。以下典型 URI **不会**出现在「全部功能」页,即使接口返回了顶层权限: + +- `basic_info`、`photographer_stats`、`photographer_orders` +- `location_info`、`scan_qr`、`payment_qr`、`travel_album` +- `album_list`、`material_upload` 等未登记 URI + +子权限 URI 也不会出现(未 flatten)。 + +--- + +## 第三步:菜单项字段映射 + +每个保留的 `PermissionItem` 转为 `HomeMenuItem`: + +| 字段 | 规则 | +| --- | --- | +| `uri` | 权限节点原始 URI,精确字符串 | +| `title` | API `name` 非空时用 `name`;否则 `HomeMenuRouter.title(for:)`;再经 `HomeMenuRouter.displayTitle` 统一部分同义入口文案 | +| `iconSrc` | API `icon_src`;为空时 UI 层用 `HomeIconCatalog` SF Symbol 兜底 | + +Android 端标题/icon 来自本地 `Constants.menuList` drawable;iOS 优先接口字段 + 本地图标兜底。 + +--- + +## 第四步:读取常用 URI + +由 `HomeCommonMenuStore.load` 提供 `commonUris`,规则详见 [`Home.md`](Home.md)「常用应用」章节,核心要点: + +- 存储 key:`home.common.menu.uris.account.{accountScope}.role.{roleCode}`(无 accountScope 时用 `home.common.menu.uris.role.{roleCode}`) +- 首次无 saved:按顶层前 4 个 URI 与 menuList 求交生成默认,并落库 +- 已有 saved:精确 URI 匹配当前可用顶层权限;全部失效时返回空,不回退默认 +- 增删常用:精确 URI 匹配(不使用 `menuAliasKey`) + +--- + +## 第五步:拆分为常用 / 更多 + +`HomeAllFunctionsBuilder.build(topLevelPermissions:commonURIs:)`: + +```text +commonSet = Set(commonURIs) + +commonFunctions = allFunctions.filter { commonSet.contains($0.uri) } +moreFunctions = allFunctions.filter { !commonSet.contains($0.uri) } +``` + +要点: + +- 匹配方式:**精确 URI**,不做别名归一 +- **常用区顺序**:跟随 `allFunctions` 列表顺序(API 顺序 ∩ 白名单后的顺序),**不是** `commonUris` 数组的存储顺序 +- **更多区顺序**:同样跟随 `allFunctions` 剩余项顺序 + +与 Android 一致: + +```kotlin +val common = functions.filter { it.uri in commonUris } +val more = functions.filter { it.uri !in commonUris } +``` + +--- + +## 展示层 + +### 布局 + +- 两节标题:「常用应用」「更多功能」 +- 每节 3 列 `LazyVGrid`,卡片高度 112pt +- 卡片右上角:`常用` 显示红色 `-`,`更多` 显示蓝色 `+` + +### 点击行为 + +- 点击卡片主体:`HomeMenuRouter.resolve(uri:title:)` 解析路由 + - Tab 切换、订单 Tab、Home 子路由、占位页等 + - `more_functions` URI 在页内忽略(防循环) +- 点击 `-`:`HomeCommonMenuStore.remove` → `applySnapshot` +- 点击 `+`:`HomeCommonMenuStore.add`(校验 URI 在顶层可用白名单内)→ `applySnapshot` + +### 与首页的关系 + +| 维度 | 首页常用应用网格 | 全部功能页 | +| --- | --- | --- | +| 数据源 | `HomeCommonMenuStore` + `HomeViewModel`(展示标题/icon) | `HomeAllFunctionsBuilder` | +| 列表范围 | 仅 common URIs + 「更多功能」入口 | 全部可用入口分 common / more | +| 排序 | common 按 store 顺序映射 | common/more 均按 `allFunctions` API 顺序 | +| 持久化 | 共用 `HomeCommonMenuStore` | 共用 `HomeCommonMenuStore` | + +--- + +## 与 HomeViewModel 的区别 + +| | `HomeViewModel`(首页等) | `HomeAllFunctionsBuilder`(全部功能) | +| --- | --- | --- | +| 权限范围 | 递归 flatten 整棵权限树 | 仅顶层 | +| 白名单 | 无 | `androidHomeMenuURIs` | +| 排序 | `preferredOrder` 硬编码权重 | API 顶层顺序 | +| 去重 | `menuAliasKey` 别名去重 | 无(顶层 URI 精确保留) | + +「全部功能」页**不应**调用 `HomeViewModel.buildMenus()`。 + +--- + +## 诊断日志 + +Debug 构建下可用 Xcode Console 过滤 `HomeAllFunctions`: + +| step | 含义 | +| --- | --- | +| `allFunctions` | 白名单过滤后的完整 URI 列表 | +| `commonFunctions` | 常用分区 URI | +| `moreFunctions` | 更多分区 URI | + +常用应用持久化链路仍使用 `HomeCommonMenu` tag,见 `HomeCommonMenuStore` / 首页 `HomeView` 日志。 + +--- + +## 测试 + +单元测试:`suixinkanTests/HomeAllFunctionsBuilderTests.swift` + +覆盖场景: + +- menuList 白名单过滤 +- API 顶层顺序保留(非 preferredOrder) +- 不展开子权限 +- 精确 URI 拆分 common / more +- 常用区顺序跟随 `allFunctions` +- 与 Android 样例账号(27 顶层 → 20 可用 → 3 常用 + 17 更多)一致 diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index 931f4bf..53c24cf 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -1,5 +1,7 @@ # Home 模块业务逻辑 +Android 工程对应文档:[`zhiflyfollow/docs/home/Home.md`](../../../zhiflyfollow/docs/home/Home.md) + ## 模块职责 Home 模块负责登录后的首页工作台,包括当前景区展示、工作状态、位置上报入口、快捷操作、常用应用和全部功能入口。 @@ -18,14 +20,15 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作 ## 常用应用 -`HomeCommonMenuStore` 使用 UserDefaults 按账号和角色保存常用应用 URI: +`HomeCommonMenuStore` 使用 UserDefaults 按账号和角色保存常用应用 URI,算法与 Android `AllFunctionsViewModel` 对齐: - 当前版本使用账号 + 角色作用域保存常用应用 URI,避免不同业务账号的同一角色互相覆盖;旧版角色 key 会在首次读取时迁移。 -- 首次无配置时,按 Android 规则从当前角色顶层权限 URI 生成默认值:权限多于 4 个取前 4 个,否则全部使用。 -- 首页常用应用按 Android `Constants.menuList` 白名单过滤;iOS 已接入但 Android 首页未登记的 URI 不进入首页常用应用。 -- 读取时按当前角色权限过滤不可用 URI。 -- 添加和移除时按同义 URI 去重。 +- 可用菜单范围:仅**顶层权限** ∩ Android `Constants.menuList` 白名单(`androidHomeMenuURIs`),不使用扁平化子权限。 +- 首次无配置时:先取顶层权限前 4 个 URI(原始 API 顺序),再与 menuList 白名单求交后落库。 +- 已有 saved 配置时:精确 URI 匹配过滤;全部失效时返回空,**不回退**默认生成。 +- 添加/移除常用应用使用精确 URI 匹配(与 Android 一致)。 - 不同角色的常用应用互不影响。 - 升级时会从旧版 `role.{roleId}` key 一次性迁移到 `role.{roleCode}`。 +- Debug 构建下可通过 `HomeCommonMenu` 日志 tag 与 Android 端逐步对比(`context` / `rawTopLevel` / `availableFunctions` / `savedCommon` / `defaultCommon` / `finalCommon` / `displayItems`)。 ## 路由规则 @@ -39,6 +42,10 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作 `HomeMoreFunctionsView` 使用系统导航栏展示标题和返回,以支持左边缘滑动返回。 +## 全部功能 + +「全部功能」页的数据、筛选、展示逻辑见独立文档 [`AllFunctions.md`](AllFunctions.md)。 + ## 位置上报 首页工作状态区域由 `HomeLocationViewModel` 驱动,与 `LocationReport` 详情页共享同一实例(在 `RootView` 注入)。 diff --git a/suixinkan/Features/Home/Services/HomeAllFunctionsBuilder.swift b/suixinkan/Features/Home/Services/HomeAllFunctionsBuilder.swift new file mode 100644 index 0000000..3308db4 --- /dev/null +++ b/suixinkan/Features/Home/Services/HomeAllFunctionsBuilder.swift @@ -0,0 +1,57 @@ +// +// HomeAllFunctionsBuilder.swift +// suixinkan +// +// Created by Codex on 2026/6/30. +// + +import Foundation + +/// 「全部功能」页菜单快照,包含完整列表及按常用 URI 拆分后的分区。 +struct HomeAllFunctionsSnapshot: Equatable { + let allFunctions: [HomeMenuItem] + let commonFunctions: [HomeMenuItem] + let moreFunctions: [HomeMenuItem] +} + +/// 「全部功能」页菜单构建器,与 Android `AllFunctionsViewModel` 对齐。 +enum HomeAllFunctionsBuilder { + /// 从顶层权限构建全部功能列表,并按常用 URI 精确拆分为常用/更多。 + static func build( + topLevelPermissions: [PermissionItem], + commonURIs: [String] + ) -> HomeAllFunctionsSnapshot { + let allFunctions = allMenuItems(from: topLevelPermissions) + let commonSet = Set(commonURIs) + let commonFunctions = allFunctions.filter { commonSet.contains($0.uri) } + let moreFunctions = allFunctions.filter { !commonSet.contains($0.uri) } + + HomeAllFunctionsDiagnostics.log(snapshot: HomeAllFunctionsSnapshot( + allFunctions: allFunctions, + commonFunctions: commonFunctions, + moreFunctions: moreFunctions + )) + + return HomeAllFunctionsSnapshot( + allFunctions: allFunctions, + commonFunctions: commonFunctions, + moreFunctions: moreFunctions + ) + } + + /// 将顶层权限转为 menuList 白名单内的菜单项,保持 API 返回顺序。 + private static func allMenuItems(from topLevelPermissions: [PermissionItem]) -> [HomeMenuItem] { + topLevelPermissions.compactMap { item -> HomeMenuItem? in + let uri = item.uri.trimmingCharacters(in: .whitespacesAndNewlines) + guard !uri.isEmpty, HomeCommonMenuStore.androidHomeMenuURIs.contains(uri) else { + return nil + } + let fallbackTitle = item.name.isEmpty ? HomeMenuRouter.title(for: uri) : item.name + return HomeMenuItem( + title: HomeMenuRouter.displayTitle(for: uri, fallback: fallbackTitle), + uri: uri, + iconSrc: item.iconSrc + ) + } + } +} diff --git a/suixinkan/Features/Home/Services/HomeAllFunctionsDiagnostics.swift b/suixinkan/Features/Home/Services/HomeAllFunctionsDiagnostics.swift new file mode 100644 index 0000000..591c9ef --- /dev/null +++ b/suixinkan/Features/Home/Services/HomeAllFunctionsDiagnostics.swift @@ -0,0 +1,29 @@ +// +// HomeAllFunctionsDiagnostics.swift +// suixinkan +// +// Created by Codex on 2026/6/30. +// + +import Foundation +import os + +/// 「全部功能」页诊断日志,便于与 Android `AllFunctionsViewModel` 输出对比。 +enum HomeAllFunctionsDiagnostics { + private static let logger = Logger(subsystem: "com.yuanzhixiang.suixinkan", category: "HomeAllFunctions") + + /// 记录全部功能页的分区菜单 URI 列表。 + static func log(snapshot: HomeAllFunctionsSnapshot) { + #if DEBUG + log(step: "allFunctions", uris: snapshot.allFunctions.map(\.uri)) + log(step: "commonFunctions", uris: snapshot.commonFunctions.map(\.uri)) + log(step: "moreFunctions", uris: snapshot.moreFunctions.map(\.uri)) + #endif + } + + /// 记录单步 URI 列表。 + private static func log(step: String, uris: [String]) { + let payload = "count=\(uris.count) uris=\(HomeCommonMenuDiagnostics.formatURIList(uris))" + logger.debug("step=\(step, privacy: .public) \(payload, privacy: .public)") + } +} diff --git a/suixinkan/Features/Home/Services/HomeCommonMenuDiagnostics.swift b/suixinkan/Features/Home/Services/HomeCommonMenuDiagnostics.swift new file mode 100644 index 0000000..2aba247 --- /dev/null +++ b/suixinkan/Features/Home/Services/HomeCommonMenuDiagnostics.swift @@ -0,0 +1,33 @@ +// +// HomeCommonMenuDiagnostics.swift +// suixinkan +// +// Created by Codex on 2026/6/30. +// + +import Foundation +import os + +/// 首页常用应用诊断日志,输出与 Android `HomeCommonMenu` tag 对齐的字段。 +enum HomeCommonMenuDiagnostics { + private static let logger = Logger(subsystem: "com.yuanzhixiang.suixinkan", category: "HomeCommonMenu") + + /// 记录常用应用流水线中的单步诊断信息。 + static func log(step: String, fields: [String: String]) { + #if DEBUG + let payload = fields.map { "\($0.key)=\($0.value)" }.joined(separator: " ") + logger.debug("step=\(step, privacy: .public) \(payload, privacy: .public)") + #endif + } + + /// 将 URI 列表格式化为日志可读字符串。 + static func formatURIList(_ uris: [String]) -> String { + "[\(uris.joined(separator: ","))]" + } + + /// 将展示项格式化为 title|uri 列表。 + static func formatDisplayItems(_ items: [(title: String, uri: String)]) -> String { + let labels = items.map { "\($0.title)|\($0.uri)" } + return "[\(labels.joined(separator: ","))]" + } +} diff --git a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift index 96b7356..e255b30 100644 --- a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift +++ b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift @@ -49,25 +49,26 @@ struct HomeCommonMenuStore { self.defaults = defaults } - /// 按 Android 规则生成默认常用 URI:顶层权限顺序、可用权限过滤、别名去重,超过 4 个取前 4 个。 - static func defaultCommonURIs( - orderedTopLevelURIs: [String], - availableURIs: Set - ) -> [String] { - let resolved = orderedTopLevelURIs.compactMap { uri -> String? in + /// 按 Android 规则生成默认常用 URI:先取顶层前 4 个 URI,再与 menuList 白名单求交。 + static func defaultCommonURIs(orderedTopLevelURIs: [String]) -> [String] { + let rawTopLevel = orderedTopLevelURIs.compactMap { uri -> String? in let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - let canonical = HomeMenuRouter.canonicalURI(for: trimmed, availableURIs: availableURIs) - return Self.isAndroidHomeMenuURI(canonical) && availableURIs.contains(canonical) ? canonical : nil + return trimmed.isEmpty ? nil : trimmed } - let deduplicated = unique(resolved) - if deduplicated.count > 4 { - return Array(deduplicated.prefix(4)) - } - return deduplicated + let candidates = rawTopLevel.count > 4 ? Array(rawTopLevel.prefix(4)) : rawTopLevel + return candidates.filter { isAndroidHomeMenuURI($0) } } - /// 读取常用 URI;无角色时返回空,无保存或过滤后为空时写入 Android 对齐默认值。 + /// 返回顶层权限中属于 Android menuList 的 URI,顺序与 API 一致。 + static func availableTopLevelMenuURIs(from orderedTopLevelURIs: [String]) -> [String] { + orderedTopLevelURIs.compactMap { uri -> String? in + let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, isAndroidHomeMenuURI(trimmed) else { return nil } + return trimmed + } + } + + /// 读取常用 URI;无角色时返回空,有 saved 时精确匹配且不回退默认。 func load( menuItems: [HomeMenuItem], roleCode: String?, @@ -78,55 +79,99 @@ struct HomeCommonMenuStore { guard let roleCode, !roleCode.isEmpty else { return [] } migrateLegacyStorageIfNeeded(roleCode: roleCode, accountScope: accountScope, legacyRoleId: legacyRoleId) - let availableURIs = Set(menuItems.map(\.uri)) + let rawTopLevel = topLevelPermissionURIs.compactMap { uri -> String? in + let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + let availableTopLevel = Self.availableTopLevelMenuURIs(from: rawTopLevel) + let availableTopLevelSet = Set(availableTopLevel) let key = Self.storageKey(roleCode: roleCode, accountScope: accountScope) - let saved = defaults.stringArray(forKey: key) + let saved = defaults.stringArray(forKey: key) ?? [] + let defaultCommon = Self.defaultCommonURIs(orderedTopLevelURIs: rawTopLevel) - if let saved, !saved.isEmpty { - let filtered = Self.unique(saved.compactMap { uri -> String? in - let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) - return Self.isAndroidHomeMenuURI(resolved) && availableURIs.contains(resolved) ? resolved : nil - }) - if !filtered.isEmpty { - save(filtered, roleCode: roleCode, accountScope: accountScope) - return filtered - } + HomeCommonMenuDiagnostics.log( + step: "context", + fields: [ + "roleCode": roleCode, + "accountScope": accountScope ?? "", + "storageKey": key + ] + ) + HomeCommonMenuDiagnostics.log( + step: "rawTopLevel", + fields: [ + "count": String(rawTopLevel.count), + "uris": HomeCommonMenuDiagnostics.formatURIList(rawTopLevel) + ] + ) + HomeCommonMenuDiagnostics.log( + step: "availableFunctions", + fields: [ + "count": String(availableTopLevel.count), + "uris": HomeCommonMenuDiagnostics.formatURIList(availableTopLevel) + ] + ) + HomeCommonMenuDiagnostics.log( + step: "savedCommon", + fields: [ + "uris": HomeCommonMenuDiagnostics.formatURIList(saved) + ] + ) + HomeCommonMenuDiagnostics.log( + step: "defaultCommon", + fields: [ + "uris": HomeCommonMenuDiagnostics.formatURIList(defaultCommon) + ] + ) + + let finalCommon: [String] + if !saved.isEmpty { + finalCommon = saved.filter { availableTopLevelSet.contains($0) } + } else { + finalCommon = defaultCommon + save(finalCommon, roleCode: roleCode, accountScope: accountScope) } - let defaultURIs = Self.defaultCommonURIs( - orderedTopLevelURIs: topLevelPermissionURIs, - availableURIs: availableURIs + HomeCommonMenuDiagnostics.log( + step: "finalCommon", + fields: [ + "count": String(finalCommon.count), + "uris": HomeCommonMenuDiagnostics.formatURIList(finalCommon) + ] ) - save(defaultURIs, roleCode: roleCode, accountScope: accountScope) - return defaultURIs + _ = menuItems + return finalCommon } - /// 将指定 URI 加入常用应用,按别名避免重复添加。 - func add(_ uri: String, current: [String], menuItems: [HomeMenuItem], roleCode: String?, accountScope: String? = nil) -> [String] { + /// 将指定 URI 加入常用应用,使用精确 URI 匹配。 + func add( + _ uri: String, + current: [String], + topLevelPermissionURIs: [String], + roleCode: String?, + accountScope: String? = nil + ) -> [String] { guard let roleCode, !roleCode.isEmpty else { return current } - let availableURIs = Set(menuItems.map(\.uri)) - let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs) - guard Self.isAndroidHomeMenuURI(resolved), availableURIs.contains(resolved) else { return current } - guard !current.contains(where: { HomeMenuRouter.menuAliasKey(for: $0) == HomeMenuRouter.menuAliasKey(for: resolved) }) else { - return current - } - let next = current + [resolved] + let availableTopLevel = Set(Self.availableTopLevelMenuURIs(from: topLevelPermissionURIs)) + let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines) + guard availableTopLevel.contains(trimmed), !current.contains(trimmed) else { return current } + let next = current + [trimmed] save(next, roleCode: roleCode, accountScope: accountScope) return next } - /// 从常用应用中移除指定 URI,同义 URI 会一起移除。 + /// 从常用应用中移除指定 URI,使用精确 URI 匹配。 func remove(_ uri: String, current: [String], roleCode: String?, accountScope: String? = nil) -> [String] { guard let roleCode, !roleCode.isEmpty else { return current } - let aliasKey = HomeMenuRouter.menuAliasKey(for: uri) - let next = current.filter { HomeMenuRouter.menuAliasKey(for: $0) != aliasKey } + let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines) + let next = current.filter { $0 != trimmed } save(next, roleCode: roleCode, accountScope: accountScope) return next } /// 保存指定角色的常用 URI。 func save(_ uris: [String], roleCode: String, accountScope: String? = nil) { - defaults.set(Self.unique(uris), forKey: Self.storageKey(roleCode: roleCode, accountScope: accountScope)) + defaults.set(uris, forKey: Self.storageKey(roleCode: roleCode, accountScope: accountScope)) } /// 若账号级 key 无数据,则从旧 roleCode 或 legacy role id key 一次性迁移常用菜单。 @@ -170,12 +215,4 @@ struct HomeCommonMenuStore { private static func isAndroidHomeMenuURI(_ uri: String) -> Bool { androidHomeMenuURIs.contains(uri) } - - /// 按 URI 别名去重,保留首次出现的 URI。 - private static func unique(_ uris: [String]) -> [String] { - var seen = Set() - return uris.filter { uri in - seen.insert(HomeMenuRouter.menuAliasKey(for: uri)).inserted - } - } } diff --git a/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift index b25b97e..715e02c 100644 --- a/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift +++ b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift @@ -14,26 +14,20 @@ struct HomeMoreFunctionsView: View { @EnvironmentObject private var appRouter: AppRouter @EnvironmentObject private var router: RouterPath - @StateObject private var viewModel = HomeViewModel() + @State private var snapshot = HomeAllFunctionsSnapshot( + allFunctions: [], + commonFunctions: [], + moreFunctions: [] + ) @State private var commonUris: [String] = [] private let commonMenuStore = HomeCommonMenuStore() - private var commonItems: [HomeMenuItem] { - commonUris.compactMap(menuItem(for:)) - } - - private var moreItems: [HomeMenuItem] { - viewModel.menuItems.filter { item in - !isCommonURI(item.uri) - } - } - var body: some View { ScrollView { VStack(alignment: .leading, spacing: 26) { - section(title: "常用应用", items: commonItems, isCommon: true) - section(title: "更多功能", items: moreItems, isCommon: false) + section(title: "常用应用", items: snapshot.commonFunctions, isCommon: true) + section(title: "更多功能", items: snapshot.moreFunctions, isCommon: false) } .padding(.horizontal, AppMetrics.Spacing.mediumLarge) .padding(.top, AppMetrics.Spacing.medium) @@ -87,23 +81,7 @@ struct HomeMoreFunctionsView: View { .buttonStyle(.plain) Button { - let roleCode = permissionContext.currentAppRole?.rawValue - if isCommon { - commonUris = commonMenuStore.remove( - item.uri, - current: commonUris, - roleCode: roleCode, - accountScope: accountContext.accountCachePrefix - ) - } else { - commonUris = commonMenuStore.add( - item.uri, - current: commonUris, - menuItems: viewModel.menuItems, - roleCode: roleCode, - accountScope: accountContext.accountCachePrefix - ) - } + updateCommonUris(isCommon: isCommon, uri: item.uri) } label: { Image(systemName: isCommon ? "minus.circle.fill" : "plus.circle.fill") .font(.system(size: 24, weight: .bold)) @@ -134,24 +112,6 @@ struct HomeMoreFunctionsView: View { .frame(width: 34, height: 34) } - 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 func isCommonURI(_ uri: String) -> Bool { - let aliasKey = HomeMenuRouter.menuAliasKey(for: uri) - return commonUris.contains { HomeMenuRouter.menuAliasKey(for: $0) == aliasKey } - } - private func openMenu(_ item: HomeMenuItem) { switch HomeMenuRouter.resolve(uri: item.uri, title: item.title) { case .tab(let tab): @@ -174,19 +134,49 @@ struct HomeMoreFunctionsView: View { } } + /// 增删常用应用并刷新分区快照。 + private func updateCommonUris(isCommon: Bool, uri: String) { + let roleCode = permissionContext.currentAppRole?.rawValue + let topLevelURIs = permissionContext.topLevelPermissionURIs(for: roleCode) + if isCommon { + commonUris = commonMenuStore.remove( + uri, + current: commonUris, + roleCode: roleCode, + accountScope: accountContext.accountCachePrefix + ) + } else { + commonUris = commonMenuStore.add( + uri, + current: commonUris, + topLevelPermissionURIs: topLevelURIs, + roleCode: roleCode, + accountScope: accountContext.accountCachePrefix + ) + } + applySnapshot(commonURIs: commonUris) + } + private func rebuildMenus() { let roleCode = permissionContext.currentAppRole?.rawValue let legacyRoleId = permissionContext.currentRole?.id - viewModel.buildMenus( - from: permissionContext.rolePermissions, - currentRoleCode: roleCode - ) + let topLevelPermissions = permissionContext.topLevelPermissions(for: roleCode) commonUris = commonMenuStore.load( - menuItems: viewModel.menuItems, + menuItems: [], roleCode: roleCode, accountScope: accountContext.accountCachePrefix, legacyRoleId: legacyRoleId, - topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode) + topLevelPermissionURIs: topLevelPermissions.map(\.uri) + ) + applySnapshot(commonURIs: commonUris) + } + + /// 按当前顶层权限与常用 URI 重建全部功能快照。 + private func applySnapshot(commonURIs: [String]) { + let roleCode = permissionContext.currentAppRole?.rawValue + snapshot = HomeAllFunctionsBuilder.build( + topLevelPermissions: permissionContext.topLevelPermissions(for: roleCode), + commonURIs: commonURIs ) } } diff --git a/suixinkan/Features/Home/Views/HomeView.swift b/suixinkan/Features/Home/Views/HomeView.swift index cc4b5b6..2ceb0c1 100644 --- a/suixinkan/Features/Home/Views/HomeView.swift +++ b/suixinkan/Features/Home/Views/HomeView.swift @@ -456,6 +456,13 @@ struct HomeView: View { legacyRoleId: legacyRoleId, topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode) ) + let displayItems = displayMenuItems.map { ($0.title, $0.uri) } + HomeCommonMenuDiagnostics.log( + step: "displayItems", + fields: [ + "items": HomeCommonMenuDiagnostics.formatDisplayItems(displayItems) + ] + ) } /// 包裹需要 GPS 定位的位置上报操作,定位期间展示全局 Loading。 diff --git a/suixinkanTests/HomeAllFunctionsBuilderTests.swift b/suixinkanTests/HomeAllFunctionsBuilderTests.swift new file mode 100644 index 0000000..786e5b3 --- /dev/null +++ b/suixinkanTests/HomeAllFunctionsBuilderTests.swift @@ -0,0 +1,139 @@ +// +// HomeAllFunctionsBuilderTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/30. +// + +import XCTest +@testable import suixinkan + +/// 「全部功能」页菜单构建测试,覆盖 Android 对齐的过滤、顺序与拆分规则。 +final class HomeAllFunctionsBuilderTests: XCTestCase { + /// 测试仅保留顶层权限中属于 menuList 白名单的 URI。 + func testBuildFiltersToAndroidMenuListWhitelist() { + let snapshot = HomeAllFunctionsBuilder.build( + topLevelPermissions: [ + permission(name: "基本信息", uri: "basic_info"), + permission(name: "位置上报", uri: "location_report"), + permission(name: "相册管理", uri: "album_list"), + permission(name: "我的钱包", uri: "wallet") + ], + commonURIs: [] + ) + + XCTAssertEqual(snapshot.allFunctions.map(\.uri), ["location_report", "wallet"]) + } + + /// 测试保持 API 顶层顺序,不使用 preferredOrder 重排。 + func testBuildPreservesTopLevelAPIOrder() { + let snapshot = HomeAllFunctionsBuilder.build( + topLevelPermissions: [ + permission(name: "消息中心", uri: "message_center"), + permission(name: "空间设置", uri: "space_settings"), + permission(name: "我的钱包", uri: "wallet") + ], + commonURIs: [] + ) + + XCTAssertEqual(snapshot.allFunctions.map(\.uri), ["message_center", "space_settings", "wallet"]) + } + + /// 测试不会展开子权限节点。 + func testBuildIgnoresChildPermissions() { + let snapshot = HomeAllFunctionsBuilder.build( + topLevelPermissions: [ + permission( + name: "基本信息", + uri: "basic_info", + children: [permission(name: "消息中心", uri: "message_center")] + ), + permission(name: "我的钱包", uri: "wallet") + ], + commonURIs: [] + ) + + XCTAssertEqual(snapshot.allFunctions.map(\.uri), ["wallet"]) + } + + /// 测试常用/更多按精确 URI 拆分。 + func testBuildSplitsCommonAndMoreByExactURI() { + let snapshot = HomeAllFunctionsBuilder.build( + topLevelPermissions: [ + permission(name: "位置上报", uri: "location_report"), + permission(name: "注册邀请", uri: "registration_invitation"), + permission(name: "店铺管理", uri: "store"), + permission(name: "飞行管理", uri: "fly") + ], + commonURIs: ["registration_invitation", "location_report"] + ) + + XCTAssertEqual(snapshot.commonFunctions.map(\.uri), ["location_report", "registration_invitation"]) + XCTAssertEqual(snapshot.moreFunctions.map(\.uri), ["store", "fly"]) + } + + /// 测试常用区顺序跟随 allFunctions 列表顺序,而非 commonURIs 存储顺序。 + func testCommonFunctionsFollowAllFunctionsOrder() { + let snapshot = HomeAllFunctionsBuilder.build( + topLevelPermissions: [ + permission(name: "位置上报", uri: "location_report"), + permission(name: "注册邀请", uri: "registration_invitation"), + permission(name: "店铺管理", uri: "store") + ], + commonURIs: ["store", "location_report"] + ) + + XCTAssertEqual(snapshot.commonFunctions.map(\.uri), ["location_report", "store"]) + } + + /// 测试与 Android 日志样例一致的过滤结果。 + func testBuildMatchesAndroidSampleAccountShape() { + let topLevelURIs = [ + "location_report", + "basic_info", + "registration_invitation", + "store", + "fly", + "space_settings", + "photographer_stats", + "photographer_orders", + "asset_management", + "schedule_management", + "system_settings", + "message_center", + "location_info", + "checkin_points", + "sample_management", + "live_album", + "live_stream_management", + "cloud_management", + "cooperation_order", + "scan_qr", + "payment_qr", + "wallet", + "pilot_cert", + "task_management_editor", + "pm_manager", + "pilot_controller", + "travel_album" + ] + let snapshot = HomeAllFunctionsBuilder.build( + topLevelPermissions: topLevelURIs.map { permission(name: $0, uri: $0) }, + commonURIs: ["location_report", "registration_invitation", "store"] + ) + + XCTAssertEqual(snapshot.allFunctions.count, 20) + XCTAssertEqual(snapshot.allFunctions.first?.uri, "location_report") + XCTAssertEqual(snapshot.commonFunctions.map(\.uri), ["location_report", "registration_invitation", "store"]) + XCTAssertEqual(snapshot.moreFunctions.first?.uri, "fly") + XCTAssertEqual(snapshot.moreFunctions.count, 17) + } + + private func permission( + name: String, + uri: String, + children: [PermissionItem] = [] + ) -> PermissionItem { + PermissionItem(id: uri.hashValue, name: name, uri: uri, children: children) + } +} diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift index db8ea67..7b79159 100644 --- a/suixinkanTests/HomeCommonMenuStoreTests.swift +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -8,7 +8,7 @@ import XCTest @testable import suixinkan -/// 首页常用应用存储测试,覆盖默认值、过滤和别名去重。 +/// 首页常用应用存储测试,覆盖默认值、过滤和 Android 对齐行为。 final class HomeCommonMenuStoreTests: XCTestCase { /// 测试首次无配置时会按顶层权限生成默认常用应用。 func testLoadCreatesDefaultCommonMenusFromTopLevelPermissions() { @@ -36,13 +36,6 @@ final class HomeCommonMenuStoreTests: XCTestCase { /// 测试顶层权限超过 4 个时只取前 4 个默认常用应用。 func testDefaultCommonURIsTakeFirstFourWhenMoreThanFourPermissions() { - let availableURIs: Set = [ - "space_settings", - "wallet", - "cloud_management", - "message_center", - "task_management" - ] let uris = HomeCommonMenuStore.defaultCommonURIs( orderedTopLevelURIs: [ "space_settings", @@ -50,8 +43,7 @@ final class HomeCommonMenuStoreTests: XCTestCase { "cloud_management", "message_center", "task_management" - ], - availableURIs: availableURIs + ] ) XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management", "message_center"]) @@ -76,10 +68,10 @@ final class HomeCommonMenuStoreTests: XCTestCase { XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management"]) } - /// 测试已保存 URI 会按当前权限和 Android 白名单过滤并解析同义 URI。 + /// 测试已保存 URI 会按当前顶层权限和白名单精确过滤。 func testLoadFiltersSavedUrisByCurrentPermissions() { let defaults = makeIsolatedDefaults() - defaults.set(["task_management", "unknown", "pm"], forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer") + defaults.set(["task_management_editor", "unknown", "pm"], forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer") let store = HomeCommonMenuStore(defaults: defaults) let menus = [ menu("task_management_editor"), @@ -95,6 +87,26 @@ final class HomeCommonMenuStoreTests: XCTestCase { XCTAssertEqual(uris, ["task_management_editor"]) } + /// 测试已保存 URI 与当前权限不匹配时不会回退默认。 + func testSavedCommonDoesNotFallbackWhenAllInvalid() { + let defaults = makeIsolatedDefaults() + defaults.set(["task_management", "unknown"], forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer") + let store = HomeCommonMenuStore(defaults: defaults) + let menus = [menu("task_management_editor")] + + let uris = store.load( + menuItems: menus, + roleCode: "photographer", + topLevelPermissionURIs: ["task_management_editor"] + ) + + XCTAssertTrue(uris.isEmpty) + XCTAssertEqual( + defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer"), + ["task_management", "unknown"] + ) + } + /// 测试同一角色不同账号使用独立的常用应用缓存。 func testLoadUsesAccountScopedStorageKeys() { let defaults = makeIsolatedDefaults() @@ -155,32 +167,29 @@ final class HomeCommonMenuStoreTests: XCTestCase { XCTAssertEqual(roleTwo, ["cloud_management"]) } - /// 测试添加常用应用时会按同义 URI 去重。 - func testAddDeduplicatesAliases() { + /// 测试添加常用应用时使用精确 URI 匹配。 + func testAddUsesExactURIMatching() { let defaults = makeIsolatedDefaults() let store = HomeCommonMenuStore(defaults: defaults) - let menus = [ - menu("task_management_editor") - ] let uris = store.add( "task_management", current: ["task_management_editor"], - menuItems: menus, + topLevelPermissionURIs: ["task_management_editor"], roleCode: "photographer" ) XCTAssertEqual(uris, ["task_management_editor"]) } - /// 测试移除常用应用时会移除同义 URI。 - func testRemoveDeletesAliases() { + /// 测试移除常用应用时使用精确 URI 匹配。 + func testRemoveUsesExactURIMatching() { let defaults = makeIsolatedDefaults() let store = HomeCommonMenuStore(defaults: defaults) let uris = store.remove("pm", current: ["project_edit", "pm_manager", "location_report"], roleCode: "photographer") - XCTAssertEqual(uris, ["pm_manager", "location_report"]) + XCTAssertEqual(uris, ["project_edit", "pm_manager", "location_report"]) } /// 测试无 role_code 时不读写常用应用。 @@ -239,7 +248,7 @@ final class HomeCommonMenuStoreTests: XCTestCase { XCTAssertNil(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer")) } - /// 测试 Android 白名单外的 URI 不会进入默认常用应用。 + /// 测试默认常用先取顶层前 4 个,再与 menuList 白名单求交。 func testDefaultCommonURIsExcludeMenusMissingFromAndroidCatalog() { let defaults = makeIsolatedDefaults() let store = HomeCommonMenuStore(defaults: defaults) @@ -260,6 +269,21 @@ final class HomeCommonMenuStoreTests: XCTestCase { XCTAssertEqual(uris, ["wallet", "message_center"]) } + /// 测试非白名单顶层项占用前 4 名额时,默认常用不会用后续白名单项补位。 + func testDefaultCommonDoesNotBackfillAfterNonCatalogSlots() { + let uris = HomeCommonMenuStore.defaultCommonURIs( + orderedTopLevelURIs: [ + "album_list", + "wallet", + "cloud_management", + "asset_management", + "task_management" + ] + ) + + XCTAssertEqual(uris, ["wallet", "cloud_management", "asset_management"]) + } + /// 测试已保存常用应用超过 4 个时只过滤权限,不会被强制截断。 func testSavedCommonURIsAreNotTruncatedToFour() { let defaults = makeIsolatedDefaults() @@ -286,8 +310,8 @@ final class HomeCommonMenuStoreTests: XCTestCase { XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management", "asset_management", "message_center"]) } - /// 测试默认常用只取顶层权限,不会用子权限补位。 - func testDefaultCommonURIsUseOnlyTopLevelPermissions() { + /// 测试默认常用只取顶层权限,不会用扁平化子权限补位。 + func testDefaultCommonUsesTopLevelOnlyNotFlattenedChildren() { let defaults = makeIsolatedDefaults() let store = HomeCommonMenuStore(defaults: defaults) let menus = [ @@ -305,6 +329,25 @@ final class HomeCommonMenuStoreTests: XCTestCase { XCTAssertEqual(uris, ["task_management"]) } + /// 测试扁平化 menuItems 中的子权限不会扩大可用常用范围。 + func testAvailableFunctionsIgnoreFlattenedChildPermissions() { + let defaults = makeIsolatedDefaults() + let store = HomeCommonMenuStore(defaults: defaults) + let menus = [ + menu("wallet"), + menu("material_upload") + ] + + let uris = store.load( + menuItems: menus, + roleCode: "photographer", + accountScope: "scenic_user_101", + topLevelPermissionURIs: ["wallet"] + ) + + XCTAssertEqual(uris, ["wallet"]) + } + /// 创建测试菜单实体。 private func menu(_ uri: String) -> HomeMenuItem { HomeMenuItem(title: HomeMenuRouter.title(for: uri), uri: uri, iconSrc: nil)