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>
419 lines
14 KiB
Swift
419 lines
14 KiB
Swift
//
|
||
// 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?()
|
||
}
|
||
}
|