接入首页位置上报与在线倒计时,并优化上报交互体验。
持久化服务端 expired 过期时间戳以正确恢复倒计时,切换在线/离线与上报时展示定位 Loading,移除重复的成功 Alert,并将 Toast 调整为居中半透明样式。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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` 接管。
|
||||
|
||||
380
suixinkan/Features/Home/ViewModels/HomeLocationViewModel.swift
Normal file
380
suixinkan/Features/Home/ViewModels/HomeLocationViewModel.swift
Normal 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
|
||||
}()
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,9 @@ protocol LocationReportServing {
|
||||
|
||||
/// 获取位置上报历史列表。
|
||||
func locationReportList(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?) async throws -> ListPayload<LocationReportHistoryItem>
|
||||
|
||||
/// 获取位置上报详情,用于超时提醒判断。
|
||||
func locationDetail(staffId: Int) async throws -> LocationDetailResponse
|
||||
}
|
||||
|
||||
/// 位置上报 API,负责封装旧工程定位上报相关接口。
|
||||
@ -78,4 +81,15 @@ final class LocationReportAPI: LocationReportServing {
|
||||
APIRequest(method: .get, path: "/api/yf-handset-app/photog/loacation/list", queryItems: query)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取位置上报详情。
|
||||
func locationDetail(staffId: Int) async throws -> LocationDetailResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/loacation/detail",
|
||||
queryItems: [URLQueryItem(name: "staff_id", value: "\(staffId)")]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
LocationReport 模块负责首页 `location_report` 和 `location_report_history` 入口。
|
||||
|
||||
本模块包含当前位置获取、标记点、在线状态、立即上报、提醒设置、历史记录、类型筛选、日期筛选和分页。上报状态只保存在页面 ViewModel 内,不进入全局 App 状态。
|
||||
本模块包含当前位置获取、标记点、在线状态、立即上报、提醒设置、历史记录、类型筛选、日期筛选和分页。在线状态、倒计时和提醒设置与首页共享 `HomeLocationViewModel`。
|
||||
|
||||
## 数据来源
|
||||
|
||||
@ -12,27 +12,28 @@ LocationReport 模块负责首页 `location_report` 和 `location_report_history
|
||||
- 上报人员 ID 从 `AccountSnapshotStore.load()?.businessUserId` 读取,避免把业务账号 ID 复制进页面全局状态。
|
||||
- 提交接口使用旧工程拼写 `/api/yf-handset-app/photog/loacation/report`。
|
||||
- 历史接口使用 `/api/yf-handset-app/photog/loacation/list`。
|
||||
- 超时检查接口使用 `/api/yf-handset-app/photog/loacation/detail`。
|
||||
|
||||
## 上报流程
|
||||
|
||||
`LocationReportViewModel` 管理当前位置、标记点、在线状态、提醒分钟数和页面内倒计时。
|
||||
`LocationReportViewModel` 管理当前位置、标记点和提交表单;`HomeLocationViewModel` 管理在线状态、倒计时、提醒和 API 提交。
|
||||
|
||||
1. 页面进入时请求一次前台定位。
|
||||
2. 用户可以立即上报当前位置,也可以把当前位置设为标记点后上报。
|
||||
3. 切换到在线状态时提交一次在线状态上报。
|
||||
4. 上报成功后按服务端 `expired` 重置倒计时;服务端未返回时兜底为 2 小时。
|
||||
3. 切换在线时提交 `type=1` 立即上报;切换离线时提交 `type=3` 离线上报。
|
||||
4. 上报成功后按服务端 `expired`(过期 Unix 时间戳)重置倒计时,并持久化到本地;下次启动优先用本地过期时间戳计算剩余时间。服务端未返回时兜底为 2 小时。
|
||||
|
||||
## 历史流程
|
||||
|
||||
`LocationReportHistoryViewModel` 管理类型筛选、日期筛选、分页和错误状态。
|
||||
|
||||
历史记录支持 `全部`、`立即上报`、`标记点`、`在线状态` 四类筛选。分页失败只保留错误提示,不影响登录态和其他模块。
|
||||
历史记录支持 `全部`、`立即上报`、`标记点`、`离线上报` 四类筛选。分页失败只保留错误提示,不影响登录态和其他模块。
|
||||
|
||||
## 定位边界
|
||||
|
||||
当前只做前台手动定位和页面内倒计时。本轮不做后台持续定位、后台定时上报、离线持久化队列或推送提醒。
|
||||
|
||||
定位结果、在线状态、提醒倒计时和提交表单均不落盘。
|
||||
在线状态、过期时间戳、上次上报时间和提醒分钟通过 `LocationStateStore` 持久化;坐标表单仍只在页面内维护。
|
||||
|
||||
## 测试要求
|
||||
|
||||
|
||||
@ -7,12 +7,12 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 位置上报类型实体,表示立即上报、标记点上报和在线状态上报。
|
||||
/// 位置上报类型实体,表示立即上报、标记点上报和离线上报。
|
||||
enum LocationReportType: Int, CaseIterable, Identifiable {
|
||||
case all = 0
|
||||
case immediate = 1
|
||||
case marked = 2
|
||||
case onlineStatus = 3
|
||||
case offlineReport = 3
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
@ -22,7 +22,7 @@ enum LocationReportType: Int, CaseIterable, Identifiable {
|
||||
case .all: "全部"
|
||||
case .immediate: "立即上报"
|
||||
case .marked: "标记点"
|
||||
case .onlineStatus: "在线状态"
|
||||
case .offlineReport: "离线上报"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -33,7 +33,40 @@ struct LocationCoordinate: Equatable {
|
||||
var longitude: Double
|
||||
}
|
||||
|
||||
/// 位置上报提交响应实体,表示服务端返回的上报人员、过期秒数和状态。
|
||||
/// 位置上报详情响应实体,表示服务端返回的在线状态与过期时间。
|
||||
struct LocationDetailResponse: Decodable, Equatable {
|
||||
let status: Int
|
||||
let needReport: Bool
|
||||
let expireTime: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case status
|
||||
case needReport = "need_report"
|
||||
case expireTime = "expire_time"
|
||||
}
|
||||
|
||||
/// 创建位置上报详情响应,主要用于测试替身返回。
|
||||
init(status: Int, needReport: Bool, expireTime: Int) {
|
||||
self.status = status
|
||||
self.needReport = needReport
|
||||
self.expireTime = expireTime
|
||||
}
|
||||
|
||||
/// 宽松解码详情响应字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
if let boolValue = try? container.decodeIfPresent(Bool.self, forKey: .needReport) {
|
||||
needReport = boolValue
|
||||
} else {
|
||||
let raw = try container.decodeLossyInt(forKey: .needReport) ?? 0
|
||||
needReport = raw != 0
|
||||
}
|
||||
expireTime = try container.decodeLossyInt(forKey: .expireTime) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 位置上报提交响应实体,表示服务端返回的上报人员、过期时间戳和状态。
|
||||
struct LocationReportSubmitResponse: Decodable, Equatable {
|
||||
let staffId: String
|
||||
let expired: Int
|
||||
|
||||
@ -8,28 +8,16 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// 位置上报 ViewModel,负责页面内在线状态、坐标、倒计时和提交动作。
|
||||
/// 位置上报 ViewModel,负责页面内坐标、标记点和提交表单。
|
||||
@MainActor
|
||||
final class LocationReportViewModel: ObservableObject {
|
||||
@Published var isOnline = false
|
||||
@Published var reminderMinutes = 30
|
||||
@Published var secondsUntilReport = 0
|
||||
@Published var currentCoordinate: LocationCoordinate?
|
||||
@Published var markedCoordinate: LocationCoordinate?
|
||||
@Published var currentAddress = ""
|
||||
@Published var markedAddress = ""
|
||||
@Published var lastReportText = ""
|
||||
@Published var errorMessage: String?
|
||||
@Published var isSubmitting = false
|
||||
|
||||
/// 倒计时展示文案。
|
||||
var countdownText: String {
|
||||
guard secondsUntilReport > 0 else { return "可立即上报" }
|
||||
let hours = secondsUntilReport / 3600
|
||||
let minutes = (secondsUntilReport % 3600) / 60
|
||||
return "\(hours)小时\(minutes)分钟后可再次提醒"
|
||||
}
|
||||
|
||||
/// 设置当前位置。
|
||||
func applyCurrentLocation(latitude: Double, longitude: Double, address: String) {
|
||||
currentCoordinate = LocationCoordinate(latitude: latitude, longitude: longitude)
|
||||
@ -42,24 +30,13 @@ final class LocationReportViewModel: ObservableObject {
|
||||
markedAddress = address
|
||||
}
|
||||
|
||||
/// 切换在线状态;切到在线时会提交一次状态上报。
|
||||
func setOnline(
|
||||
_ value: Bool,
|
||||
staffId: Int?,
|
||||
scenicId: Int?,
|
||||
api: any LocationReportServing
|
||||
) async -> Bool {
|
||||
isOnline = value
|
||||
guard value else { return true }
|
||||
return await submit(type: .onlineStatus, staffId: staffId, scenicId: scenicId, api: api)
|
||||
}
|
||||
|
||||
/// 提交指定类型的位置上报。
|
||||
func submit(
|
||||
type: LocationReportType,
|
||||
staffId: Int?,
|
||||
scenicId: Int?,
|
||||
api: any LocationReportServing
|
||||
homeLocationViewModel: HomeLocationViewModel,
|
||||
toast: ToastCenter
|
||||
) async -> Bool {
|
||||
guard !isSubmitting else { return false }
|
||||
guard let staffId, staffId > 0 else {
|
||||
@ -86,28 +63,19 @@ final class LocationReportViewModel: ObservableObject {
|
||||
errorMessage = nil
|
||||
defer { isSubmitting = false }
|
||||
|
||||
do {
|
||||
let response = try await api.reportLocation(
|
||||
staffId: staffId,
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude,
|
||||
address: address,
|
||||
type: type,
|
||||
scenicId: scenicId
|
||||
)
|
||||
secondsUntilReport = response.expired > 0 ? response.expired : 7200
|
||||
lastReportText = LocationReportViewModel.displayFormatter.string(from: Date())
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
let success = await homeLocationViewModel.reportCoordinate(
|
||||
latitude: coordinate.latitude,
|
||||
longitude: coordinate.longitude,
|
||||
address: address,
|
||||
type: type,
|
||||
staffId: staffId,
|
||||
scenicId: scenicId,
|
||||
toast: toast
|
||||
)
|
||||
if !success, errorMessage == nil {
|
||||
errorMessage = "上报失败"
|
||||
}
|
||||
}
|
||||
|
||||
/// 页面内倒计时每秒递减。
|
||||
func tick() {
|
||||
guard secondsUntilReport > 0 else { return }
|
||||
secondsUntilReport -= 1
|
||||
return success
|
||||
}
|
||||
|
||||
/// 根据上报类型选择当前坐标或标记点坐标。
|
||||
@ -115,16 +83,10 @@ final class LocationReportViewModel: ObservableObject {
|
||||
switch type {
|
||||
case .marked:
|
||||
(markedCoordinate ?? currentCoordinate, markedAddress.isEmpty ? currentAddress : markedAddress)
|
||||
case .all, .immediate, .onlineStatus:
|
||||
case .all, .immediate, .offlineReport:
|
||||
(currentCoordinate, currentAddress)
|
||||
}
|
||||
}
|
||||
|
||||
private static let displayFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
/// 位置上报历史 ViewModel,负责筛选、日期和分页加载。
|
||||
|
||||
@ -11,6 +11,7 @@ import SwiftUI
|
||||
/// 位置上报页面,支持当前位置上报、标记点上报、在线状态和提醒设置。
|
||||
struct LocationReportView: View {
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var homeLocationViewModel: HomeLocationViewModel
|
||||
@Environment(\.accountSnapshotStore) private var snapshotStore
|
||||
@Environment(\.locationReportAPI) private var locationReportAPI
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@ -19,6 +20,8 @@ struct LocationReportView: View {
|
||||
|
||||
@StateObject private var viewModel = LocationReportViewModel()
|
||||
@State private var locationProvider = ForegroundLocationProvider()
|
||||
@State private var showOnlineDialog = false
|
||||
@State private var showReminderDialog = false
|
||||
private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
@ -46,11 +49,36 @@ struct LocationReportView: View {
|
||||
.accessibilityLabel("上报历史")
|
||||
}
|
||||
}
|
||||
.onReceive(timer) { _ in viewModel.tick() }
|
||||
.onReceive(timer) { _ in homeLocationViewModel.tick() }
|
||||
.task {
|
||||
homeLocationViewModel.restoreState()
|
||||
guard viewModel.currentCoordinate == nil else { return }
|
||||
await locateCurrent()
|
||||
}
|
||||
.alert("切换在线状态", isPresented: $showOnlineDialog) {
|
||||
Button("取消", role: .cancel) {}
|
||||
Button("确定") {
|
||||
Task {
|
||||
await globalLoading.withLoading(message: "正在定位...") {
|
||||
await homeLocationViewModel.confirmOnlineToggle(
|
||||
staffId: staffId,
|
||||
scenicId: scenicId,
|
||||
toast: toastCenter
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(homeLocationViewModel.isOnline ? "是否确认切换为离线状态?离线后将暂停位置上报。" : "是否确认切换为在线状态?在线后将开始位置上报和计时。")
|
||||
}
|
||||
.confirmationDialog("提前提醒时间", isPresented: $showReminderDialog, titleVisibility: .visible) {
|
||||
ForEach([0, 5, 10, 15, 30], id: \.self) { minute in
|
||||
Button(minute == 0 ? "不提醒" : "\(minute)分钟") {
|
||||
homeLocationViewModel.updateReminderMinutes(minute)
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 在线状态和倒计时区域。
|
||||
@ -58,24 +86,28 @@ struct LocationReportView: View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(viewModel.isOnline ? "当前在线" : "当前离线")
|
||||
Text(homeLocationViewModel.isOnline ? "当前在线" : "当前离线")
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text(viewModel.countdownText)
|
||||
Text(countdownText)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: Binding(
|
||||
get: { viewModel.isOnline },
|
||||
set: { value in
|
||||
Task { await toggleOnline(value) }
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
Button {
|
||||
showOnlineDialog = true
|
||||
} label: {
|
||||
Text(homeLocationViewModel.isOnline ? "在线" : "离线")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(homeLocationViewModel.isOnline ? Color(hex: 0xF0FDF4) : Color(hex: 0x7B8EAA))
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.padding(.vertical, AppMetrics.Spacing.xxSmall + 2)
|
||||
.background(homeLocationViewModel.isOnline ? Color(hex: 0x22C55E) : Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if !viewModel.lastReportText.isEmpty {
|
||||
Text("上次上报:\(viewModel.lastReportText)")
|
||||
if !homeLocationViewModel.reportTimeText.isEmpty {
|
||||
Text("上次上报:\(homeLocationViewModel.reportTimeText)")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
}
|
||||
@ -84,6 +116,13 @@ struct LocationReportView: View {
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
private var countdownText: String {
|
||||
guard homeLocationViewModel.secondsUntilReport > 0 else { return "可立即上报" }
|
||||
let hours = homeLocationViewModel.secondsUntilReport / 3600
|
||||
let minutes = (homeLocationViewModel.secondsUntilReport % 3600) / 60
|
||||
return "\(hours)小时\(minutes)分钟后可再次提醒"
|
||||
}
|
||||
|
||||
/// 当前定位区域。
|
||||
private var currentLocationSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
@ -149,8 +188,14 @@ struct LocationReportView: View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text("提醒设置")
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
|
||||
Stepper("提前 \(viewModel.reminderMinutes) 分钟提醒", value: $viewModel.reminderMinutes, in: 5...120, step: 5)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
Button {
|
||||
showReminderDialog = true
|
||||
} label: {
|
||||
Text(homeLocationViewModel.reminderMinutes == 0 ? "不提醒" : "提前 \(homeLocationViewModel.reminderMinutes) 分钟提醒")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
@ -177,7 +222,7 @@ struct LocationReportView: View {
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.disabled(viewModel.isSubmitting)
|
||||
.disabled(viewModel.isSubmitting || homeLocationViewModel.isReporting)
|
||||
}
|
||||
|
||||
/// 坐标和地址展示。
|
||||
@ -211,20 +256,16 @@ struct LocationReportView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换在线状态。
|
||||
private func toggleOnline(_ value: Bool) async {
|
||||
let success = await globalLoading.withOptionalLoading(value) {
|
||||
await viewModel.setOnline(value, staffId: staffId, scenicId: scenicId, api: locationReportAPI)
|
||||
}
|
||||
if !success {
|
||||
toastCenter.show(viewModel.errorMessage ?? "状态上报失败")
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交指定类型的位置上报。
|
||||
private func submit(type: LocationReportType) async {
|
||||
let success = await globalLoading.withLoading {
|
||||
await viewModel.submit(type: type, staffId: staffId, scenicId: scenicId, api: locationReportAPI)
|
||||
await viewModel.submit(
|
||||
type: type,
|
||||
staffId: staffId,
|
||||
scenicId: scenicId,
|
||||
homeLocationViewModel: homeLocationViewModel,
|
||||
toast: toastCenter
|
||||
)
|
||||
}
|
||||
toastCenter.show(success ? "上报成功" : (viewModel.errorMessage ?? "上报失败"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user