Files
suixinkan_uikit/suixinkan/Features/Home/ViewModels/HomeViewModel.swift
T
lujiuyinandCursor caeeb9a1cf feat: 接入消息未读数、全部已读与首页红点角标同步。
固定消息中心为首页入口,并在推送到达、已读后刷新桌面角标。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 18:02:43 +08:00

474 lines
16 KiB
Swift

//
// 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
private(set) var unreadMessageCount = 0
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 }
var hasUnreadMessages: Bool { unreadMessageCount > 0 }
/// 首次进入首页时加载权限并恢复在线状态。
func initialize(api: HomeAPI) async {
locationStateStore.restoreStateIfNeeded()
refreshLocalDisplayState()
await loadPermissions(api: api, force: true)
}
/// 账号切换后标记需要重新拉取权限。
func markNeedsPermissionReload() {
needsPermissionReload = true
unreadMessageCount = 0
}
/// 查询未读消息总数,成功时更新首页状态并返回 `true`。
func refreshUnreadMessageStatus(api: any MessageCenterServing) async -> Bool {
do {
let response = try await api.unreadCount()
unreadMessageCount = response.unreadCount
notifyStateChange()
return true
} catch is CancellationError {
return false
} catch {
// 查询失败时保留上一次状态,避免网络抖动导致红点错误消失。
return false
}
}
/// 使用已知的最新未读消息数量立即刷新首页状态。
func updateUnreadMessageCount(_ count: Int) {
unreadMessageCount = max(count, 0)
notifyStateChange()
}
/// 查询待审核记录并决定进入申请页或审核状态页。
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?()
}
}