接入首页位置上报与在线倒计时,并优化上报交互体验。

持久化服务端 expired 过期时间戳以正确恢复倒计时,切换在线/离线与上报时展示定位 Loading,移除重复的成功 Alert,并将 Toast 调整为居中半透明样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 15:59:59 +08:00
parent d9f897038d
commit 560b52fcc8
19 changed files with 1028 additions and 204 deletions

View File

@ -37,6 +37,17 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
`HomeView` 不创建自己的 `NavigationStack`,而是使用 Main Tab 注入的 `RouterPath` 进行页面跳转。
`HomeMoreFunctionsView` 使用系统导航栏展示标题和返回,以支持左边缘滑动返回。
## 位置上报
首页工作状态区域由 `HomeLocationViewModel` 驱动,与 `LocationReport` 详情页共享同一实例(在 `RootView` 注入)。
- 在线/离线切换、立即上报、2 小时倒计时和提前提醒均走真实 API。
- 本地状态通过 `LocationStateStore` 持久化在线标志、过期时间戳、上次上报时间和提醒分钟数。
- 首页大按钮直接触发 GPS 上报;菜单 `location_report` 仍进入详情页。
-`homeMinimalTopRoles` 角色才展示工作状态卡片。
## 后续迁移
目前 `payment_collection``payment_qr``payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`cooperation_order` 已由 `Features/CooperationOrder` 接管,`scenicselection``permission_apply``permission_apply_status``scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management``task_management_editor``task_create` 已由 `Features/Tasks` 接管,`cloud_management``cloud_storage_transit``asset_management``material_upload``album_list``album_trailer``sample_management``sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report``location_report_history` 已由 `Features/LocationReport` 接管,`pm``project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation``photographer_invite``invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail``deposit_order``deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement``scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue``queue_management` 已由 `Features/QueueManagement` 接管,`live_stream_management``live_album` 已由 `Features/Live` 接管,`operating-area` 已由 `Features/OperatingArea` 接管,`pilot_cert` 已由 `Features/PilotCertification` 接管。

View File

@ -0,0 +1,380 @@
//
// HomeLocationViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Combine
import CoreLocation
import Foundation
/// ViewModel线GPS
@MainActor
final class HomeLocationViewModel: ObservableObject {
@Published private(set) var isOnline = false
@Published private(set) var secondsUntilReport = 0
@Published private(set) var reminderMinutes = 0
@Published private(set) var isReporting = false
@Published var showTimeoutDialog = false
@Published var showReminderDialog = false
@Published var reminderDialogMessage = ""
@Published private(set) var reportTimeText = ""
private let api: any LocationReportServing
private let store: LocationStateStore
private var locationProvider: ForegroundLocationProvider?
private var lastReportAnchorTime: TimeInterval?
private var expireTimestamp: Int?
private var lastOperationTime: TimeInterval = 0
private var timeoutDialogTriggered = false
private var reminderTriggered = false
private var hasRestoredState = false
private let operationIntervalSeconds = 30
/// H:MM:SS
var countdownDisplay: String {
let hours = secondsUntilReport / 3600
let minutes = (secondsUntilReport % 3600) / 60
let seconds = secondsUntilReport % 60
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
}
/// ViewModel
nonisolated init(
api: any LocationReportServing,
store: LocationStateStore = LocationStateStore()
) {
self.api = api
self.store = store
}
/// 线
func restoreState() {
guard !hasRestoredState else { return }
hasRestoredState = true
reminderMinutes = store.loadReminderMinutes()
guard store.loadOnlineStatus() else {
isOnline = false
secondsUntilReport = 0
expireTimestamp = nil
return
}
let persistedExpireTime = store.loadExpireTime()
if persistedExpireTime > 0 {
let remaining = store.remainingSeconds(untilExpireTime: persistedExpireTime)
guard remaining > 0 else {
setOfflineLocally(clearPersisted: true)
reminderDialogMessage = "距离上次上报已超过两小时,请立即重新上报位置。"
showReminderDialog = true
return
}
isOnline = true
expireTimestamp = persistedExpireTime
secondsUntilReport = remaining
lastReportAnchorTime = store.loadLastReportTime()
evaluateReminderThreshold(remainingSeconds: remaining, forceDialog: false)
return
}
let lastTime = store.loadLastReportTime()
guard lastTime > 0 else {
setOfflineLocally()
return
}
let remaining = store.remainingSeconds(since: lastTime)
guard remaining > 0 else {
setOfflineLocally(clearPersisted: true)
reminderDialogMessage = "距离上次上报已超过两小时,请立即重新上报位置。"
showReminderDialog = true
return
}
isOnline = true
secondsUntilReport = remaining
lastReportAnchorTime = lastTime
evaluateReminderThreshold(remainingSeconds: remaining, forceDialog: false)
}
/// 线
func tick() {
guard isOnline, secondsUntilReport > 0 else { return }
secondsUntilReport -= 1
if secondsUntilReport <= 0 {
setOfflineLocally(clearPersisted: true)
reminderDialogMessage = "距离上次上报已超过两小时,请立即重新上报位置。"
showReminderDialog = true
return
}
evaluateReminderThreshold(remainingSeconds: secondsUntilReport, forceDialog: false)
}
///
func updateReminderMinutes(_ minutes: Int) {
reminderMinutes = minutes
store.saveReminderMinutes(minutes)
reminderTriggered = false
timeoutDialogTriggered = false
if let expireTime = expireTimestamp {
let remaining = store.remainingSeconds(untilExpireTime: expireTime)
evaluateReminderThreshold(remainingSeconds: remaining, forceDialog: false)
} else if let anchor = lastReportAnchorTime {
let remaining = store.remainingSeconds(since: anchor)
evaluateReminderThreshold(remainingSeconds: remaining, forceDialog: false)
}
}
/// 线线线线
func confirmOnlineToggle(staffId: Int?, scenicId: Int?, toast: ToastCenter) async {
guard enforceOperationInterval(for: .immediate, toast: toast) else { return }
guard validateScope(staffId: staffId, scenicId: scenicId, toast: toast) else { return }
if isOnline {
setOfflineLocally(clearPersisted: false)
store.saveOnlineStatus(false)
await reportLocation(type: .offlineReport, staffId: staffId!, scenicId: scenicId!, toast: toast)
} else {
store.saveOnlineStatus(true)
isOnline = true
await reportLocation(type: .immediate, staffId: staffId!, scenicId: scenicId!, toast: toast)
}
}
/// type=1
func startImmediateReport(staffId: Int?, scenicId: Int?, toast: ToastCenter) async {
guard enforceOperationInterval(for: .immediate, toast: toast) else { return }
guard validateScope(staffId: staffId, scenicId: scenicId, toast: toast) else { return }
await reportLocation(type: .immediate, staffId: staffId!, scenicId: scenicId!, toast: toast)
}
/// detail
func checkLocationTimeout(staffId: Int?) async {
guard reminderMinutes > 0 else {
showTimeoutDialog = false
return
}
guard let staffId, staffId > 0 else {
showTimeoutDialog = false
return
}
do {
let detail = try await api.locationDetail(staffId: staffId)
if detail.expireTime <= 0 {
showTimeoutDialog = true
return
}
let currentMillis = Date().timeIntervalSince1970 * 1000
let expireMillis = Double(detail.expireTime) * 1000
let thresholdMillis = expireMillis - Double(reminderMinutes * 60 * 1000)
showTimeoutDialog = currentMillis >= thresholdMillis
} catch {
showTimeoutDialog = true
}
}
/// 线
func dismissTimeoutDialog() {
showTimeoutDialog = false
timeoutDialogTriggered = false
setOfflineLocally(clearPersisted: true)
}
///
func dismissReminderDialog() {
showReminderDialog = false
}
///
func handleReportSuccess(expired: Int, type: LocationReportType) {
guard type != .offlineReport else { return }
beginOnlineCountdown(expired: expired)
}
///
func reportCoordinate(
latitude: Double,
longitude: Double,
address: String,
type: LocationReportType,
staffId: Int?,
scenicId: Int?,
toast: ToastCenter
) async -> Bool {
guard !isReporting else { return false }
if type != .offlineReport {
guard enforceOperationInterval(for: type, toast: toast) else { return false }
}
guard validateScope(staffId: staffId, scenicId: scenicId, toast: toast) else { return false }
isReporting = true
defer { isReporting = false }
do {
let response = try await api.reportLocation(
staffId: staffId!,
latitude: latitude,
longitude: longitude,
address: address,
type: type,
scenicId: scenicId!
)
lastOperationTime = Date().timeIntervalSince1970 * 1000
if type != .offlineReport {
beginOnlineCountdown(expired: response.expired)
}
showTimeoutDialog = false
timeoutDialogTriggered = false
reminderTriggered = false
return true
} catch {
toast.show("位置上报失败:\(error.localizedDescription)")
return false
}
}
///
private func reportLocation(
type: LocationReportType,
staffId: Int,
scenicId: Int,
toast: ToastCenter
) async {
guard hasLocationPermission() else {
toast.show("需要位置权限才能上报位置")
return
}
isReporting = true
defer { isReporting = false }
do {
let location = try await activeLocationProvider().requestCurrentLocation()
let response = try await api.reportLocation(
staffId: staffId,
latitude: location.latitude,
longitude: location.longitude,
address: location.address,
type: type,
scenicId: scenicId
)
lastOperationTime = Date().timeIntervalSince1970 * 1000
if type != .offlineReport {
beginOnlineCountdown(expired: response.expired)
toast.show("位置上报成功")
} else {
toast.show("已切换到离线状态")
}
showTimeoutDialog = false
timeoutDialogTriggered = false
reminderTriggered = false
} catch {
toast.show(type == .offlineReport ? "离线上报失败:\(error.localizedDescription)" : "位置上报失败:\(error.localizedDescription)")
}
}
/// Provider
private func activeLocationProvider() -> ForegroundLocationProvider {
if let locationProvider {
return locationProvider
}
let provider = ForegroundLocationProvider()
locationProvider = provider
return provider
}
/// expired 线
private func beginOnlineCountdown(expired: Int) {
let now = Date().timeIntervalSince1970 * 1000
let expireTime = store.normalizedExpireTimestamp(from: expired)
lastReportAnchorTime = now
expireTimestamp = expireTime
store.saveLastReportTime(now)
store.saveExpireTime(expireTime)
store.saveOnlineStatus(true)
isOnline = true
secondsUntilReport = store.remainingSeconds(untilExpireTime: expireTime)
reportTimeText = Self.timeFormatter.string(from: Date())
}
/// 线
private func setOfflineLocally(clearPersisted: Bool = true) {
isOnline = false
secondsUntilReport = 0
lastReportAnchorTime = nil
expireTimestamp = nil
if clearPersisted {
store.saveOnlineStatus(false)
store.clearLastReportTime()
store.clearExpireTime()
}
}
/// staffId scenicId
private func validateScope(staffId: Int?, scenicId: Int?, toast: ToastCenter) -> Bool {
guard let scenicId, scenicId > 0 else {
toast.show("请先选择景区")
return false
}
guard let staffId, staffId > 0 else {
toast.show("获取用户ID失败")
return false
}
return true
}
/// 30 type=3
private func enforceOperationInterval(for type: LocationReportType, toast: ToastCenter) -> Bool {
guard type != .offlineReport else { return true }
let now = Date().timeIntervalSince1970 * 1000
let elapsed = now - lastOperationTime
guard lastOperationTime > 0, elapsed < Double(operationIntervalSeconds * 1000) else { return true }
let remaining = Int((Double(operationIntervalSeconds * 1000) - elapsed) / 1000)
toast.show("操作过于频繁,请\(max(remaining, 1))秒后再试")
return false
}
///
private func evaluateReminderThreshold(remainingSeconds: Int, forceDialog: Bool) {
guard reminderMinutes > 0 else { return }
let thresholdSeconds = reminderMinutes * 60
guard remainingSeconds <= thresholdSeconds else { return }
if !reminderTriggered {
reminderTriggered = true
reminderDialogMessage = "倒计时即将结束,请尽快重新上报位置。"
showReminderDialog = true
}
if !timeoutDialogTriggered || forceDialog {
timeoutDialogTriggered = true
showTimeoutDialog = true
}
}
///
private func hasLocationPermission() -> Bool {
switch CLLocationManager.authorizationStatus() {
case .authorizedAlways, .authorizedWhenInUse:
true
default:
false
}
}
private static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm"
return formatter
}()
}

View File

@ -13,7 +13,6 @@ struct HomeMoreFunctionsView: View {
@EnvironmentObject private var permissionContext: PermissionContext
@EnvironmentObject private var appRouter: AppRouter
@EnvironmentObject private var router: RouterPath
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = HomeViewModel()
@State private var commonUris: [String] = []
@ -31,22 +30,18 @@ struct HomeMoreFunctionsView: View {
}
var body: some View {
VStack(spacing: 0) {
topBar
ScrollView {
VStack(alignment: .leading, spacing: 26) {
section(title: "常用应用", items: commonItems, isCommon: true)
section(title: "更多功能", items: moreItems, isCommon: false)
}
.padding(.horizontal, AppMetrics.Spacing.mediumLarge)
.padding(.top, AppMetrics.Spacing.xxLarge + 1)
.padding(.bottom, AppMetrics.Spacing.xxLarge)
ScrollView {
VStack(alignment: .leading, spacing: 26) {
section(title: "常用应用", items: commonItems, isCommon: true)
section(title: "更多功能", items: moreItems, isCommon: false)
}
.background(Color(hex: 0xF5F5F5))
.padding(.horizontal, AppMetrics.Spacing.mediumLarge)
.padding(.top, AppMetrics.Spacing.medium)
.padding(.bottom, AppMetrics.Spacing.xxLarge)
}
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
.toolbar(.hidden, for: .navigationBar)
.background(Color(hex: 0xF5F5F5))
.navigationTitle("全部功能")
.navigationBarTitleDisplayMode(.inline)
.task {
rebuildMenus()
}
@ -58,37 +53,6 @@ struct HomeMoreFunctionsView: View {
}
}
private var topBar: some View {
ZStack {
Text("全部功能")
.font(.system(size: AppMetrics.FontSize.title2 + 1, weight: .semibold))
.foregroundStyle(Color(hex: 0x2C2C2C))
.frame(maxWidth: .infinity)
HStack {
Button {
dismiss()
} label: {
Image(systemName: "chevron.left")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(Color(hex: 0x111827))
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
}
.buttonStyle(.plain)
Spacer()
}
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
}
.frame(height: 62)
.background(.white)
.overlay(alignment: .bottom) {
Rectangle()
.fill(Color.black.opacity(0.06))
.frame(height: 0.5)
}
}
private func section(title: String, items: [HomeMenuItem], isCommon: Bool) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
Text(title)

View File

@ -16,14 +16,15 @@ struct HomeView: View {
@EnvironmentObject private var router: RouterPath
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@EnvironmentObject private var toastCenter: ToastCenter
@EnvironmentObject private var homeLocationViewModel: HomeLocationViewModel
@Environment(\.globalLoading) private var globalLoading
@Environment(\.accountSnapshotStore) private var snapshotStore
@StateObject private var viewModel = HomeViewModel()
@State private var commonUris: [String] = []
@State private var isOnline = false
@State private var reminderMinutes = 0
@State private var secondsUntilReport = 0
@State private var showOnlineDialog = false
@State private var showReminderDialog = false
@State private var showReportSuccess = false
private let commonMenuStore = HomeCommonMenuStore()
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@ -46,14 +47,19 @@ struct HomeView: View {
}
private var countdownDisplay: String {
let hours = secondsUntilReport / 3_600
let minutes = (secondsUntilReport % 3_600) / 60
let seconds = secondsUntilReport % 60
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
homeLocationViewModel.countdownDisplay
}
private var reminderText: String {
reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟"
homeLocationViewModel.reminderMinutes == 0 ? "不提醒" : "提前\(homeLocationViewModel.reminderMinutes)分钟"
}
private var staffId: Int? {
snapshotStore.load()?.businessUserId
}
private var scenicId: Int? {
accountContext.currentScenic?.id
}
private var tileHeight: CGFloat {
@ -102,10 +108,11 @@ struct HomeView: View {
.toolbar(.hidden, for: .navigationBar)
.task {
rebuildMenusFromCurrentContext()
homeLocationViewModel.restoreState()
await homeLocationViewModel.checkLocationTimeout(staffId: staffId)
}
.onReceive(timer) { _ in
guard isOnline, secondsUntilReport > 0 else { return }
secondsUntilReport -= 1
homeLocationViewModel.tick()
}
.onChange(of: permissionContext.currentRole?.roleCode) { _ in
rebuildMenusFromCurrentContext()
@ -116,26 +123,58 @@ struct HomeView: View {
.alert("切换在线状态", isPresented: $showOnlineDialog) {
Button("取消", role: .cancel) {}
Button("确定") {
isOnline.toggle()
if isOnline {
secondsUntilReport = 7_200
runLocationReportTask {
await homeLocationViewModel.confirmOnlineToggle(
staffId: staffId,
scenicId: scenicId,
toast: toastCenter
)
}
}
} message: {
Text(isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。")
Text(homeLocationViewModel.isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。")
}
.confirmationDialog("提前提醒时间", isPresented: $showReminderDialog, titleVisibility: .visible) {
ForEach([0, 5, 10, 15, 30], id: \.self) { minute in
Button(minute == 0 ? "不提醒" : "\(minute)分钟") {
reminderMinutes = minute
homeLocationViewModel.updateReminderMinutes(minute)
}
}
Button("取消", role: .cancel) {}
}
.alert("上报成功", isPresented: $showReportSuccess) {
Button("知道了", role: .cancel) {}
.alert("位置上报提醒", isPresented: $homeLocationViewModel.showReminderDialog) {
Button("立即上报") {
homeLocationViewModel.dismissReminderDialog()
runLocationReportTask {
await homeLocationViewModel.startImmediateReport(
staffId: staffId,
scenicId: scenicId,
toast: toastCenter
)
}
}
Button("稍后", role: .cancel) {
homeLocationViewModel.dismissReminderDialog()
}
} message: {
Text("上报已成功,距离下次上报时间 \(countdownDisplay)")
Text(homeLocationViewModel.reminderDialogMessage)
}
.alert("位置上报超时", isPresented: $homeLocationViewModel.showTimeoutDialog) {
Button("立即上报") {
homeLocationViewModel.showTimeoutDialog = false
runLocationReportTask {
await homeLocationViewModel.startImmediateReport(
staffId: staffId,
scenicId: scenicId,
toast: toastCenter
)
}
}
Button("取消", role: .cancel) {
homeLocationViewModel.dismissTimeoutDialog()
}
} message: {
Text("位置上报即将超时或已超时,请立即重新上报。")
}
}
@ -169,12 +208,12 @@ struct HomeView: View {
Button {
showOnlineDialog = true
} label: {
Text(isOnline ? "在线" : "离线")
Text(homeLocationViewModel.isOnline ? "在线" : "离线")
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(isOnline ? Color(hex: 0xF0FDF4) : Color(hex: 0x7B8EAA))
.foregroundStyle(homeLocationViewModel.isOnline ? Color(hex: 0xF0FDF4) : Color(hex: 0x7B8EAA))
.padding(.horizontal, AppMetrics.Spacing.small)
.padding(.vertical, AppMetrics.Spacing.xxSmall + 2)
.background(isOnline ? Color(hex: 0x22C55E) : Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 4))
.background(homeLocationViewModel.isOnline ? Color(hex: 0x22C55E) : Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 4))
}
.buttonStyle(.plain)
@ -235,7 +274,13 @@ struct HomeView: View {
Spacer()
Button {
openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报"))
runLocationReportTask {
await homeLocationViewModel.startImmediateReport(
staffId: staffId,
scenicId: scenicId,
toast: toastCenter
)
}
} label: {
Image(systemName: "hand.tap.fill")
.font(.system(size: 38, weight: .semibold))
@ -269,7 +314,7 @@ struct HomeView: View {
openRoute(HomeMenuRouter.resolve(uri: "task_create", title: "提交任务"))
}
quickAction(icon: isOnline ? "wifi" : "wifi.slash", title: isOnline ? "在线" : "离线", active: isOnline) {
quickAction(icon: homeLocationViewModel.isOnline ? "wifi" : "wifi.slash", title: homeLocationViewModel.isOnline ? "在线" : "离线", active: homeLocationViewModel.isOnline) {
showOnlineDialog = true
}
}
@ -423,6 +468,15 @@ struct HomeView: View {
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
)
}
/// GPS Loading
private func runLocationReportTask(_ body: @escaping () async -> Void) {
Task {
await globalLoading.withLoading(message: "正在定位...") {
await body()
}
}
}
}
#Preview {
@ -432,5 +486,8 @@ struct HomeView: View {
.environmentObject(PermissionContext())
.environmentObject(AppRouter())
.environmentObject(RouterPath())
.environmentObject(ToastCenter())
.environmentObject(HomeLocationViewModel(api: LocationReportAPI(client: APIClient())))
.environment(\.accountSnapshotStore, AccountSnapshotStore())
}
}