迁移合作订单完整能力,并统一 AppRoleCode 角色权限与首页菜单展示。

新增 CooperationOrder 模块(双 Tab 列表、获客员绑定、主 Tab 扫码)、订单来源/带客单绑定及配套 API 测试;同步引入 roleCode 权限匹配与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 13:33:00 +08:00
parent d310d26293
commit 1970572edd
66 changed files with 4247 additions and 201 deletions

View File

@ -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 按角色保存常用应用 URIkey`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 的映射,并同步补充单元测试。

View File

@ -56,6 +56,7 @@ enum HomeRoute: Hashable {
case locationReportHistory
case depositOrders
case withdrawalAudit
case cooperationOrder
case scenicSettlement
case scenicSettlementReview
case messageCenter

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -88,6 +88,8 @@ extension HomeRoute {
DepositOrderEntryView()
case .withdrawalAudit:
WithdrawalAuditView()
case .cooperationOrder:
CooperationOrderAccessView()
case .scenicSettlement:
ScenicSettlementView()
case .scenicSettlementReview:

View File

@ -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)
}
}