Align iOS home common apps and all-functions page with Android.
Add unified HomeCommonMenu/HomeAllFunctions diagnostics, rebuild all-functions from top-level menuList permissions, and document Home and AllFunctions module logic. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -74,13 +74,17 @@ final class PermissionContext: ObservableObject {
|
|||||||
|
|
||||||
/// 返回指定角色的顶层权限 URI 列表,顺序与 API 返回一致。
|
/// 返回指定角色的顶层权限 URI 列表,顺序与 API 返回一致。
|
||||||
func topLevelPermissionURIs(for roleCode: String?) -> [String] {
|
func topLevelPermissionURIs(for roleCode: String?) -> [String] {
|
||||||
|
topLevelPermissions(for: roleCode).map(\.uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 返回指定角色的顶层权限节点,顺序与 API 返回一致。
|
||||||
|
func topLevelPermissions(for roleCode: String?) -> [PermissionItem] {
|
||||||
guard let roleCode,
|
guard let roleCode,
|
||||||
let rolePermission = matchedRolePermission(roleCode: roleCode) else {
|
let rolePermission = matchedRolePermission(roleCode: roleCode) else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
return rolePermission.role.permission.compactMap { item in
|
return rolePermission.role.permission.filter { item in
|
||||||
let uri = item.uri.trimmingCharacters(in: .whitespacesAndNewlines)
|
!item.uri.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
return uri.isEmpty ? nil : uri
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
209
suixinkan/Features/Home/AllFunctions.md
Normal file
209
suixinkan/Features/Home/AllFunctions.md
Normal file
@ -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 更多)一致
|
||||||
@ -1,5 +1,7 @@
|
|||||||
# Home 模块业务逻辑
|
# Home 模块业务逻辑
|
||||||
|
|
||||||
|
Android 工程对应文档:[`zhiflyfollow/docs/home/Home.md`](../../../zhiflyfollow/docs/home/Home.md)
|
||||||
|
|
||||||
## 模块职责
|
## 模块职责
|
||||||
|
|
||||||
Home 模块负责登录后的首页工作台,包括当前景区展示、工作状态、位置上报入口、快捷操作、常用应用和全部功能入口。
|
Home 模块负责登录后的首页工作台,包括当前景区展示、工作状态、位置上报入口、快捷操作、常用应用和全部功能入口。
|
||||||
@ -18,14 +20,15 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
|||||||
|
|
||||||
## 常用应用
|
## 常用应用
|
||||||
|
|
||||||
`HomeCommonMenuStore` 使用 UserDefaults 按账号和角色保存常用应用 URI:
|
`HomeCommonMenuStore` 使用 UserDefaults 按账号和角色保存常用应用 URI,算法与 Android `AllFunctionsViewModel` 对齐:
|
||||||
- 当前版本使用账号 + 角色作用域保存常用应用 URI,避免不同业务账号的同一角色互相覆盖;旧版角色 key 会在首次读取时迁移。
|
- 当前版本使用账号 + 角色作用域保存常用应用 URI,避免不同业务账号的同一角色互相覆盖;旧版角色 key 会在首次读取时迁移。
|
||||||
- 首次无配置时,按 Android 规则从当前角色顶层权限 URI 生成默认值:权限多于 4 个取前 4 个,否则全部使用。
|
- 可用菜单范围:仅**顶层权限** ∩ Android `Constants.menuList` 白名单(`androidHomeMenuURIs`),不使用扁平化子权限。
|
||||||
- 首页常用应用按 Android `Constants.menuList` 白名单过滤;iOS 已接入但 Android 首页未登记的 URI 不进入首页常用应用。
|
- 首次无配置时:先取顶层权限前 4 个 URI(原始 API 顺序),再与 menuList 白名单求交后落库。
|
||||||
- 读取时按当前角色权限过滤不可用 URI。
|
- 已有 saved 配置时:精确 URI 匹配过滤;全部失效时返回空,**不回退**默认生成。
|
||||||
- 添加和移除时按同义 URI 去重。
|
- 添加/移除常用应用使用精确 URI 匹配(与 Android 一致)。
|
||||||
- 不同角色的常用应用互不影响。
|
- 不同角色的常用应用互不影响。
|
||||||
- 升级时会从旧版 `role.{roleId}` key 一次性迁移到 `role.{roleCode}`。
|
- 升级时会从旧版 `role.{roleId}` key 一次性迁移到 `role.{roleCode}`。
|
||||||
|
- Debug 构建下可通过 `HomeCommonMenu` 日志 tag 与 Android 端逐步对比(`context` / `rawTopLevel` / `availableFunctions` / `savedCommon` / `defaultCommon` / `finalCommon` / `displayItems`)。
|
||||||
|
|
||||||
## 路由规则
|
## 路由规则
|
||||||
|
|
||||||
@ -39,6 +42,10 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
|||||||
|
|
||||||
`HomeMoreFunctionsView` 使用系统导航栏展示标题和返回,以支持左边缘滑动返回。
|
`HomeMoreFunctionsView` 使用系统导航栏展示标题和返回,以支持左边缘滑动返回。
|
||||||
|
|
||||||
|
## 全部功能
|
||||||
|
|
||||||
|
「全部功能」页的数据、筛选、展示逻辑见独立文档 [`AllFunctions.md`](AllFunctions.md)。
|
||||||
|
|
||||||
## 位置上报
|
## 位置上报
|
||||||
|
|
||||||
首页工作状态区域由 `HomeLocationViewModel` 驱动,与 `LocationReport` 详情页共享同一实例(在 `RootView` 注入)。
|
首页工作状态区域由 `HomeLocationViewModel` 驱动,与 `LocationReport` 详情页共享同一实例(在 `RootView` 注入)。
|
||||||
|
|||||||
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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: ","))]"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -49,25 +49,26 @@ struct HomeCommonMenuStore {
|
|||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按 Android 规则生成默认常用 URI:顶层权限顺序、可用权限过滤、别名去重,超过 4 个取前 4 个。
|
/// 按 Android 规则生成默认常用 URI:先取顶层前 4 个 URI,再与 menuList 白名单求交。
|
||||||
static func defaultCommonURIs(
|
static func defaultCommonURIs(orderedTopLevelURIs: [String]) -> [String] {
|
||||||
orderedTopLevelURIs: [String],
|
let rawTopLevel = orderedTopLevelURIs.compactMap { uri -> String? in
|
||||||
availableURIs: Set<String>
|
|
||||||
) -> [String] {
|
|
||||||
let resolved = orderedTopLevelURIs.compactMap { uri -> String? in
|
|
||||||
let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return nil }
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
let canonical = HomeMenuRouter.canonicalURI(for: trimmed, availableURIs: availableURIs)
|
|
||||||
return Self.isAndroidHomeMenuURI(canonical) && availableURIs.contains(canonical) ? canonical : nil
|
|
||||||
}
|
}
|
||||||
let deduplicated = unique(resolved)
|
let candidates = rawTopLevel.count > 4 ? Array(rawTopLevel.prefix(4)) : rawTopLevel
|
||||||
if deduplicated.count > 4 {
|
return candidates.filter { isAndroidHomeMenuURI($0) }
|
||||||
return Array(deduplicated.prefix(4))
|
|
||||||
}
|
|
||||||
return deduplicated
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取常用 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(
|
func load(
|
||||||
menuItems: [HomeMenuItem],
|
menuItems: [HomeMenuItem],
|
||||||
roleCode: String?,
|
roleCode: String?,
|
||||||
@ -78,55 +79,99 @@ struct HomeCommonMenuStore {
|
|||||||
guard let roleCode, !roleCode.isEmpty else { return [] }
|
guard let roleCode, !roleCode.isEmpty else { return [] }
|
||||||
migrateLegacyStorageIfNeeded(roleCode: roleCode, accountScope: accountScope, legacyRoleId: legacyRoleId)
|
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 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 {
|
HomeCommonMenuDiagnostics.log(
|
||||||
let filtered = Self.unique(saved.compactMap { uri -> String? in
|
step: "context",
|
||||||
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
fields: [
|
||||||
return Self.isAndroidHomeMenuURI(resolved) && availableURIs.contains(resolved) ? resolved : nil
|
"roleCode": roleCode,
|
||||||
})
|
"accountScope": accountScope ?? "",
|
||||||
if !filtered.isEmpty {
|
"storageKey": key
|
||||||
save(filtered, roleCode: roleCode, accountScope: accountScope)
|
]
|
||||||
return filtered
|
)
|
||||||
}
|
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(
|
HomeCommonMenuDiagnostics.log(
|
||||||
orderedTopLevelURIs: topLevelPermissionURIs,
|
step: "finalCommon",
|
||||||
availableURIs: availableURIs
|
fields: [
|
||||||
|
"count": String(finalCommon.count),
|
||||||
|
"uris": HomeCommonMenuDiagnostics.formatURIList(finalCommon)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
save(defaultURIs, roleCode: roleCode, accountScope: accountScope)
|
_ = menuItems
|
||||||
return defaultURIs
|
return finalCommon
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将指定 URI 加入常用应用,按别名避免重复添加。
|
/// 将指定 URI 加入常用应用,使用精确 URI 匹配。
|
||||||
func add(_ uri: String, current: [String], menuItems: [HomeMenuItem], roleCode: String?, accountScope: String? = nil) -> [String] {
|
func add(
|
||||||
|
_ uri: String,
|
||||||
|
current: [String],
|
||||||
|
topLevelPermissionURIs: [String],
|
||||||
|
roleCode: String?,
|
||||||
|
accountScope: String? = nil
|
||||||
|
) -> [String] {
|
||||||
guard let roleCode, !roleCode.isEmpty else { return current }
|
guard let roleCode, !roleCode.isEmpty else { return current }
|
||||||
let availableURIs = Set(menuItems.map(\.uri))
|
let availableTopLevel = Set(Self.availableTopLevelMenuURIs(from: topLevelPermissionURIs))
|
||||||
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard Self.isAndroidHomeMenuURI(resolved), availableURIs.contains(resolved) else { return current }
|
guard availableTopLevel.contains(trimmed), !current.contains(trimmed) else { return current }
|
||||||
guard !current.contains(where: { HomeMenuRouter.menuAliasKey(for: $0) == HomeMenuRouter.menuAliasKey(for: resolved) }) else {
|
let next = current + [trimmed]
|
||||||
return current
|
|
||||||
}
|
|
||||||
let next = current + [resolved]
|
|
||||||
save(next, roleCode: roleCode, accountScope: accountScope)
|
save(next, roleCode: roleCode, accountScope: accountScope)
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从常用应用中移除指定 URI,同义 URI 会一起移除。
|
/// 从常用应用中移除指定 URI,使用精确 URI 匹配。
|
||||||
func remove(_ uri: String, current: [String], roleCode: String?, accountScope: String? = nil) -> [String] {
|
func remove(_ uri: String, current: [String], roleCode: String?, accountScope: String? = nil) -> [String] {
|
||||||
guard let roleCode, !roleCode.isEmpty else { return current }
|
guard let roleCode, !roleCode.isEmpty else { return current }
|
||||||
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
|
let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let next = current.filter { HomeMenuRouter.menuAliasKey(for: $0) != aliasKey }
|
let next = current.filter { $0 != trimmed }
|
||||||
save(next, roleCode: roleCode, accountScope: accountScope)
|
save(next, roleCode: roleCode, accountScope: accountScope)
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存指定角色的常用 URI。
|
/// 保存指定角色的常用 URI。
|
||||||
func save(_ uris: [String], roleCode: String, accountScope: String? = nil) {
|
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 一次性迁移常用菜单。
|
/// 若账号级 key 无数据,则从旧 roleCode 或 legacy role id key 一次性迁移常用菜单。
|
||||||
@ -170,12 +215,4 @@ struct HomeCommonMenuStore {
|
|||||||
private static func isAndroidHomeMenuURI(_ uri: String) -> Bool {
|
private static func isAndroidHomeMenuURI(_ uri: String) -> Bool {
|
||||||
androidHomeMenuURIs.contains(uri)
|
androidHomeMenuURIs.contains(uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按 URI 别名去重,保留首次出现的 URI。
|
|
||||||
private static func unique(_ uris: [String]) -> [String] {
|
|
||||||
var seen = Set<String>()
|
|
||||||
return uris.filter { uri in
|
|
||||||
seen.insert(HomeMenuRouter.menuAliasKey(for: uri)).inserted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,26 +14,20 @@ struct HomeMoreFunctionsView: View {
|
|||||||
@EnvironmentObject private var appRouter: AppRouter
|
@EnvironmentObject private var appRouter: AppRouter
|
||||||
@EnvironmentObject private var router: RouterPath
|
@EnvironmentObject private var router: RouterPath
|
||||||
|
|
||||||
@StateObject private var viewModel = HomeViewModel()
|
@State private var snapshot = HomeAllFunctionsSnapshot(
|
||||||
|
allFunctions: [],
|
||||||
|
commonFunctions: [],
|
||||||
|
moreFunctions: []
|
||||||
|
)
|
||||||
@State private var commonUris: [String] = []
|
@State private var commonUris: [String] = []
|
||||||
|
|
||||||
private let commonMenuStore = HomeCommonMenuStore()
|
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 {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 26) {
|
VStack(alignment: .leading, spacing: 26) {
|
||||||
section(title: "常用应用", items: commonItems, isCommon: true)
|
section(title: "常用应用", items: snapshot.commonFunctions, isCommon: true)
|
||||||
section(title: "更多功能", items: moreItems, isCommon: false)
|
section(title: "更多功能", items: snapshot.moreFunctions, isCommon: false)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, AppMetrics.Spacing.mediumLarge)
|
.padding(.horizontal, AppMetrics.Spacing.mediumLarge)
|
||||||
.padding(.top, AppMetrics.Spacing.medium)
|
.padding(.top, AppMetrics.Spacing.medium)
|
||||||
@ -87,23 +81,7 @@ struct HomeMoreFunctionsView: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
let roleCode = permissionContext.currentAppRole?.rawValue
|
updateCommonUris(isCommon: isCommon, uri: item.uri)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: isCommon ? "minus.circle.fill" : "plus.circle.fill")
|
Image(systemName: isCommon ? "minus.circle.fill" : "plus.circle.fill")
|
||||||
.font(.system(size: 24, weight: .bold))
|
.font(.system(size: 24, weight: .bold))
|
||||||
@ -134,24 +112,6 @@ struct HomeMoreFunctionsView: View {
|
|||||||
.frame(width: 34, height: 34)
|
.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) {
|
private func openMenu(_ item: HomeMenuItem) {
|
||||||
switch HomeMenuRouter.resolve(uri: item.uri, title: item.title) {
|
switch HomeMenuRouter.resolve(uri: item.uri, title: item.title) {
|
||||||
case .tab(let tab):
|
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() {
|
private func rebuildMenus() {
|
||||||
let roleCode = permissionContext.currentAppRole?.rawValue
|
let roleCode = permissionContext.currentAppRole?.rawValue
|
||||||
let legacyRoleId = permissionContext.currentRole?.id
|
let legacyRoleId = permissionContext.currentRole?.id
|
||||||
viewModel.buildMenus(
|
let topLevelPermissions = permissionContext.topLevelPermissions(for: roleCode)
|
||||||
from: permissionContext.rolePermissions,
|
|
||||||
currentRoleCode: roleCode
|
|
||||||
)
|
|
||||||
commonUris = commonMenuStore.load(
|
commonUris = commonMenuStore.load(
|
||||||
menuItems: viewModel.menuItems,
|
menuItems: [],
|
||||||
roleCode: roleCode,
|
roleCode: roleCode,
|
||||||
accountScope: accountContext.accountCachePrefix,
|
accountScope: accountContext.accountCachePrefix,
|
||||||
legacyRoleId: legacyRoleId,
|
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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -456,6 +456,13 @@ struct HomeView: View {
|
|||||||
legacyRoleId: legacyRoleId,
|
legacyRoleId: legacyRoleId,
|
||||||
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
|
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
|
||||||
)
|
)
|
||||||
|
let displayItems = displayMenuItems.map { ($0.title, $0.uri) }
|
||||||
|
HomeCommonMenuDiagnostics.log(
|
||||||
|
step: "displayItems",
|
||||||
|
fields: [
|
||||||
|
"items": HomeCommonMenuDiagnostics.formatDisplayItems(displayItems)
|
||||||
|
]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 包裹需要 GPS 定位的位置上报操作,定位期间展示全局 Loading。
|
/// 包裹需要 GPS 定位的位置上报操作,定位期间展示全局 Loading。
|
||||||
|
|||||||
139
suixinkanTests/HomeAllFunctionsBuilderTests.swift
Normal file
139
suixinkanTests/HomeAllFunctionsBuilderTests.swift
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,7 +8,7 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
@testable import suixinkan
|
@testable import suixinkan
|
||||||
|
|
||||||
/// 首页常用应用存储测试,覆盖默认值、过滤和别名去重。
|
/// 首页常用应用存储测试,覆盖默认值、过滤和 Android 对齐行为。
|
||||||
final class HomeCommonMenuStoreTests: XCTestCase {
|
final class HomeCommonMenuStoreTests: XCTestCase {
|
||||||
/// 测试首次无配置时会按顶层权限生成默认常用应用。
|
/// 测试首次无配置时会按顶层权限生成默认常用应用。
|
||||||
func testLoadCreatesDefaultCommonMenusFromTopLevelPermissions() {
|
func testLoadCreatesDefaultCommonMenusFromTopLevelPermissions() {
|
||||||
@ -36,13 +36,6 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
|
|
||||||
/// 测试顶层权限超过 4 个时只取前 4 个默认常用应用。
|
/// 测试顶层权限超过 4 个时只取前 4 个默认常用应用。
|
||||||
func testDefaultCommonURIsTakeFirstFourWhenMoreThanFourPermissions() {
|
func testDefaultCommonURIsTakeFirstFourWhenMoreThanFourPermissions() {
|
||||||
let availableURIs: Set<String> = [
|
|
||||||
"space_settings",
|
|
||||||
"wallet",
|
|
||||||
"cloud_management",
|
|
||||||
"message_center",
|
|
||||||
"task_management"
|
|
||||||
]
|
|
||||||
let uris = HomeCommonMenuStore.defaultCommonURIs(
|
let uris = HomeCommonMenuStore.defaultCommonURIs(
|
||||||
orderedTopLevelURIs: [
|
orderedTopLevelURIs: [
|
||||||
"space_settings",
|
"space_settings",
|
||||||
@ -50,8 +43,7 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
"cloud_management",
|
"cloud_management",
|
||||||
"message_center",
|
"message_center",
|
||||||
"task_management"
|
"task_management"
|
||||||
],
|
]
|
||||||
availableURIs: availableURIs
|
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management", "message_center"])
|
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management", "message_center"])
|
||||||
@ -76,10 +68,10 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management"])
|
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management"])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试已保存 URI 会按当前权限和 Android 白名单过滤并解析同义 URI。
|
/// 测试已保存 URI 会按当前顶层权限和白名单精确过滤。
|
||||||
func testLoadFiltersSavedUrisByCurrentPermissions() {
|
func testLoadFiltersSavedUrisByCurrentPermissions() {
|
||||||
let defaults = makeIsolatedDefaults()
|
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 store = HomeCommonMenuStore(defaults: defaults)
|
||||||
let menus = [
|
let menus = [
|
||||||
menu("task_management_editor"),
|
menu("task_management_editor"),
|
||||||
@ -95,6 +87,26 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
XCTAssertEqual(uris, ["task_management_editor"])
|
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() {
|
func testLoadUsesAccountScopedStorageKeys() {
|
||||||
let defaults = makeIsolatedDefaults()
|
let defaults = makeIsolatedDefaults()
|
||||||
@ -155,32 +167,29 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
XCTAssertEqual(roleTwo, ["cloud_management"])
|
XCTAssertEqual(roleTwo, ["cloud_management"])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试添加常用应用时会按同义 URI 去重。
|
/// 测试添加常用应用时使用精确 URI 匹配。
|
||||||
func testAddDeduplicatesAliases() {
|
func testAddUsesExactURIMatching() {
|
||||||
let defaults = makeIsolatedDefaults()
|
let defaults = makeIsolatedDefaults()
|
||||||
let store = HomeCommonMenuStore(defaults: defaults)
|
let store = HomeCommonMenuStore(defaults: defaults)
|
||||||
let menus = [
|
|
||||||
menu("task_management_editor")
|
|
||||||
]
|
|
||||||
|
|
||||||
let uris = store.add(
|
let uris = store.add(
|
||||||
"task_management",
|
"task_management",
|
||||||
current: ["task_management_editor"],
|
current: ["task_management_editor"],
|
||||||
menuItems: menus,
|
topLevelPermissionURIs: ["task_management_editor"],
|
||||||
roleCode: "photographer"
|
roleCode: "photographer"
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(uris, ["task_management_editor"])
|
XCTAssertEqual(uris, ["task_management_editor"])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试移除常用应用时会移除同义 URI。
|
/// 测试移除常用应用时使用精确 URI 匹配。
|
||||||
func testRemoveDeletesAliases() {
|
func testRemoveUsesExactURIMatching() {
|
||||||
let defaults = makeIsolatedDefaults()
|
let defaults = makeIsolatedDefaults()
|
||||||
let store = HomeCommonMenuStore(defaults: defaults)
|
let store = HomeCommonMenuStore(defaults: defaults)
|
||||||
|
|
||||||
let uris = store.remove("pm", current: ["project_edit", "pm_manager", "location_report"], roleCode: "photographer")
|
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 时不读写常用应用。
|
/// 测试无 role_code 时不读写常用应用。
|
||||||
@ -239,7 +248,7 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
XCTAssertNil(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer"))
|
XCTAssertNil(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试 Android 白名单外的 URI 不会进入默认常用应用。
|
/// 测试默认常用先取顶层前 4 个,再与 menuList 白名单求交。
|
||||||
func testDefaultCommonURIsExcludeMenusMissingFromAndroidCatalog() {
|
func testDefaultCommonURIsExcludeMenusMissingFromAndroidCatalog() {
|
||||||
let defaults = makeIsolatedDefaults()
|
let defaults = makeIsolatedDefaults()
|
||||||
let store = HomeCommonMenuStore(defaults: defaults)
|
let store = HomeCommonMenuStore(defaults: defaults)
|
||||||
@ -260,6 +269,21 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
XCTAssertEqual(uris, ["wallet", "message_center"])
|
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 个时只过滤权限,不会被强制截断。
|
/// 测试已保存常用应用超过 4 个时只过滤权限,不会被强制截断。
|
||||||
func testSavedCommonURIsAreNotTruncatedToFour() {
|
func testSavedCommonURIsAreNotTruncatedToFour() {
|
||||||
let defaults = makeIsolatedDefaults()
|
let defaults = makeIsolatedDefaults()
|
||||||
@ -286,8 +310,8 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management", "asset_management", "message_center"])
|
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management", "asset_management", "message_center"])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试默认常用只取顶层权限,不会用子权限补位。
|
/// 测试默认常用只取顶层权限,不会用扁平化子权限补位。
|
||||||
func testDefaultCommonURIsUseOnlyTopLevelPermissions() {
|
func testDefaultCommonUsesTopLevelOnlyNotFlattenedChildren() {
|
||||||
let defaults = makeIsolatedDefaults()
|
let defaults = makeIsolatedDefaults()
|
||||||
let store = HomeCommonMenuStore(defaults: defaults)
|
let store = HomeCommonMenuStore(defaults: defaults)
|
||||||
let menus = [
|
let menus = [
|
||||||
@ -305,6 +329,25 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
|||||||
XCTAssertEqual(uris, ["task_management"])
|
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 {
|
private func menu(_ uri: String) -> HomeMenuItem {
|
||||||
HomeMenuItem(title: HomeMenuRouter.title(for: uri), uri: uri, iconSrc: nil)
|
HomeMenuItem(title: HomeMenuRouter.title(for: uri), uri: uri, iconSrc: nil)
|
||||||
|
|||||||
Reference in New Issue
Block a user