Files
suixinkan_uikit/suixinkan/Features/Home/ViewModels/HomeViewModel.swift
汉秋 efb3925257 完善首页权限申请流程与队列播报体验。
对齐 Android 无权限强制弹窗、角色下拉与申请页交互,补充 UIButton configuration 适配,并增强景区排队 TTS 与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:46:45 +08:00

450 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// HomeViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel Android `HomeViewModel`
final class HomeViewModel {
///
static let locationReportPromptText = "您已进入打卡范围"
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 = HomeViewModel.locationReportPromptText
private(set) var reportTimeText = ""
private(set) var isLocationReporting = false
private(set) var needsPermissionReload = false
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
let locationStateStore: HomeLocationStateStore
let locationReportService: LocationReportService
private let appStore: AppStore
private let commonMenuStore: HomeCommonMenuStore
private let locationProvider: any LocationProviding
private var timeoutDialogTriggered = false
private var dismissedLocationTimeoutDialog = false
init(
appStore: AppStore = .shared,
locationStateStore: HomeLocationStateStore = .shared,
commonMenuStore: HomeCommonMenuStore = HomeCommonMenuStore(),
locationProvider: any LocationProviding = LocationProvider.shared
) {
self.appStore = appStore
self.locationStateStore = locationStateStore
self.locationProvider = locationProvider
self.locationReportService = LocationReportService(
appStore: appStore,
locationStateStore: locationStateStore,
locationProvider: locationProvider
)
self.commonMenuStore = commonMenuStore
self.locationStateStore.observe(self) { [weak self] in
self?.notifyStateChange()
}
}
deinit {
locationStateStore.removeObserver(self)
}
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 resolvePermissionApplyDestination(api: HomeAPI) async -> PermissionApplyDestination {
do {
let pendingList = try await api.roleApplyPending()
guard let first = pendingList.first,
first.status == PermissionApplyPendingStatus.reviewing
|| first.status == PermissionApplyPendingStatus.rejected else {
return .apply
}
return .status(applyCode: first.code)
} catch {
return .apply
}
}
///
func reloadIfNeeded(api: HomeAPI) async {
locationStateStore.restoreStateIfNeeded()
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.permissions.permissionItems().isEmpty {
rebuildCommonMenus()
await loadStoreListIfNeeded(api: api)
notifyStateChange()
return
}
do {
let rolePermissionList = try await api.rolePermissions()
if rolePermissionList.isEmpty {
appStore.permissions.savePermissionItems([])
showPermissionDialog = true
rebuildCommonMenus()
notifyStateChange()
return
}
appStore.permissions.saveRolePermissionList(rolePermissionList)
let matched = appStore.permissions.matchRolePermissionItem(in: rolePermissionList) ?? rolePermissionList[0]
if !matched.role.roleCode.isEmpty {
appStore.session.roleCode = matched.role.roleCode
}
if !matched.role.name.isEmpty {
appStore.session.roleName = matched.role.name
}
appStore.permissions.saveRoleScenicList(matched.scenic)
let permissions = matched.role.permission.map(HomePermissionItem.init)
appStore.permissions.savePermissionItems(permissions)
showPermissionDialog = false
refreshLocalDisplayState()
rebuildCommonMenus()
await loadStoreListIfNeeded(api: api)
notifyStateChange()
} catch is CancellationError {
return
} catch {
appStore.permissions.savePermissionItems([])
showPermissionDialog = true
rebuildCommonMenus()
onShowMessage?(error.localizedDescription)
notifyStateChange()
}
}
///
func loadStoreListIfNeeded(api: HomeAPI) async {
guard currentAppRole == .storeAdmin else {
storeItem = nil
notifyStateChange()
return
}
guard appStore.session.currentScenicId > 0 else {
storeItem = nil
notifyStateChange()
return
}
do {
let response = try await api.storeList()
let scenicId = appStore.session.currentScenicId
let savedStoreId = appStore.session.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.session.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.session.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.session.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()
}
}
/// Android `initCurrentScenicName`
func refreshLocalDisplayState() {
let scenicId = appStore.session.currentScenicId
let scenicName = appStore.session.currentScenicName.trimmingCharacters(in: .whitespacesAndNewlines)
if scenicId > 0, !scenicName.isEmpty {
currentScenicName = scenicName
} else {
currentScenicName = ""
appStore.session.currentScenicId = 0
appStore.session.currentScenicName = ""
}
currentAppRole = appStore.session.currentAppRole
isMinimalTopRole = currentAppRole?.usesHomeMinimalTopLayout ?? false
}
///
func rebuildCommonMenus() {
let permissions = appStore.permissions.permissionItems()
let allMenus = HomeMenuCatalog.visibleMenus(from: permissions)
let accountScope = appStore.session.accountCachePrefix
let roleCode = appStore.session.roleCode
let savedURIs = commonMenuStore.savedCommonURIs(accountScope: accountScope, roleCode: roleCode)
if savedURIs.isEmpty, !permissions.isEmpty {
let defaultURIs = HomeCommonMenuStore.defaultCommonURIs(from: permissions)
commonMenuStore.saveCommonURIs(defaultURIs, accountScope: accountScope, roleCode: roleCode)
}
commonMenus = commonMenuStore.commonMenus(
from: allMenus,
permissions: permissions,
accountScope: accountScope,
roleCode: 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 {
hideOnlineStatusSwitchDialog()
isLocationReporting = true
notifyStateChange()
defer {
isLocationReporting = locationReportService.isReporting
notifyStateChange()
}
do {
let goingOnline = !isOnline
if let result = try await locationReportService.switchOnlineStatus(api: api) {
applyReportSuccess(result)
onShowMessage?(goingOnline ? "已切换到在线状态,正在上报位置" : "已切换到离线状态")
} else {
onShowMessage?("已切换到离线状态")
}
timeoutDialogTriggered = false
dismissedLocationTimeoutDialog = false
} catch let error as LocationReportServiceError {
onShowMessage?(error.localizedDescription)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
}
///
func manualReportLocation(api: HomeAPI) async {
isLocationReporting = true
notifyStateChange()
defer {
isLocationReporting = locationReportService.isReporting
notifyStateChange()
}
do {
let result = try await locationReportService.manualReport(api: api)
applyReportSuccess(result)
onShowMessage?("位置上报成功")
} catch let error as LocationReportServiceError {
onShowMessage?(error.localizedDescription)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
}
/// type=2
func reportLocation(
api: HomeAPI,
latitude: Double,
longitude: Double,
address: String?,
type: Int
) async {
isLocationReporting = true
notifyStateChange()
defer {
isLocationReporting = locationReportService.isReporting
notifyStateChange()
}
do {
let result = try await locationReportService.reportWithCoordinates(
api: api,
latitude: latitude,
longitude: longitude,
address: address,
type: type
)
applyReportSuccess(result)
onShowMessage?("位置上报成功")
} catch let error as LocationReportServiceError {
onShowMessage?(error.localizedDescription)
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
}
///
func confirmLocationTimeoutReport(api: HomeAPI) async {
showLocationTimeoutDialog = false
dismissedLocationTimeoutDialog = false
await manualReportLocation(api: api)
}
///
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 applyReportSuccess(_ result: LocationReportSuccessResult) {
locationInfoText = result.locationInfoText
if result.shouldShowSuccessDialog {
reportTimeText = result.reportTimeText
showLocationReportSuccessDialog = true
timeoutDialogTriggered = false
dismissedLocationTimeoutDialog = false
}
notifyStateChange()
}
private func notifyStateChange() {
onStateChange?()
}
}