From 560b52fcc8d1e86cb5dcd8b8d83764e41e3f773d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 29 Jun 2026 15:59:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E9=A6=96=E9=A1=B5=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=8A=E6=8A=A5=E4=B8=8E=E5=9C=A8=E7=BA=BF=E5=80=92?= =?UTF-8?q?=E8=AE=A1=E6=97=B6=EF=BC=8C=E5=B9=B6=E4=BC=98=E5=8C=96=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 持久化服务端 expired 过期时间戳以正确恢复倒计时,切换在线/离线与上报时展示定位 Loading,移除重复的成功 Alert,并将 Toast 调整为居中半透明样式。 Co-authored-by: Cursor --- suixinkan.xcodeproj/project.pbxproj | 8 +- suixinkan/App/AMapConfig.swift | 13 + suixinkan/App/App.md | 2 +- suixinkan/App/RootView.swift | 6 +- suixinkan/App/State/ToastCenter.swift | 36 +- suixinkan/App/suixinkanApp.swift | 1 + suixinkan/Core/Core.md | 2 +- .../Core/Location/LocationStateStore.swift | 108 +++++ suixinkan/Features/Home/Home.md | 11 + .../ViewModels/HomeLocationViewModel.swift | 380 ++++++++++++++++++ .../Home/Views/HomeMoreFunctionsView.swift | 56 +-- suixinkan/Features/Home/Views/HomeView.swift | 105 +++-- .../API/LocationReportAPI.swift | 14 + .../Features/LocationReport/LocationReport.md | 13 +- .../Models/LocationReportModels.swift | 41 +- .../ViewModels/LocationReportViewModels.swift | 70 +--- .../Views/LocationReportViews.swift | 93 +++-- .../LocationReport/LocationReportTests.swift | 271 ++++++++++++- suixinkanUITests/Support/BackNavigator.swift | 2 +- 19 files changed, 1028 insertions(+), 204 deletions(-) create mode 100644 suixinkan/App/AMapConfig.swift create mode 100644 suixinkan/Core/Location/LocationStateStore.swift create mode 100644 suixinkan/Features/Home/ViewModels/HomeLocationViewModel.swift diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 45a585d..f20a244 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -64,15 +64,11 @@ }; A00000022FE9000000000002 /* suixinkanTests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkanTests; sourceTree = ""; }; B00000022FEF000000000002 /* suixinkanUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkanUITests; sourceTree = ""; }; @@ -328,10 +324,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; diff --git a/suixinkan/App/AMapConfig.swift b/suixinkan/App/AMapConfig.swift new file mode 100644 index 0000000..32141ca --- /dev/null +++ b/suixinkan/App/AMapConfig.swift @@ -0,0 +1,13 @@ +// +// AMapConfig.swift +// suixinkan +// +// 高德地图 Key 配置。请在高德控制台申请 iOS 平台 Key 后替换 apiKey。 +// + +import Foundation + +enum AMapConfig { + /// 占位 Key,与 Android 工程一致;上线前请替换为 iOS 平台专用 Key。 + static let apiKey = "79e82527e55a4927992f79efe93c112b" +} diff --git a/suixinkan/App/App.md b/suixinkan/App/App.md index e4e0bdf..55369d5 100644 --- a/suixinkan/App/App.md +++ b/suixinkan/App/App.md @@ -84,7 +84,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航 全局 Toast 由 `RootView` 挂载在页面最上层,业务页面只调用 `toastCenter.show(...)` 发出提示命令。 -Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。 +Toast 展示为屏幕中央的黑色半透明圆角卡片(`Color.black.opacity(0.78)`),左侧带警告图标,文案 16pt 展示,以透明度淡入淡出,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。 ## 导航规则 diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index a9d1c0e..6a369b3 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -44,6 +44,7 @@ struct RootView: View { @State private var assetsAPI: AssetsAPI @State private var punchPointAPI: PunchPointAPI @State private var locationReportAPI: LocationReportAPI + @StateObject private var homeLocationViewModel: HomeLocationViewModel @State private var cooperationOrderAPI: CooperationOrderAPI @State private var authSessionCoordinator: AuthSessionCoordinator @State private var sessionBootstrapper: SessionBootstrapper @@ -82,7 +83,9 @@ struct RootView: View { _inviteAPI = State(initialValue: InviteAPI(client: apiClient)) _assetsAPI = State(initialValue: AssetsAPI(client: apiClient)) _punchPointAPI = State(initialValue: PunchPointAPI(client: apiClient)) - _locationReportAPI = State(initialValue: LocationReportAPI(client: apiClient)) + let locationReportAPI = LocationReportAPI(client: apiClient) + _locationReportAPI = State(initialValue: locationReportAPI) + _homeLocationViewModel = StateObject(wrappedValue: HomeLocationViewModel(api: locationReportAPI)) _cooperationOrderAPI = State(initialValue: CooperationOrderAPI(client: apiClient)) _authSessionCoordinator = State( initialValue: AuthSessionCoordinator( @@ -146,6 +149,7 @@ struct RootView: View { .environment(\.assetsAPI, assetsAPI) .environment(\.punchPointAPI, punchPointAPI) .environment(\.locationReportAPI, locationReportAPI) + .environmentObject(homeLocationViewModel) .environment(\.cooperationOrderAPI, cooperationOrderAPI) .environment(\.authSessionCoordinator, authSessionCoordinator) .task { diff --git a/suixinkan/App/State/ToastCenter.swift b/suixinkan/App/State/ToastCenter.swift index db72217..fcb38e8 100644 --- a/suixinkan/App/State/ToastCenter.swift +++ b/suixinkan/App/State/ToastCenter.swift @@ -62,18 +62,17 @@ final class ToastCenter: ObservableObject { #endif } -/// 全局 Toast 叠加层修饰器,负责把 Toast 展示到页面顶部。 +/// 全局 Toast 叠加层修饰器,负责把 Toast 展示到屏幕中央。 private struct GlobalToastOverlay: ViewModifier { @EnvironmentObject private var toastCenter: ToastCenter func body(content: Content) -> some View { content - .overlay(alignment: .top) { - GeometryReader { proxy in + .overlay { + Group { if let message = toastCenter.message { - toastBanner(message, topInset: proxy.safeAreaInsets.top) - .transition(.move(edge: .top).combined(with: .opacity)) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + toastBanner(message) + .transition(.opacity) } } .allowsHitTesting(false) @@ -81,22 +80,23 @@ private struct GlobalToastOverlay: ViewModifier { .animation(.easeInOut(duration: 0.18), value: toastCenter.message) } - /// 构建顶部全宽 Toast 横幅。 - private func toastBanner(_ message: String, topInset: CGFloat) -> some View { - HStack { + /// 构建屏幕中央的黑色半透明圆角 Toast 卡片。 + private func toastBanner(_ message: String) -> some View { + HStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 16, weight: .semibold)) + Text(message) - .font(.system(size: 14, weight: .medium)) - .lineLimit(2) - .multilineTextAlignment(.center) + .font(.system(size: 16, weight: .medium)) + .lineLimit(3) + .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .center) } .foregroundStyle(.white) - .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) - .padding(.top, topInset + AppMetrics.Spacing.small) - .padding(.bottom, AppMetrics.Spacing.small) - .frame(maxWidth: .infinity, alignment: .center) - .background(AppDesign.primary.ignoresSafeArea(edges: .top)) + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background(Color.black.opacity(0.78), in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 40) } } diff --git a/suixinkan/App/suixinkanApp.swift b/suixinkan/App/suixinkanApp.swift index cc91b63..4c71871 100644 --- a/suixinkan/App/suixinkanApp.swift +++ b/suixinkan/App/suixinkanApp.swift @@ -14,6 +14,7 @@ struct suixinkanApp: App { init() { AppUITestLaunchState.resetIfNeeded() + AMapBootstrap.configure(apiKey: AMapConfig.apiKey) } var body: some Scene { diff --git a/suixinkan/Core/Core.md b/suixinkan/Core/Core.md index d3bb50e..09312cd 100644 --- a/suixinkan/Core/Core.md +++ b/suixinkan/Core/Core.md @@ -79,7 +79,7 @@ Loading 的可观察状态只在 `GlobalLoadingOverlayHost` 内部订阅,并 `ToastCenter` 只用于展示轻量提示文案,业务页面通过 `toastCenter.show(...)` 发出提示命令,不直接读取 Toast 展示状态。 -Toast UI 是顶部全宽横幅,背景使用不透明主色并延伸到顶部安全区和屏幕左右边;文案居中展示,无关闭按钮,默认 2.2 秒自动隐藏。新的 Toast 会覆盖旧 Toast 并重新计时。 +Toast UI 是屏幕中央的黑色半透明圆角卡片,左侧带警告图标,文案 16pt;以透明度淡入淡出,无关闭按钮,默认 2.2 秒自动隐藏。新的 Toast 会覆盖旧 Toast 并重新计时。 ## Upload diff --git a/suixinkan/Core/Location/LocationStateStore.swift b/suixinkan/Core/Location/LocationStateStore.swift new file mode 100644 index 0000000..93eaa75 --- /dev/null +++ b/suixinkan/Core/Location/LocationStateStore.swift @@ -0,0 +1,108 @@ +// +// LocationStateStore.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import Foundation + +/// 位置上报本地状态存储,持久化在线状态、过期时间、上次上报时间和提醒设置。 +final class LocationStateStore { + static let onlineDurationSeconds = 7200 + /// 区分 Unix 时间戳与剩余秒数的阈值。 + private static let expireTimestampThreshold = 1_000_000_000 + + private let defaults: UserDefaults + private let lastReportTimeKey = "key_last_location_report_time" + private let expireTimeKey = "key_location_expire_time" + private let onlineStatusKey = "key_online_status" + private let reminderMinutesKey = "key_location_reminder_minutes" + + /// 初始化位置状态存储,并允许测试注入独立 UserDefaults。 + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// 读取是否在线。 + func loadOnlineStatus() -> Bool { + defaults.bool(forKey: onlineStatusKey) + } + + /// 保存是否在线。 + func saveOnlineStatus(_ isOnline: Bool) { + defaults.set(isOnline, forKey: onlineStatusKey) + } + + /// 读取上次上报时间戳(毫秒)。 + func loadLastReportTime() -> TimeInterval { + defaults.double(forKey: lastReportTimeKey) + } + + /// 保存上次上报时间戳(毫秒)。 + func saveLastReportTime(_ timestamp: TimeInterval) { + defaults.set(timestamp, forKey: lastReportTimeKey) + } + + /// 清除上次上报时间。 + func clearLastReportTime() { + defaults.removeObject(forKey: lastReportTimeKey) + } + + /// 读取服务端返回的过期 Unix 时间戳(秒)。 + func loadExpireTime() -> Int { + defaults.integer(forKey: expireTimeKey) + } + + /// 保存服务端返回的过期 Unix 时间戳(秒)。 + func saveExpireTime(_ timestamp: Int) { + defaults.set(timestamp, forKey: expireTimeKey) + } + + /// 清除过期时间。 + func clearExpireTime() { + defaults.removeObject(forKey: expireTimeKey) + } + + /// 读取提前提醒分钟数,默认 0 表示不提醒。 + func loadReminderMinutes() -> Int { + guard defaults.object(forKey: reminderMinutesKey) != nil else { return 0 } + return defaults.integer(forKey: reminderMinutesKey) + } + + /// 保存提前提醒分钟数。 + func saveReminderMinutes(_ minutes: Int) { + defaults.set(minutes, forKey: reminderMinutesKey) + } + + /// 将服务端 expired 规范化为过期 Unix 时间戳(秒)。 + func normalizedExpireTimestamp(from raw: Int, now: Date = Date()) -> Int { + guard raw > 0 else { + return Int(now.timeIntervalSince1970) + Self.onlineDurationSeconds + } + if raw > Self.expireTimestampThreshold { + return raw + } + return Int(now.timeIntervalSince1970) + raw + } + + /// 根据过期时间戳计算剩余倒计时秒数;已过期返回 0。 + func remainingSeconds(untilExpireTime expireTime: Int, now: Date = Date()) -> Int { + guard expireTime > 0 else { return 0 } + let nowSeconds = Int(now.timeIntervalSince1970) + return max(0, expireTime - nowSeconds) + } + + /// 根据上次上报时间计算剩余倒计时秒数;已过期返回 0。仅用于无过期时间戳时的兼容恢复。 + func remainingSeconds(since anchorTime: TimeInterval, now: Date = Date()) -> Int { + let elapsed = now.timeIntervalSince1970 * 1000 - anchorTime + let remainingMillis = Double(Self.onlineDurationSeconds) * 1000 - elapsed + guard remainingMillis > 0 else { return 0 } + return Int(remainingMillis / 1000) + } + + /// 判断上次上报是否仍在 2 小时在线窗口内。 + func isWithinOnlineWindow(since anchorTime: TimeInterval, now: Date = Date()) -> Bool { + remainingSeconds(since: anchorTime, now: now) > 0 + } +} diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index cd87584..8bdd131 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -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` 接管。 diff --git a/suixinkan/Features/Home/ViewModels/HomeLocationViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeLocationViewModel.swift new file mode 100644 index 0000000..6ea3c88 --- /dev/null +++ b/suixinkan/Features/Home/ViewModels/HomeLocationViewModel.swift @@ -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 + }() +} diff --git a/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift index 3e2f95c..b25b97e 100644 --- a/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift +++ b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift @@ -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) diff --git a/suixinkan/Features/Home/Views/HomeView.swift b/suixinkan/Features/Home/Views/HomeView.swift index 8bda946..7be3f3d 100644 --- a/suixinkan/Features/Home/Views/HomeView.swift +++ b/suixinkan/Features/Home/Views/HomeView.swift @@ -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()) } } diff --git a/suixinkan/Features/LocationReport/API/LocationReportAPI.swift b/suixinkan/Features/LocationReport/API/LocationReportAPI.swift index f175670..5b70da6 100644 --- a/suixinkan/Features/LocationReport/API/LocationReportAPI.swift +++ b/suixinkan/Features/LocationReport/API/LocationReportAPI.swift @@ -16,6 +16,9 @@ protocol LocationReportServing { /// 获取位置上报历史列表。 func locationReportList(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?) async throws -> ListPayload + + /// 获取位置上报详情,用于超时提醒判断。 + 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)")] + ) + ) + } } diff --git a/suixinkan/Features/LocationReport/LocationReport.md b/suixinkan/Features/LocationReport/LocationReport.md index e7df61a..0c153c5 100644 --- a/suixinkan/Features/LocationReport/LocationReport.md +++ b/suixinkan/Features/LocationReport/LocationReport.md @@ -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` 持久化;坐标表单仍只在页面内维护。 ## 测试要求 diff --git a/suixinkan/Features/LocationReport/Models/LocationReportModels.swift b/suixinkan/Features/LocationReport/Models/LocationReportModels.swift index f22868a..f762bd7 100644 --- a/suixinkan/Features/LocationReport/Models/LocationReportModels.swift +++ b/suixinkan/Features/LocationReport/Models/LocationReportModels.swift @@ -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 diff --git a/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModels.swift b/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModels.swift index 6c2956a..24f4a94 100644 --- a/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModels.swift +++ b/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModels.swift @@ -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,负责筛选、日期和分页加载。 diff --git a/suixinkan/Features/LocationReport/Views/LocationReportViews.swift b/suixinkan/Features/LocationReport/Views/LocationReportViews.swift index 45444b4..652c2d1 100644 --- a/suixinkan/Features/LocationReport/Views/LocationReportViews.swift +++ b/suixinkan/Features/LocationReport/Views/LocationReportViews.swift @@ -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 ?? "上报失败")) } diff --git a/suixinkanTests/LocationReport/LocationReportTests.swift b/suixinkanTests/LocationReport/LocationReportTests.swift index 20143b7..52266e1 100644 --- a/suixinkanTests/LocationReport/LocationReportTests.swift +++ b/suixinkanTests/LocationReport/LocationReportTests.swift @@ -51,54 +51,88 @@ final class LocationReportAPITests: XCTestCase { XCTAssertEqual(payload.list.first?.latitude, "30.1") } + /// 测试详情接口路径与字段解码。 + func testDetailUsesExpectedQueryAndDecodesFields() async throws { + let session = LocationRecordingSession(data: Self.detailResponse) + let api = LocationReportAPI(client: APIClient(session: session)) + + let detail = try await api.locationDetail(staffId: 77) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/loacation/detail") + XCTAssertEqual(locationQueryItems(from: request)["staff_id"], "77") + XCTAssertEqual(detail.status, 1) + XCTAssertTrue(detail.needReport) + XCTAssertEqual(detail.expireTime, 1_718_000_000) + } + private static let submitResponse = Data(#"{"code":100000,"msg":"ok","data":{"staff_id":77,"expired":"7200","status":"1"}}"#.utf8) private static let historyResponse = Data(#"{"code":100000,"msg":"ok","data":{"total":"1","list":[{"id":"1","staff_id":"77","type":"2","latitude":30.1,"longitude":"120.2","address":"入口","ip":"127.0.0.1","remark":"ok","created_at":"2026-06-24 10:00:00"}]}}"#.utf8) + private static let detailResponse = Data(#"{"code":100000,"msg":"ok","data":{"status":"1","need_report":"1","expire_time":"1718000000"}}"#.utf8) } @MainActor -/// 位置上报 ViewModel 测试,覆盖提交保护、类型和历史分页。 +/// 位置上报 ViewModel 测试,覆盖提交保护和类型选择。 final class LocationReportViewModelTests: XCTestCase { /// 测试缺 staffId 或 scenicId 时禁止上报。 func testSubmitRequiresStaffAndScenic() async { let api = MockLocationReportService() + let homeVM = HomeLocationViewModel(api: api, store: makeIsolatedStore()) + let toast = ToastCenter() let viewModel = LocationReportViewModel() viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") - let missingStaff = await viewModel.submit(type: .immediate, staffId: nil, scenicId: 88, api: api) - let missingScenic = await viewModel.submit(type: .immediate, staffId: 77, scenicId: nil, api: api) + let missingStaff = await viewModel.submit(type: .immediate, staffId: nil, scenicId: 88, homeLocationViewModel: homeVM, toast: toast) + let missingScenic = await viewModel.submit(type: .immediate, staffId: 77, scenicId: nil, homeLocationViewModel: homeVM, toast: toast) XCTAssertFalse(missingStaff) XCTAssertFalse(missingScenic) XCTAssertEqual(api.reportRequests.count, 0) } - /// 测试立即上报、标记点上报和在线状态使用正确类型。 - func testSubmitUsesExpectedTypes() async { + /// 测试标记点上报使用正确坐标并更新倒计时。 + func testSubmitUsesExpectedCoordinates() async { let api = MockLocationReportService() + let homeVM = HomeLocationViewModel(api: api, store: makeIsolatedStore()) + let toast = ToastCenter() let viewModel = LocationReportViewModel() viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") viewModel.applyMarkedLocation(latitude: 31.1, longitude: 121.2, address: "标记点") - _ = await viewModel.submit(type: .immediate, staffId: 77, scenicId: 88, api: api) - _ = await viewModel.submit(type: .marked, staffId: 77, scenicId: 88, api: api) - _ = await viewModel.setOnline(true, staffId: 77, scenicId: 88, api: api) + _ = await viewModel.submit(type: .marked, staffId: 77, scenicId: 88, homeLocationViewModel: homeVM, toast: toast) - XCTAssertEqual(api.reportRequests.map(\.type), [.immediate, .marked, .onlineStatus]) - XCTAssertEqual(api.reportRequests[1].latitude, 31.1) - XCTAssertEqual(viewModel.secondsUntilReport, 600) + XCTAssertEqual(api.reportRequests.map(\.type), [.marked]) + XCTAssertEqual(api.reportRequests.first?.latitude, 31.1) + XCTAssertEqual(homeVM.secondsUntilReport, 600) } - /// 测试上报失败保留当前状态并暴露错误。 - func testSubmitFailureKeepsState() async { + /// 测试立即上报成功更新倒计时。 + func testSubmitImmediateUpdatesCountdown() async { let api = MockLocationReportService() - api.shouldFailReport = true + let homeVM = HomeLocationViewModel(api: api, store: makeIsolatedStore()) + let toast = ToastCenter() let viewModel = LocationReportViewModel() viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") - let success = await viewModel.submit(type: .immediate, staffId: 77, scenicId: 88, api: api) + _ = await viewModel.submit(type: .immediate, staffId: 77, scenicId: 88, homeLocationViewModel: homeVM, toast: toast) + + XCTAssertEqual(api.reportRequests.map(\.type), [.immediate]) + XCTAssertEqual(homeVM.secondsUntilReport, 600) + } + + /// 测试上报失败保留当前倒计时。 + func testSubmitFailureKeepsCountdown() async { + let api = MockLocationReportService() + api.shouldFailReport = true + let homeVM = HomeLocationViewModel(api: api, store: makeIsolatedStore()) + let toast = ToastCenter() + let viewModel = LocationReportViewModel() + viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") + + let success = await viewModel.submit(type: .immediate, staffId: 77, scenicId: 88, homeLocationViewModel: homeVM, toast: toast) XCTAssertFalse(success) - XCTAssertEqual(viewModel.secondsUntilReport, 0) + XCTAssertEqual(homeVM.secondsUntilReport, 0) XCTAssertNotNil(viewModel.errorMessage) } @@ -120,6 +154,201 @@ final class LocationReportViewModelTests: XCTestCase { XCTAssertEqual(api.historyRequests.map(\.page), [1, 2]) XCTAssertEqual(api.historyRequests.first?.type, .marked) } + + private func makeIsolatedStore() -> LocationStateStore { + let suiteName = "LocationReportViewModelTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + return LocationStateStore(defaults: defaults) + } +} + +@MainActor +/// 位置状态存储测试。 +final class LocationStateStoreTests: XCTestCase { + /// 测试在线状态、过期时间、上报时间和提醒分钟持久化。 + func testPersistsOnlineReminderReportAndExpireTime() { + let store = makeIsolatedStore() + store.saveOnlineStatus(true) + store.saveReminderMinutes(15) + store.saveLastReportTime(1_000) + store.saveExpireTime(1_782_726_128) + + XCTAssertTrue(store.loadOnlineStatus()) + XCTAssertEqual(store.loadReminderMinutes(), 15) + XCTAssertEqual(store.loadLastReportTime(), 1_000) + XCTAssertEqual(store.loadExpireTime(), 1_782_726_128) + } + + /// 测试将 Unix 时间戳和剩余秒数规范化为过期时间戳。 + func testNormalizedExpireTimestamp() { + let store = makeIsolatedStore() + let now = Date(timeIntervalSince1970: 1_782_718_928) + + XCTAssertEqual(store.normalizedExpireTimestamp(from: 1_782_726_128, now: now), 1_782_726_128) + XCTAssertEqual(store.normalizedExpireTimestamp(from: 7_200, now: now), 1_782_726_128) + XCTAssertEqual(store.normalizedExpireTimestamp(from: 0, now: now), 1_782_726_128) + } + + /// 测试根据过期时间戳计算剩余倒计时。 + func testRemainingSecondsUntilExpireTime() { + let store = makeIsolatedStore() + let now = Date(timeIntervalSince1970: 1_782_719_528) + + XCTAssertEqual(store.remainingSeconds(untilExpireTime: 1_782_726_128, now: now), 6_600) + XCTAssertEqual(store.remainingSeconds(untilExpireTime: 1_782_719_528, now: now), 0) + } + + /// 测试剩余倒计时与过期判断(兼容旧版仅保存上报时间)。 + func testRemainingSecondsAndWindow() { + let store = makeIsolatedStore() + let anchor = Date(timeIntervalSince1970: 1_000).timeIntervalSince1970 * 1000 + let beforeExpiry = Date(timeIntervalSince1970: 1_000 + 3_600) + let afterExpiry = Date(timeIntervalSince1970: 1_000 + 8_000) + + XCTAssertTrue(store.isWithinOnlineWindow(since: anchor, now: beforeExpiry)) + XCTAssertFalse(store.isWithinOnlineWindow(since: anchor, now: afterExpiry)) + XCTAssertEqual(store.remainingSeconds(since: anchor, now: beforeExpiry), 3_600) + XCTAssertEqual(store.remainingSeconds(since: anchor, now: afterExpiry), 0) + } + + private func makeIsolatedStore() -> LocationStateStore { + let suiteName = "LocationStateStoreTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + return LocationStateStore(defaults: defaults) + } +} + +@MainActor +/// 首页位置上报 ViewModel 测试。 +final class HomeLocationViewModelTests: XCTestCase { + /// 测试上报成功后启动倒计时并持久化在线状态。 + func testReportCoordinateStartsCountdown() async { + let api = MockLocationReportService() + let store = makeIsolatedStore() + let viewModel = HomeLocationViewModel(api: api, store: store) + let toast = ToastCenter() + + let success = await viewModel.reportCoordinate( + latitude: 30.1, + longitude: 120.2, + address: "入口", + type: .immediate, + staffId: 77, + scenicId: 88, + toast: toast + ) + + XCTAssertTrue(success) + XCTAssertTrue(viewModel.isOnline) + XCTAssertEqual(viewModel.secondsUntilReport, 600) + XCTAssertTrue(store.loadOnlineStatus()) + XCTAssertGreaterThan(store.loadLastReportTime(), 0) + XCTAssertGreaterThan(store.loadExpireTime(), Int(Date().timeIntervalSince1970)) + } + + /// 测试上报成功后持久化服务端过期时间戳。 + func testReportCoordinatePersistsExpireTimestamp() async { + let api = MockLocationReportService() + let expireTime = Int(Date().timeIntervalSince1970) + 7_200 + api.reportExpired = expireTime + let store = makeIsolatedStore() + let viewModel = HomeLocationViewModel(api: api, store: store) + let toast = ToastCenter() + + _ = await viewModel.reportCoordinate( + latitude: 30.1, + longitude: 120.2, + address: "入口", + type: .immediate, + staffId: 77, + scenicId: 88, + toast: toast + ) + + XCTAssertEqual(store.loadExpireTime(), expireTime) + XCTAssertEqual(viewModel.secondsUntilReport, 7_200) + } + + /// 测试 30 秒操作节流。 + func testOperationIntervalBlocksFrequentReports() async { + let api = MockLocationReportService() + let viewModel = HomeLocationViewModel(api: api, store: makeIsolatedStore()) + let toast = ToastCenter() + + _ = await viewModel.reportCoordinate( + latitude: 30.1, + longitude: 120.2, + address: "入口", + type: .immediate, + staffId: 77, + scenicId: 88, + toast: toast + ) + let second = await viewModel.reportCoordinate( + latitude: 30.2, + longitude: 120.3, + address: "入口2", + type: .immediate, + staffId: 77, + scenicId: 88, + toast: toast + ) + + XCTAssertFalse(second) + XCTAssertEqual(api.reportRequests.count, 1) + } + + /// 测试恢复在线倒计时(兼容旧版仅保存上报时间)。 + func testRestoreStateRecoversCountdown() { + let store = makeIsolatedStore() + let now = Date().timeIntervalSince1970 * 1000 + store.saveOnlineStatus(true) + store.saveLastReportTime(now - 1_000 * 1000) + store.saveReminderMinutes(10) + + let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store) + viewModel.restoreState() + + XCTAssertTrue(viewModel.isOnline) + XCTAssertGreaterThan(viewModel.secondsUntilReport, 0) + XCTAssertEqual(viewModel.reminderMinutes, 10) + } + + /// 测试下次启动优先使用本地过期时间戳恢复倒计时。 + func testRestoreStateUsesPersistedExpireTime() { + let store = makeIsolatedStore() + let expireTime = Int(Date().timeIntervalSince1970) + 3_600 + store.saveOnlineStatus(true) + store.saveExpireTime(expireTime) + store.saveLastReportTime(Date().timeIntervalSince1970 * 1000) + store.saveReminderMinutes(10) + + let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store) + viewModel.restoreState() + + XCTAssertTrue(viewModel.isOnline) + XCTAssertEqual(viewModel.secondsUntilReport, 3_600) + XCTAssertEqual(viewModel.reminderMinutes, 10) + } + + /// 测试 detail 接口触发超时对话框。 + func testCheckLocationTimeoutShowsDialogWhenNearExpiry() async { + let api = MockLocationReportService() + let expireTime = Int(Date().timeIntervalSince1970) + 120 + api.detailResponse = LocationDetailResponse(status: 1, needReport: true, expireTime: expireTime) + let viewModel = HomeLocationViewModel(api: api, store: makeIsolatedStore()) + viewModel.updateReminderMinutes(5) + + await viewModel.checkLocationTimeout(staffId: 77) + + XCTAssertTrue(viewModel.showTimeoutDialog) + } + + private func makeIsolatedStore() -> LocationStateStore { + let suiteName = "HomeLocationViewModelTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + return LocationStateStore(defaults: defaults) + } } /// 位置上报 API 测试用 URLSession。 @@ -142,8 +371,10 @@ private final class LocationRecordingSession: URLSessionProtocol { @MainActor /// 位置上报服务测试替身。 -private final class MockLocationReportService: LocationReportServing { +final class MockLocationReportService: LocationReportServing { var shouldFailReport = false + var reportExpired = 600 + var detailResponse = LocationDetailResponse(status: 0, needReport: false, expireTime: 0) var reportRequests: [(staffId: Int, latitude: Double, longitude: Double, address: String, type: LocationReportType, scenicId: Int)] = [] var historyPages: [ListPayload] = [ListPayload(total: 0, list: [])] var historyRequests: [(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?)] = [] @@ -151,13 +382,17 @@ private final class MockLocationReportService: LocationReportServing { func reportLocation(staffId: Int, latitude: Double, longitude: Double, address: String, type: LocationReportType, scenicId: Int) async throws -> LocationReportSubmitResponse { if shouldFailReport { throw NSError(domain: "location", code: 1) } reportRequests.append((staffId, latitude, longitude, address, type, scenicId)) - return LocationReportSubmitResponse(staffId: "\(staffId)", expired: 600, status: 1) + return LocationReportSubmitResponse(staffId: "\(staffId)", expired: reportExpired, status: 1) } func locationReportList(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?) async throws -> ListPayload { historyRequests.append((staffId, page, pageSize, type, startDate, endDate)) return historyPages.isEmpty ? ListPayload(total: 0, list: []) : historyPages.removeFirst() } + + func locationDetail(staffId: Int) async throws -> LocationDetailResponse { + detailResponse + } } private extension LocationReportHistoryItem { diff --git a/suixinkanUITests/Support/BackNavigator.swift b/suixinkanUITests/Support/BackNavigator.swift index f596fb3..fde216b 100644 --- a/suixinkanUITests/Support/BackNavigator.swift +++ b/suixinkanUITests/Support/BackNavigator.swift @@ -23,7 +23,7 @@ enum BackNavigator { app.waitForGlobalLoadingToDisappear() } - /// 关闭自定义顶栏页面(如全部功能页)。 + /// 关闭仍使用自定义顶栏返回按钮的页面。 static func dismissCustomTopBarIfNeeded(app: SuixinkanApp) { let backCandidates = app.application.buttons.matching(NSPredicate(format: "label CONTAINS 'chevron.left'")) if backCandidates.count > 0 {