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:
2026-07-06 17:27:54 +08:00
parent 75d0cb1f9a
commit 3b17b7f7f0
27 changed files with 2573 additions and 10 deletions

View 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"
}
}

View 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: "")
}
}

View 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?()
}
}

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

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

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