迁移合作订单完整能力,并统一 AppRoleCode 角色权限与首页菜单展示。
新增 CooperationOrder 模块(双 Tab 列表、获客员绑定、主 Tab 扫码)、订单来源/带客单绑定及配套 API 测试;同步引入 roleCode 权限匹配与相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -9,17 +9,21 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
||||
## 菜单生成
|
||||
|
||||
`HomeViewModel` 从当前角色的权限树递归提取菜单:
|
||||
- 当前角色 ID 存在但找不到时清空菜单。
|
||||
- 当前角色 ID 为空时使用第一个角色。
|
||||
- 当前 `role_code` 存在但找不到时清空菜单。
|
||||
- 当前 `role_code` 为空时使用第一个角色。
|
||||
- URI 按旧工程顺序排序。
|
||||
- 同义 URI 按 `HomeMenuRouter.menuAliasKey` 去重。
|
||||
|
||||
角色 UI 判断使用 `PermissionContext.currentAppRole`(`AppRoleCode` 枚举),例如店长 `isStoreAdmin`、首页隐藏工作状态 `homeMinimalTopRoles`。
|
||||
|
||||
## 常用应用
|
||||
|
||||
`HomeCommonMenuStore` 使用 UserDefaults 保存常用应用 URI:
|
||||
- 首次无配置时写入默认常用应用。
|
||||
`HomeCommonMenuStore` 使用 UserDefaults 按角色保存常用应用 URI(key:`home.common.menu.uris.role.{roleCode}`):
|
||||
- 首次无配置时,按 Android 规则从当前角色顶层权限 URI 生成默认值:权限多于 4 个取前 4 个,否则全部使用。
|
||||
- 读取时按当前角色权限过滤不可用 URI。
|
||||
- 添加和移除时按同义 URI 去重。
|
||||
- 不同角色的常用应用互不影响。
|
||||
- 升级时会从旧版 `role.{roleId}` key 一次性迁移到 `role.{roleCode}`。
|
||||
|
||||
## 路由规则
|
||||
|
||||
@ -33,6 +37,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
|
||||
|
||||
## 后续迁移
|
||||
|
||||
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管,`pm`、`project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation`、`photographer_invite`、`invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement` 和 `scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue` 和 `queue_management` 已由 `Features/QueueManagement` 接管,`live_stream_management` 和 `live_album` 已由 `Features/Live` 接管,`operating-area` 已由 `Features/OperatingArea` 接管,`pilot_cert` 已由 `Features/PilotCertification` 接管。
|
||||
目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`cooperation_order` 已由 `Features/CooperationOrder` 接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管,`pm`、`project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation`、`photographer_invite`、`invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement` 和 `scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue` 和 `queue_management` 已由 `Features/QueueManagement` 接管,`live_stream_management` 和 `live_album` 已由 `Features/Live` 接管,`operating-area` 已由 `Features/OperatingArea` 接管,`pilot_cert` 已由 `Features/PilotCertification` 接管。
|
||||
|
||||
后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。
|
||||
|
||||
@ -56,6 +56,7 @@ enum HomeRoute: Hashable {
|
||||
case locationReportHistory
|
||||
case depositOrders
|
||||
case withdrawalAudit
|
||||
case cooperationOrder
|
||||
case scenicSettlement
|
||||
case scenicSettlementReview
|
||||
case messageCenter
|
||||
|
||||
@ -60,6 +60,7 @@ enum HomeMenuRouter {
|
||||
"deposit_order": "押金订单详情",
|
||||
"deposit_order_shooting_info": "押金拍摄信息",
|
||||
"withdrawal_audit": "提现审核",
|
||||
"cooperation_order": "合作订单",
|
||||
"location_report_history": "定位上报历史",
|
||||
"photographer_invite": "邀请摄影师",
|
||||
"invite_record": "邀请记录",
|
||||
@ -131,6 +132,8 @@ enum HomeMenuRouter {
|
||||
return .destination(.depositOrders)
|
||||
case "withdrawal_audit":
|
||||
return .destination(.withdrawalAudit)
|
||||
case "cooperation_order":
|
||||
return .destination(.cooperationOrder)
|
||||
case "scenic_settlement":
|
||||
return .destination(.scenicSettlement)
|
||||
case "scenic_settlement_review":
|
||||
@ -199,6 +202,7 @@ enum HomeMenuRouter {
|
||||
"scenic_settlement",
|
||||
"scenic_settlement_review",
|
||||
"verification_order",
|
||||
"cooperation_order",
|
||||
"photographer_orders",
|
||||
"/scenic-order-manage",
|
||||
"photographer_stats",
|
||||
@ -301,10 +305,15 @@ enum HomeMenuRouter {
|
||||
/// 首页权限路由审计器,用于识别权限里哪些 URI 尚未映射。
|
||||
enum HomePermissionRouteAuditor {
|
||||
/// 扫描当前角色权限并分类统计可路由、已知不支持和未知 URI。
|
||||
static func audit(permissions: [RolePermissionResponse], currentRoleId: Int?) -> HomePermissionRouteAudit {
|
||||
static func audit(permissions: [RolePermissionResponse], currentRoleCode: String?) -> HomePermissionRouteAudit {
|
||||
let source: RolePermissionResponse?
|
||||
if let currentRoleId {
|
||||
source = permissions.first { $0.role.id == currentRoleId }
|
||||
if let currentRoleCode, !currentRoleCode.isEmpty {
|
||||
source = RolePermissionMatching.match(
|
||||
in: permissions,
|
||||
roleCode: currentRoleCode,
|
||||
roleId: nil,
|
||||
roleName: nil
|
||||
)
|
||||
} else {
|
||||
source = permissions.first
|
||||
}
|
||||
|
||||
@ -7,47 +7,71 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 首页常用应用存储服务,负责按当前权限过滤和持久化常用 URI。
|
||||
/// 首页常用应用存储服务,负责按当前角色权限过滤和持久化常用 URI。
|
||||
struct HomeCommonMenuStore {
|
||||
static let defaultStorageKey = "home.common.menu.uris"
|
||||
static let defaultBaselineKey = "home.common.menu.android.baseline.v2"
|
||||
static let storageKeyPrefix = "home.common.menu.uris.role."
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let storageKey: String
|
||||
private let baselineKey: String
|
||||
|
||||
/// 初始化常用应用存储服务,并允许测试注入独立 UserDefaults。
|
||||
init(
|
||||
defaults: UserDefaults = .standard,
|
||||
storageKey: String = Self.defaultStorageKey,
|
||||
baselineKey: String = Self.defaultBaselineKey
|
||||
) {
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
self.storageKey = storageKey
|
||||
self.baselineKey = baselineKey
|
||||
}
|
||||
|
||||
/// 读取常用 URI,首次使用时写入默认值,并始终按当前权限过滤。
|
||||
func load(menuItems: [HomeMenuItem]) -> [String] {
|
||||
/// 按 Android 规则生成默认常用 URI:顶层权限顺序、可用权限过滤、别名去重,超过 4 个取前 4 个。
|
||||
static func defaultCommonURIs(
|
||||
orderedTopLevelURIs: [String],
|
||||
availableURIs: Set<String>
|
||||
) -> [String] {
|
||||
let resolved = 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 availableURIs.contains(canonical) ? canonical : nil
|
||||
}
|
||||
let deduplicated = unique(resolved)
|
||||
if deduplicated.count > 4 {
|
||||
return Array(deduplicated.prefix(4))
|
||||
}
|
||||
return deduplicated
|
||||
}
|
||||
|
||||
/// 读取常用 URI;无角色时返回空,无保存或过滤后为空时写入 Android 对齐默认值。
|
||||
func load(
|
||||
menuItems: [HomeMenuItem],
|
||||
roleCode: String?,
|
||||
legacyRoleId: Int? = nil,
|
||||
topLevelPermissionURIs: [String]
|
||||
) -> [String] {
|
||||
guard let roleCode, !roleCode.isEmpty else { return [] }
|
||||
migrateLegacyStorageIfNeeded(roleCode: roleCode, legacyRoleId: legacyRoleId)
|
||||
|
||||
let availableURIs = Set(menuItems.map(\.uri))
|
||||
if !defaults.bool(forKey: baselineKey) {
|
||||
let defaults = defaultCommonURIs(availableURIs: availableURIs)
|
||||
save(defaults)
|
||||
self.defaults.set(true, forKey: baselineKey)
|
||||
return defaults
|
||||
let key = storageKey(for: roleCode)
|
||||
let saved = defaults.stringArray(forKey: key)
|
||||
|
||||
if let saved, !saved.isEmpty {
|
||||
let filtered = Self.unique(saved.compactMap { uri -> String? in
|
||||
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
return availableURIs.contains(resolved) ? resolved : nil
|
||||
})
|
||||
if !filtered.isEmpty {
|
||||
save(filtered, roleCode: roleCode)
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
|
||||
let saved = defaults.stringArray(forKey: storageKey) ?? []
|
||||
let filtered = unique(saved.compactMap { uri -> String? in
|
||||
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
return availableURIs.contains(resolved) ? resolved : nil
|
||||
})
|
||||
save(filtered)
|
||||
return filtered
|
||||
let defaultURIs = Self.defaultCommonURIs(
|
||||
orderedTopLevelURIs: topLevelPermissionURIs,
|
||||
availableURIs: availableURIs
|
||||
)
|
||||
save(defaultURIs, roleCode: roleCode)
|
||||
return defaultURIs
|
||||
}
|
||||
|
||||
/// 将指定 URI 加入常用应用,按别名避免重复添加。
|
||||
func add(_ uri: String, current: [String], menuItems: [HomeMenuItem]) -> [String] {
|
||||
func add(_ uri: String, current: [String], menuItems: [HomeMenuItem], roleCode: String?) -> [String] {
|
||||
guard let roleCode, !roleCode.isEmpty else { return current }
|
||||
let availableURIs = Set(menuItems.map(\.uri))
|
||||
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
guard availableURIs.contains(resolved) else { return current }
|
||||
@ -55,34 +79,49 @@ struct HomeCommonMenuStore {
|
||||
return current
|
||||
}
|
||||
let next = current + [resolved]
|
||||
save(next)
|
||||
save(next, roleCode: roleCode)
|
||||
return next
|
||||
}
|
||||
|
||||
/// 从常用应用中移除指定 URI,同义 URI 会一起移除。
|
||||
func remove(_ uri: String, current: [String]) -> [String] {
|
||||
func remove(_ uri: String, current: [String], roleCode: String?) -> [String] {
|
||||
guard let roleCode, !roleCode.isEmpty else { return current }
|
||||
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
|
||||
let next = current.filter { HomeMenuRouter.menuAliasKey(for: $0) != aliasKey }
|
||||
save(next)
|
||||
save(next, roleCode: roleCode)
|
||||
return next
|
||||
}
|
||||
|
||||
/// 保存常用 URI。
|
||||
func save(_ uris: [String]) {
|
||||
defaults.set(unique(uris), forKey: storageKey)
|
||||
/// 保存指定角色的常用 URI。
|
||||
func save(_ uris: [String], roleCode: String) {
|
||||
defaults.set(Self.unique(uris), forKey: storageKey(for: roleCode))
|
||||
}
|
||||
|
||||
/// 生成默认常用应用 URI。
|
||||
private func defaultCommonURIs(availableURIs: Set<String>) -> [String] {
|
||||
let preferred = ["registration_invitation", "location_report", "pm_manager", "pm"]
|
||||
return unique(preferred.compactMap { uri in
|
||||
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
|
||||
return availableURIs.contains(resolved) ? resolved : nil
|
||||
})
|
||||
/// 若新 key 无数据,则从 legacy role id key 一次性迁移常用菜单。
|
||||
func migrateLegacyStorageIfNeeded(roleCode: String, legacyRoleId: Int?) {
|
||||
let newKey = storageKey(for: roleCode)
|
||||
guard defaults.stringArray(forKey: newKey) == nil,
|
||||
let legacyRoleId,
|
||||
let legacyData = defaults.stringArray(forKey: legacyStorageKey(for: legacyRoleId)),
|
||||
!legacyData.isEmpty else {
|
||||
return
|
||||
}
|
||||
defaults.set(legacyData, forKey: newKey)
|
||||
defaults.removeObject(forKey: legacyStorageKey(for: legacyRoleId))
|
||||
}
|
||||
|
||||
/// 生成按 role_code 隔离的 UserDefaults key。
|
||||
private func storageKey(for roleCode: String) -> String {
|
||||
Self.storageKeyPrefix + roleCode
|
||||
}
|
||||
|
||||
/// 旧版按 numeric role id 存储的 key。
|
||||
private func legacyStorageKey(for roleId: Int) -> String {
|
||||
Self.storageKeyPrefix + String(roleId)
|
||||
}
|
||||
|
||||
/// 按 URI 别名去重,保留首次出现的 URI。
|
||||
private func unique(_ uris: [String]) -> [String] {
|
||||
private static func unique(_ uris: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
return uris.filter { uri in
|
||||
seen.insert(HomeMenuRouter.menuAliasKey(for: uri)).inserted
|
||||
|
||||
@ -58,10 +58,15 @@ final class HomeViewModel: ObservableObject {
|
||||
]
|
||||
|
||||
/// 从角色权限树扁平化出菜单项,按固定顺序排序并按 URI 别名去重。
|
||||
func buildMenus(from permissions: [RolePermissionResponse], currentRoleId: Int?) {
|
||||
func buildMenus(from permissions: [RolePermissionResponse], currentRoleCode: String?) {
|
||||
let source: RolePermissionResponse?
|
||||
if let currentRoleId {
|
||||
source = permissions.first { $0.role.id == currentRoleId }
|
||||
if let currentRoleCode, !currentRoleCode.isEmpty {
|
||||
source = RolePermissionMatching.match(
|
||||
in: permissions,
|
||||
roleCode: currentRoleCode,
|
||||
roleId: nil,
|
||||
roleName: nil
|
||||
)
|
||||
} else {
|
||||
source = permissions.first
|
||||
}
|
||||
|
||||
@ -49,7 +49,7 @@ struct HomeMoreFunctionsView: View {
|
||||
.task {
|
||||
rebuildMenus()
|
||||
}
|
||||
.onChange(of: permissionContext.currentRole?.id) { _ in
|
||||
.onChange(of: permissionContext.currentRole?.roleCode) { _ in
|
||||
rebuildMenus()
|
||||
}
|
||||
.onChange(of: permissionContext.rolePermissions.count) { _ in
|
||||
@ -122,10 +122,16 @@ struct HomeMoreFunctionsView: View {
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
let roleCode = permissionContext.currentAppRole?.rawValue
|
||||
if isCommon {
|
||||
commonUris = commonMenuStore.remove(item.uri, current: commonUris)
|
||||
commonUris = commonMenuStore.remove(item.uri, current: commonUris, roleCode: roleCode)
|
||||
} else {
|
||||
commonUris = commonMenuStore.add(item.uri, current: commonUris, menuItems: viewModel.menuItems)
|
||||
commonUris = commonMenuStore.add(
|
||||
item.uri,
|
||||
current: commonUris,
|
||||
menuItems: viewModel.menuItems,
|
||||
roleCode: roleCode
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: isCommon ? "minus.circle.fill" : "plus.circle.fill")
|
||||
@ -195,10 +201,17 @@ struct HomeMoreFunctionsView: View {
|
||||
}
|
||||
|
||||
private func rebuildMenus() {
|
||||
let roleCode = permissionContext.currentAppRole?.rawValue
|
||||
let legacyRoleId = permissionContext.currentRole?.id
|
||||
viewModel.buildMenus(
|
||||
from: permissionContext.rolePermissions,
|
||||
currentRoleId: permissionContext.currentRole?.id
|
||||
currentRoleCode: roleCode
|
||||
)
|
||||
commonUris = commonMenuStore.load(
|
||||
menuItems: viewModel.menuItems,
|
||||
roleCode: roleCode,
|
||||
legacyRoleId: legacyRoleId,
|
||||
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
|
||||
)
|
||||
commonUris = commonMenuStore.load(menuItems: viewModel.menuItems)
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,6 +88,8 @@ extension HomeRoute {
|
||||
DepositOrderEntryView()
|
||||
case .withdrawalAudit:
|
||||
WithdrawalAuditView()
|
||||
case .cooperationOrder:
|
||||
CooperationOrderAccessView()
|
||||
case .scenicSettlement:
|
||||
ScenicSettlementView()
|
||||
case .scenicSettlementReview:
|
||||
|
||||
@ -27,23 +27,18 @@ struct HomeView: View {
|
||||
|
||||
private let commonMenuStore = HomeCommonMenuStore()
|
||||
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
private let minimalTopRoleIds: Set<Int> = [46, 47, 52, 53, 54]
|
||||
|
||||
private var contentMaxWidth: CGFloat {
|
||||
horizontalSizeClass == .regular ? 760 : .infinity
|
||||
}
|
||||
|
||||
private var currentRoleId: Int? {
|
||||
permissionContext.currentRole?.id
|
||||
}
|
||||
|
||||
private var isStoreManager: Bool {
|
||||
currentRoleId == 46
|
||||
permissionContext.currentAppRole?.isStoreAdmin == true
|
||||
}
|
||||
|
||||
private var shouldShowWorkStatus: Bool {
|
||||
guard let currentRoleId else { return true }
|
||||
return !minimalTopRoleIds.contains(currentRoleId)
|
||||
guard let role = permissionContext.currentAppRole else { return true }
|
||||
return !AppRoleCode.homeMinimalTopRoles.contains(role)
|
||||
}
|
||||
|
||||
private var currentScenicName: String {
|
||||
@ -66,9 +61,8 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
private var displayMenuItems: [HomeMenuItem] {
|
||||
let selected = commonUris.compactMap(menuItemForHomeEntry(uri:))
|
||||
let fallback = selected.isEmpty ? Array(viewModel.menuItems.prefix(3)) : selected
|
||||
return fallback + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
|
||||
let items = commonUris.compactMap(menuItemForHomeEntry(uri:))
|
||||
return items + [HomeMenuItem(title: "更多功能", uri: "more_functions", iconSrc: nil)]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@ -113,7 +107,7 @@ struct HomeView: View {
|
||||
guard isOnline, secondsUntilReport > 0 else { return }
|
||||
secondsUntilReport -= 1
|
||||
}
|
||||
.onChange(of: permissionContext.currentRole?.id) { _ in
|
||||
.onChange(of: permissionContext.currentRole?.roleCode) { _ in
|
||||
rebuildMenusFromCurrentContext()
|
||||
}
|
||||
.onChange(of: permissionContext.rolePermissions.count) { _ in
|
||||
@ -412,11 +406,18 @@ struct HomeView: View {
|
||||
}
|
||||
|
||||
private func rebuildMenusFromCurrentContext() {
|
||||
let roleCode = permissionContext.currentAppRole?.rawValue
|
||||
let legacyRoleId = permissionContext.currentRole?.id
|
||||
viewModel.buildMenus(
|
||||
from: permissionContext.rolePermissions,
|
||||
currentRoleId: permissionContext.currentRole?.id
|
||||
currentRoleCode: roleCode
|
||||
)
|
||||
commonUris = commonMenuStore.load(
|
||||
menuItems: viewModel.menuItems,
|
||||
roleCode: roleCode,
|
||||
legacyRoleId: legacyRoleId,
|
||||
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
|
||||
)
|
||||
commonUris = commonMenuStore.load(menuItems: viewModel.menuItems)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user