实现权限驱动的首页 Tab 并对齐 Android

This commit is contained in:
2026-07-06 17:27:54 +08:00
parent e04ad8f8f0
commit d79d3003e3
27 changed files with 2573 additions and 10 deletions

View File

@ -0,0 +1,418 @@
//
// HomeViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel Android `HomeViewModel`
final class HomeViewModel {
private(set) var currentScenicName = ""
private(set) var currentAppRole: AppRoleCode?
private(set) var commonMenus: [HomeMenuItem] = []
private(set) var storeItem: StoreItem?
private(set) var showPermissionDialog = false
private(set) var showScenicDialog = false
private(set) var showLocationTimeoutDialog = false
private(set) var showOnlineStatusDialog = false
private(set) var showLocationReportSuccessDialog = false
private(set) var isMinimalTopRole = false
private(set) var locationInfoText = "定位中..."
private(set) var reportTimeText = ""
private(set) var isLocationReporting = false
private(set) var needsPermissionReload = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
let locationStateStore: HomeLocationStateStore
private let appStore: AppStore
private let commonMenuStore: HomeCommonMenuStore
private let locationProvider: HomeLocationProvider
private var lastOnlineStatusSwitchTime: Int64 = 0
private var lastLocationReportTime: Int64 = 0
private var timeoutDialogTriggered = false
private var dismissedLocationTimeoutDialog = false
private let operationIntervalMillis: Int64 = 30_000
init(
appStore: AppStore = .shared,
locationStateStore: HomeLocationStateStore = HomeLocationStateStore(),
commonMenuStore: HomeCommonMenuStore = HomeCommonMenuStore(),
locationProvider: HomeLocationProvider = HomeLocationProvider()
) {
self.appStore = appStore
self.locationStateStore = locationStateStore
self.commonMenuStore = commonMenuStore
self.locationProvider = locationProvider
self.locationStateStore.onStateChange = { [weak self] in
self?.notifyStateChange()
}
}
var isOnline: Bool { locationStateStore.isOnline }
var countdownDisplayText: String { locationStateStore.countdownDisplayText }
var reminderMinutes: Int { locationStateStore.reminderMinutes }
/// 线
func initialize(api: HomeAPI) async {
locationStateStore.restoreStateIfNeeded()
refreshLocalDisplayState()
await loadPermissions(api: api, force: true)
}
///
func markNeedsPermissionReload() {
needsPermissionReload = true
}
///
func reloadIfNeeded(api: HomeAPI) async {
guard needsPermissionReload else {
refreshLocalDisplayState()
rebuildCommonMenus()
await loadStoreListIfNeeded(api: api)
notifyStateChange()
return
}
needsPermissionReload = false
await loadPermissions(api: api, force: true)
}
/// role-permission
func loadPermissions(api: HomeAPI, force: Bool = false) async {
if !force, !appStore.permissionItems().isEmpty {
rebuildCommonMenus()
await loadStoreListIfNeeded(api: api)
notifyStateChange()
return
}
do {
let rolePermissionList = try await api.rolePermissions()
if rolePermissionList.isEmpty {
appStore.savePermissionItems([])
showPermissionDialog = true
rebuildCommonMenus()
notifyStateChange()
return
}
appStore.saveRolePermissionList(rolePermissionList)
let matched = appStore.matchRolePermissionItem(in: rolePermissionList) ?? rolePermissionList[0]
if !matched.role.name.isEmpty {
appStore.roleName = matched.role.name
}
appStore.saveRoleScenicList(matched.scenic)
let permissions = matched.role.permission.map(HomePermissionItem.init)
appStore.savePermissionItems(permissions)
showPermissionDialog = false
refreshLocalDisplayState()
rebuildCommonMenus()
await loadStoreListIfNeeded(api: api)
notifyStateChange()
} catch is CancellationError {
return
} catch {
appStore.savePermissionItems([])
showPermissionDialog = true
rebuildCommonMenus()
onShowMessage?(error.localizedDescription)
notifyStateChange()
}
}
///
func loadStoreListIfNeeded(api: HomeAPI) async {
guard currentAppRole == .storeAdmin else {
storeItem = nil
notifyStateChange()
return
}
guard appStore.currentScenicId > 0 else {
storeItem = nil
notifyStateChange()
return
}
do {
let response = try await api.storeList()
let scenicId = appStore.currentScenicId
let savedStoreId = appStore.currentStoreId
let savedMatch = savedStoreId > 0 ? response.list.first { $0.id == savedStoreId } : nil
let matched = savedMatch ?? response.list.first { $0.scenicId == scenicId }
storeItem = matched
if let matched {
appStore.currentStoreId = matched.id
}
notifyStateChange()
} catch is CancellationError {
return
} catch {
storeItem = nil
notifyStateChange()
}
}
/// > >
func evaluateDialogs(api: HomeAPI) async {
if showPermissionDialog {
showScenicDialog = false
return
}
let hasScenic = appStore.currentScenicId > 0
showScenicDialog = !hasScenic
if showScenicDialog || dismissedLocationTimeoutDialog {
showLocationTimeoutDialog = false
notifyStateChange()
return
}
guard !isMinimalTopRole else {
showLocationTimeoutDialog = false
notifyStateChange()
return
}
try? await Task.sleep(nanoseconds: 500_000_000)
await checkLocationTimeout(api: api)
}
///
func checkLocationTimeout(api: HomeAPI) async {
guard reminderMinutes > 0 else {
showLocationTimeoutDialog = false
notifyStateChange()
return
}
let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !staffId.isEmpty else {
showLocationTimeoutDialog = true
notifyStateChange()
return
}
do {
let detail = try await api.locationDetail(staffId: staffId)
if detail.expireTime <= 0 {
showLocationTimeoutDialog = true
notifyStateChange()
return
}
let currentMillis = Int64(Date().timeIntervalSince1970 * 1000)
let expireMillis = detail.expireTime * 1000
let threshold = expireMillis - Int64(reminderMinutes * 60 * 1000)
showLocationTimeoutDialog = currentMillis >= threshold
notifyStateChange()
} catch is CancellationError {
return
} catch {
showLocationTimeoutDialog = true
notifyStateChange()
}
}
///
func refreshLocalDisplayState() {
currentScenicName = appStore.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
currentAppRole = appStore.currentAppRole
isMinimalTopRole = currentAppRole?.usesHomeMinimalTopLayout ?? false
}
///
func rebuildCommonMenus() {
let permissions = appStore.permissionItems()
let allMenus = HomeMenuCatalog.visibleMenus(from: permissions)
commonMenus = commonMenuStore.commonMenus(
from: allMenus,
permissions: permissions,
accountScope: appStore.accountCachePrefix,
roleCode: appStore.roleCode
)
}
func hidePermissionDialog() {
showPermissionDialog = false
notifyStateChange()
}
func hideScenicDialog() {
showScenicDialog = false
notifyStateChange()
}
func showOnlineStatusSwitchDialog() {
showOnlineStatusDialog = true
notifyStateChange()
}
func hideOnlineStatusSwitchDialog() {
showOnlineStatusDialog = false
notifyStateChange()
}
func hideLocationTimeoutDialog() {
showLocationTimeoutDialog = false
dismissedLocationTimeoutDialog = true
timeoutDialogTriggered = false
locationStateStore.updateOnlineStatus(false)
notifyStateChange()
}
func hideLocationReportSuccessDialog() {
showLocationReportSuccessDialog = false
notifyStateChange()
}
func updateReminderMinutes(_ minutes: Int) {
locationStateStore.updateReminderMinutes(minutes)
notifyStateChange()
}
/// 线/线
func switchOnlineStatus(api: HomeAPI) async {
let now = currentTimestampMillis()
if now - lastOnlineStatusSwitchTime < operationIntervalMillis {
let remaining = Int((operationIntervalMillis - (now - lastOnlineStatusSwitchTime)) / 1000)
onShowMessage?("操作过于频繁,请\(remaining)秒后再试")
hideOnlineStatusSwitchDialog()
return
}
guard appStore.currentScenicId > 0 else {
onShowMessage?("请先选择景区")
hideOnlineStatusSwitchDialog()
return
}
let goingOnline = !isOnline
if goingOnline {
do {
_ = try await locationProvider.requestSnapshot()
} catch {
onShowMessage?(error.localizedDescription)
hideOnlineStatusSwitchDialog()
return
}
locationStateStore.updateOnlineStatus(true)
timeoutDialogTriggered = false
dismissedLocationTimeoutDialog = false
lastOnlineStatusSwitchTime = now
onShowMessage?("已切换到在线状态,正在上报位置")
hideOnlineStatusSwitchDialog()
await reportLocation(api: api, type: 1)
} else {
locationStateStore.updateOnlineStatus(false)
lastOnlineStatusSwitchTime = now
onShowMessage?("已切换到离线状态")
hideOnlineStatusSwitchDialog()
await reportLocation(api: api, type: 3)
}
}
///
func manualReportLocation(api: HomeAPI) async {
let now = currentTimestampMillis()
if now - lastLocationReportTime < operationIntervalMillis {
let remaining = Int((operationIntervalMillis - (now - lastLocationReportTime)) / 1000)
onShowMessage?("操作过于频繁,请\(remaining)秒后再试")
return
}
await reportLocation(api: api, type: 1)
}
///
func confirmLocationTimeoutReport(api: HomeAPI) async {
showLocationTimeoutDialog = false
dismissedLocationTimeoutDialog = false
await reportLocation(api: api, type: 1)
}
///
func triggerLocationTimeoutIfNeeded() {
guard locationStateStore.shouldTriggerTimeoutReminder() else { return }
guard !timeoutDialogTriggered else { return }
timeoutDialogTriggered = true
showLocationTimeoutDialog = true
notifyStateChange()
}
///
func refreshLocationInfo() async {
isLocationReporting = true
notifyStateChange()
defer {
isLocationReporting = false
notifyStateChange()
}
do {
let snapshot = try await locationProvider.requestSnapshot()
locationInfoText = snapshot.address.isEmpty
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
: snapshot.address
} catch {
locationInfoText = "定位失败"
}
}
private func reportLocation(api: HomeAPI, type: Int) async {
guard appStore.currentScenicId > 0 else {
onShowMessage?("请先选择景区")
return
}
let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !staffId.isEmpty else {
onShowMessage?("获取用户ID失败")
return
}
isLocationReporting = true
notifyStateChange()
defer {
isLocationReporting = false
notifyStateChange()
}
do {
let snapshot = try await locationProvider.requestSnapshot()
locationInfoText = snapshot.address.isEmpty
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
: snapshot.address
_ = try await api.reportLocation(
staffId: staffId,
latitude: snapshot.latitude,
longitude: snapshot.longitude,
address: snapshot.address,
type: type,
scenicId: String(appStore.currentScenicId)
)
lastLocationReportTime = currentTimestampMillis()
if type != 3 {
locationStateStore.startLocationReport()
reportTimeText = formattedNow()
showLocationReportSuccessDialog = true
timeoutDialogTriggered = false
dismissedLocationTimeoutDialog = false
}
notifyStateChange()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
}
private func formattedNow() -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter.string(from: Date())
}
private func currentTimestampMillis() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
private func notifyStateChange() {
onStateChange?()
}
}