Implement permission-driven home tab aligned with Android.
Load role-permission on first visit to drive menus, role-based layout, store card, and location reporting with account-scoped persistence and unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
50
suixinkan/Features/Home/Services/HomeCommonMenuStore.swift
Normal file
50
suixinkan/Features/Home/Services/HomeCommonMenuStore.swift
Normal file
@ -0,0 +1,50 @@
|
||||
//
|
||||
// HomeCommonMenuStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 常用应用 URI 持久化,按账号与角色作用域存储。
|
||||
final class HomeCommonMenuStore {
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
/// 读取已保存的常用 URI;无记录时返回空数组。
|
||||
func savedCommonURIs(accountScope: String, roleCode: String) -> [String] {
|
||||
defaults.stringArray(forKey: storageKey(accountScope: accountScope, roleCode: roleCode)) ?? []
|
||||
}
|
||||
|
||||
/// 保存常用 URI 列表。
|
||||
func saveCommonURIs(_ uris: [String], accountScope: String, roleCode: String) {
|
||||
defaults.set(uris, forKey: storageKey(accountScope: accountScope, roleCode: roleCode))
|
||||
}
|
||||
|
||||
/// 根据权限生成默认常用 URI(最多 4 个)。
|
||||
static func defaultCommonURIs(from permissions: [HomePermissionItem]) -> [String] {
|
||||
let uris = permissions.map(\.uri)
|
||||
return uris.count > 4 ? Array(uris.prefix(4)) : uris
|
||||
}
|
||||
|
||||
/// 构建常用菜单列表。
|
||||
func commonMenus(
|
||||
from allMenus: [HomeMenuItem],
|
||||
permissions: [HomePermissionItem],
|
||||
accountScope: String,
|
||||
roleCode: String
|
||||
) -> [HomeMenuItem] {
|
||||
let saved = savedCommonURIs(accountScope: accountScope, roleCode: roleCode)
|
||||
let defaultURIs = Self.defaultCommonURIs(from: permissions)
|
||||
let commonURIs = saved.isEmpty ? defaultURIs : saved
|
||||
let commonSet = Set(commonURIs)
|
||||
return allMenus.filter { commonSet.contains($0.uri) }
|
||||
}
|
||||
|
||||
private func storageKey(accountScope: String, roleCode: String) -> String {
|
||||
"\(accountScope)_role_\(roleCode)_common_uris"
|
||||
}
|
||||
}
|
||||
97
suixinkan/Features/Home/Services/HomeLocationProvider.swift
Normal file
97
suixinkan/Features/Home/Services/HomeLocationProvider.swift
Normal file
@ -0,0 +1,97 @@
|
||||
//
|
||||
// HomeLocationProvider.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
|
||||
/// 首页位置采集结果。
|
||||
struct HomeLocationSnapshot: Sendable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let address: String
|
||||
}
|
||||
|
||||
/// 首页位置采集,基于 CoreLocation 并提供可选逆地理。
|
||||
final class HomeLocationProvider: NSObject, CLLocationManagerDelegate {
|
||||
|
||||
private let manager = CLLocationManager()
|
||||
private var coordinateContinuation: CheckedContinuation<CLLocationCoordinate2D, Error>?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
}
|
||||
|
||||
/// 请求当前位置与地址。
|
||||
func requestSnapshot() async throws -> HomeLocationSnapshot {
|
||||
let status = manager.authorizationStatus
|
||||
if status == .notDetermined {
|
||||
manager.requestWhenInUseAuthorization()
|
||||
try await Task.sleep(nanoseconds: 300_000_000)
|
||||
}
|
||||
|
||||
let resolvedStatus = manager.authorizationStatus
|
||||
guard resolvedStatus == .authorizedWhenInUse || resolvedStatus == .authorizedAlways else {
|
||||
throw HomeLocationProviderError.permissionDenied
|
||||
}
|
||||
|
||||
let coordinate = try await requestCoordinate()
|
||||
let address = await reverseGeocode(latitude: coordinate.latitude, longitude: coordinate.longitude)
|
||||
return HomeLocationSnapshot(
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude,
|
||||
address: address
|
||||
)
|
||||
}
|
||||
|
||||
private func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.coordinateContinuation = continuation
|
||||
manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
let location = CLLocation(latitude: latitude, longitude: longitude)
|
||||
let geocoder = CLGeocoder()
|
||||
do {
|
||||
let placemarks = try await geocoder.reverseGeocodeLocation(location)
|
||||
return placemarks.first?.formattedAddress ?? ""
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let location = locations.last else { return }
|
||||
coordinateContinuation?.resume(returning: location.coordinate)
|
||||
coordinateContinuation = nil
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
coordinateContinuation?.resume(throwing: error)
|
||||
coordinateContinuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 首页定位错误。
|
||||
enum HomeLocationProviderError: LocalizedError {
|
||||
case permissionDenied
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .permissionDenied: "需要位置权限才能上线"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension CLPlacemark {
|
||||
var formattedAddress: String {
|
||||
[subLocality, locality, administrativeArea]
|
||||
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "")
|
||||
}
|
||||
}
|
||||
139
suixinkan/Features/Home/Services/HomeLocationStateStore.swift
Normal file
139
suixinkan/Features/Home/Services/HomeLocationStateStore.swift
Normal file
@ -0,0 +1,139 @@
|
||||
//
|
||||
// HomeLocationStateStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 首页在线状态与 2 小时倒计时存储,对齐 Android `LocationStateRepository`。
|
||||
final class HomeLocationStateStore {
|
||||
|
||||
static let onlineDurationMillis: Int64 = 2 * 60 * 60 * 1000
|
||||
|
||||
private(set) var isOnline = false
|
||||
private(set) var elapsedSeconds: Int64 = 0
|
||||
private(set) var nextReportCountdownSeconds: Int64 = 0
|
||||
private(set) var reminderMinutes = 0
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
|
||||
private let store: AppStore
|
||||
private var countdownTask: Task<Void, Never>?
|
||||
private var anchorTimestamp: Int64?
|
||||
|
||||
init(store: AppStore = .shared) {
|
||||
self.store = store
|
||||
reminderMinutes = store.locationReminderMinutes
|
||||
isOnline = store.onlineStatus
|
||||
}
|
||||
|
||||
/// 启动时恢复在线倒计时。
|
||||
func restoreStateIfNeeded() {
|
||||
guard store.onlineStatus else { return }
|
||||
let lastTime = store.lastLocationReportTime
|
||||
guard lastTime > 0 else { return }
|
||||
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let elapsed = now - lastTime
|
||||
if elapsed >= Self.onlineDurationMillis {
|
||||
updateOnlineStatus(false)
|
||||
store.clearLastLocationReportTime()
|
||||
notifyChange()
|
||||
return
|
||||
}
|
||||
|
||||
anchorTimestamp = lastTime
|
||||
let remaining = Self.onlineDurationMillis - elapsed
|
||||
elapsedSeconds = max(0, elapsed / 1000)
|
||||
nextReportCountdownSeconds = max(0, remaining / 1000)
|
||||
startCountdown(anchorTime: lastTime)
|
||||
notifyChange()
|
||||
}
|
||||
|
||||
/// 更新在线状态并持久化。
|
||||
func updateOnlineStatus(_ online: Bool) {
|
||||
isOnline = online
|
||||
store.onlineStatus = online
|
||||
if !online {
|
||||
stopCountdown()
|
||||
store.clearLastLocationReportTime()
|
||||
}
|
||||
notifyChange()
|
||||
}
|
||||
|
||||
/// 更新提醒分钟数。
|
||||
func updateReminderMinutes(_ minutes: Int) {
|
||||
reminderMinutes = minutes
|
||||
store.locationReminderMinutes = minutes
|
||||
notifyChange()
|
||||
}
|
||||
|
||||
/// 开始新的位置上报会话。
|
||||
func startLocationReport(at timestamp: Int64 = Int64(Date().timeIntervalSince1970 * 1000)) {
|
||||
anchorTimestamp = timestamp
|
||||
store.lastLocationReportTime = timestamp
|
||||
isOnline = true
|
||||
store.onlineStatus = true
|
||||
elapsedSeconds = 0
|
||||
nextReportCountdownSeconds = Self.onlineDurationMillis / 1000
|
||||
startCountdown(anchorTime: timestamp)
|
||||
notifyChange()
|
||||
}
|
||||
|
||||
/// 格式化倒计时文本 `HH:MM:SS`。
|
||||
func formattedCountdown(from seconds: Int64) -> String {
|
||||
let hours = seconds / 3600
|
||||
let minutes = (seconds % 3600) / 60
|
||||
let secs = seconds % 60
|
||||
return String(format: "%02d:%02d:%02d", hours, minutes, secs)
|
||||
}
|
||||
|
||||
var countdownDisplayText: String {
|
||||
formattedCountdown(from: nextReportCountdownSeconds)
|
||||
}
|
||||
|
||||
/// 是否到达提前提醒窗口。
|
||||
func shouldTriggerTimeoutReminder() -> Bool {
|
||||
guard reminderMinutes > 0, isOnline else { return false }
|
||||
let reminderSeconds = Int64(reminderMinutes * 60)
|
||||
return nextReportCountdownSeconds > 0 && nextReportCountdownSeconds <= reminderSeconds
|
||||
}
|
||||
|
||||
private func startCountdown(anchorTime: Int64) {
|
||||
countdownTask?.cancel()
|
||||
countdownTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
guard let self else { return }
|
||||
guard self.isOnline else { return }
|
||||
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
let elapsed = now - anchorTime
|
||||
let remaining = Self.onlineDurationMillis - elapsed
|
||||
let elapsedSeconds = max(0, elapsed / 1000)
|
||||
|
||||
self.elapsedSeconds = elapsedSeconds
|
||||
if remaining <= 0 {
|
||||
self.nextReportCountdownSeconds = 0
|
||||
self.updateOnlineStatus(false)
|
||||
self.store.clearLastLocationReportTime()
|
||||
return
|
||||
}
|
||||
self.nextReportCountdownSeconds = max(0, remaining / 1000)
|
||||
self.notifyChange()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopCountdown() {
|
||||
countdownTask?.cancel()
|
||||
countdownTask = nil
|
||||
anchorTimestamp = nil
|
||||
elapsedSeconds = 0
|
||||
nextReportCountdownSeconds = 0
|
||||
}
|
||||
|
||||
private func notifyChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
58
suixinkan/Features/Home/Services/HomeMenuCatalog.swift
Normal file
58
suixinkan/Features/Home/Services/HomeMenuCatalog.swift
Normal file
@ -0,0 +1,58 @@
|
||||
//
|
||||
// HomeMenuCatalog.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 首页菜单静态 catalog,与服务端 permission URI 取交集后展示。
|
||||
enum HomeMenuCatalog {
|
||||
|
||||
/// 全部已知菜单项,对齐 Android `Constants.menuList`。
|
||||
static let allItems: [HomeMenuItem] = [
|
||||
HomeMenuItem(uri: "space_settings", title: "空间设置", iconName: "gearshape"),
|
||||
HomeMenuItem(uri: "wallet", title: "我的钱包", iconName: "wallet.pass"),
|
||||
HomeMenuItem(uri: "cloud_management", title: "相册云盘", iconName: "icloud"),
|
||||
HomeMenuItem(uri: "asset_management", title: "素材管理", iconName: "photo.on.rectangle"),
|
||||
HomeMenuItem(uri: "task_management", title: "任务管理", iconName: "checklist"),
|
||||
HomeMenuItem(uri: "task_management_editor", title: "任务管理", iconName: "checklist"),
|
||||
HomeMenuItem(uri: "schedule_management", title: "日程管理", iconName: "calendar"),
|
||||
HomeMenuItem(uri: "system_settings", title: "设置中心", iconName: "slider.horizontal.3"),
|
||||
HomeMenuItem(uri: "message_center", title: "消息中心", iconName: "bell"),
|
||||
HomeMenuItem(uri: "checkin_points", title: "打卡点管理", iconName: "mappin.and.ellipse"),
|
||||
HomeMenuItem(uri: "sample_management", title: "样片管理", iconName: "photo.stack"),
|
||||
HomeMenuItem(uri: "live_stream_management", title: "直播管理", iconName: "video"),
|
||||
HomeMenuItem(uri: "verification_order", title: "核销订单", iconName: "checkmark.seal"),
|
||||
HomeMenuItem(uri: "live_album", title: "直播相册", iconName: "photo.on.rectangle.angled"),
|
||||
HomeMenuItem(uri: "pm", title: "项目管理", iconName: "folder"),
|
||||
HomeMenuItem(uri: "pm_manager", title: "项目管理", iconName: "folder"),
|
||||
HomeMenuItem(uri: "location_report", title: "位置上报", iconName: "location"),
|
||||
HomeMenuItem(uri: "registration_invitation", title: "注册邀请", iconName: "person.badge.plus"),
|
||||
HomeMenuItem(uri: "store", title: "店铺管理", iconName: "building.2"),
|
||||
HomeMenuItem(uri: "fly", title: "飞行管理", iconName: "airplane"),
|
||||
HomeMenuItem(uri: "pilot_cert", title: "飞手认证", iconName: "person.text.rectangle"),
|
||||
HomeMenuItem(uri: "pilot_controller", title: "飞控", iconName: "gamecontroller"),
|
||||
HomeMenuItem(uri: "/scenic-queue", title: "排队管理", iconName: "person.3"),
|
||||
HomeMenuItem(uri: "operating-area", title: "运营区域", iconName: "map"),
|
||||
HomeMenuItem(uri: "/scenic-order-manage", title: "景区订单", iconName: "doc.text"),
|
||||
HomeMenuItem(uri: "cooperation_order", title: "合作订单", iconName: "person.2"),
|
||||
]
|
||||
|
||||
/// 将服务端权限映射为可展示菜单,保持服务端顺序。
|
||||
static func visibleMenus(from permissions: [HomePermissionItem]) -> [HomeMenuItem] {
|
||||
permissions.compactMap { permission in
|
||||
guard let catalog = allItems.first(where: { $0.uri == permission.uri }) else { return nil }
|
||||
return HomeMenuItem(
|
||||
uri: catalog.uri,
|
||||
title: catalog.title,
|
||||
iconName: catalog.iconName,
|
||||
serverName: permission.name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 查找 catalog 项。
|
||||
static func item(for uri: String) -> HomeMenuItem? {
|
||||
allItems.first { $0.uri == uri }
|
||||
}
|
||||
}
|
||||
108
suixinkan/Features/Home/Services/HomeRouteHandler.swift
Normal file
108
suixinkan/Features/Home/Services/HomeRouteHandler.swift
Normal file
@ -0,0 +1,108 @@
|
||||
//
|
||||
// HomeRouteHandler.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 首页菜单路由,将 permission URI 映射到已有页面或提示。
|
||||
enum HomeRouteHandler {
|
||||
|
||||
/// 处理菜单点击。
|
||||
@MainActor
|
||||
static func open(
|
||||
uri: String,
|
||||
from viewController: UIViewController,
|
||||
storeItem: StoreItem?
|
||||
) {
|
||||
if uri == "pilot_controller" || uri == "more_functions" {
|
||||
showDeveloping(from: viewController)
|
||||
return
|
||||
}
|
||||
|
||||
if uri != "pilot_controller", AppStore.shared.currentScenicId <= 0 {
|
||||
showToast("请先选择景区", from: viewController)
|
||||
return
|
||||
}
|
||||
|
||||
switch uri {
|
||||
case "verification_order":
|
||||
selectTab(.orders, from: viewController)
|
||||
case "photographer_stats":
|
||||
selectTab(.statistics, from: viewController)
|
||||
case "system_settings", "space_settings":
|
||||
selectTab(.profile, from: viewController)
|
||||
case "pilot_cert":
|
||||
let controller = RealNameAuthViewController()
|
||||
viewController.navigationController?.pushViewController(controller, animated: true)
|
||||
case "operating-area":
|
||||
handleOperatingArea(from: viewController, storeItem: storeItem)
|
||||
case "cooperation_order":
|
||||
if !hasCooperationOrderPermission() {
|
||||
showToast("暂无合作订单权限", from: viewController)
|
||||
return
|
||||
}
|
||||
showDeveloping(from: viewController)
|
||||
default:
|
||||
showDeveloping(from: viewController)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func handleOperatingArea(from viewController: UIViewController, storeItem: StoreItem?) {
|
||||
guard let role = AppStore.shared.currentAppRole else {
|
||||
showToast("当前账号暂不支持运营区域", from: viewController)
|
||||
return
|
||||
}
|
||||
switch role {
|
||||
case .storeAdmin:
|
||||
guard let storeItem, storeItem.id > 0 else {
|
||||
showToast("请先选择店铺", from: viewController)
|
||||
return
|
||||
}
|
||||
showDeveloping(from: viewController)
|
||||
case .scenicAdmin:
|
||||
guard AppStore.shared.currentScenicId > 0 else {
|
||||
showToast("请先选择景区", from: viewController)
|
||||
return
|
||||
}
|
||||
showDeveloping(from: viewController)
|
||||
default:
|
||||
showToast("当前账号暂不支持运营区域", from: viewController)
|
||||
}
|
||||
}
|
||||
|
||||
private static func hasCooperationOrderPermission() -> Bool {
|
||||
let permissions = AppStore.shared.permissionItems()
|
||||
return permissions.contains { $0.uri == "cooperation_order" }
|
||||
|| permissions.contains(where: { containsCooperationOrder(in: $0) })
|
||||
}
|
||||
|
||||
private static func containsCooperationOrder(in item: HomePermissionItem) -> Bool {
|
||||
item.uri == "cooperation_order"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func selectTab(_ tab: AppTab, from viewController: UIViewController) {
|
||||
guard let tabBar = viewController.tabBarController as? MainTabBarController else { return }
|
||||
tabBar.selectTab(tab)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func showDeveloping(from viewController: UIViewController) {
|
||||
showToast("功能开发中", from: viewController)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func showToast(_ message: String, from viewController: UIViewController) {
|
||||
if let base = viewController as? BaseViewController {
|
||||
base.showToast(message)
|
||||
return
|
||||
}
|
||||
let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
|
||||
viewController.present(alert, animated: true)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
alert.dismiss(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
39
suixinkan/Features/Home/Services/RolePermissionMatcher.swift
Normal file
39
suixinkan/Features/Home/Services/RolePermissionMatcher.swift
Normal file
@ -0,0 +1,39 @@
|
||||
//
|
||||
// RolePermissionMatcher.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 角色权限匹配器,在 `role-permission` 列表中定位当前账号角色。
|
||||
enum RolePermissionMatcher {
|
||||
|
||||
/// 按 role_code → legacy id → role_name 优先级匹配权限项。
|
||||
static func match(
|
||||
in list: [RolePermissionResponse],
|
||||
roleCode: String,
|
||||
roleName: String,
|
||||
legacyRoleId: Int = 0
|
||||
) -> RolePermissionResponse? {
|
||||
if let role = AppRoleCode.fromCode(roleCode),
|
||||
let matched = list.first(where: { AppRoleCode.fromCode($0.role.roleCode) == role }) {
|
||||
return matched
|
||||
}
|
||||
|
||||
if legacyRoleId > 0,
|
||||
let matched = list.first(where: { $0.role.id == legacyRoleId }) {
|
||||
return matched
|
||||
}
|
||||
|
||||
let trimmedName = roleName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedName.isEmpty {
|
||||
return list.first { item in
|
||||
let itemName = item.role.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !itemName.isEmpty else { return false }
|
||||
return itemName.contains(trimmedName) || trimmedName.contains(itemName)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user