diff --git a/suixinkan/Core/Core.md b/suixinkan/Core/Core.md index 09312cd..1eabc54 100644 --- a/suixinkan/Core/Core.md +++ b/suixinkan/Core/Core.md @@ -71,7 +71,7 @@ Core 模块提供跨业务复用的基础能力,包括网络请求、缓存存 Loading 的可观察状态只在 `GlobalLoadingOverlayHost` 内部订阅,并由 `RootView` 挂载到应用根部。这样切换 Loading 显隐时,只会刷新根部 Overlay,不会让当前页面、Tab 根视图或业务子视图形成观察依赖。 -当前全局 Loading UI 不展示文案;`message` 字段保留为内部展示状态,便于后续需要时恢复文案展示。 +当前全局 Loading 默认只展示动画;调用方传入 `showsMessage: true` 时,才会在动画下方展示 `message` 文案(如位置上报的「获取定位中」)。 全局 Loading 只用于阻塞型等待,例如冷启动恢复、登录、首屏加载、提交表单和核销。列表加载更多、上传进度、按钮内局部反馈继续保留在页面局部状态中。 diff --git a/suixinkan/Core/Design/GlobalLoadingCenter.swift b/suixinkan/Core/Design/GlobalLoadingCenter.swift index f25b962..65a390b 100644 --- a/suixinkan/Core/Design/GlobalLoadingCenter.swift +++ b/suixinkan/Core/Design/GlobalLoadingCenter.swift @@ -16,10 +16,12 @@ final class GlobalLoadingCenter { fileprivate let state = GlobalLoadingState() /// 显示全局 Loading;多次调用会通过引用计数叠加。 - func show(message: String = "") { + func show(message: String = "", showsMessage: Bool = false) { if !message.isEmpty { state.message = message } + state.messageDisplayLevels.append(showsMessage) + state.showsMessage = state.messageDisplayLevels.contains(true) state.activeCount += 1 state.isVisible = true } @@ -27,9 +29,15 @@ final class GlobalLoadingCenter { /// 隐藏一次全局 Loading;所有调用方都结束后才真正关闭。 func hide() { state.activeCount = max(0, state.activeCount - 1) + if !state.messageDisplayLevels.isEmpty { + state.messageDisplayLevels.removeLast() + } + state.showsMessage = state.messageDisplayLevels.contains(true) guard state.activeCount == 0 else { return } state.isVisible = false state.message = "" + state.messageDisplayLevels = [] + state.showsMessage = false } /// 更新当前 Loading 文案,仅在 Loading 可见且文案非空时生效。 @@ -41,9 +49,10 @@ final class GlobalLoadingCenter { /// 包裹一个异步操作,操作结束或抛错时自动关闭 Loading。 func withLoading( message: String = "", + showsMessage: Bool = false, operation: () async throws -> T ) async rethrows -> T { - show(message: message) + show(message: message, showsMessage: showsMessage) defer { hide() } return try await operation() } @@ -52,10 +61,11 @@ final class GlobalLoadingCenter { func withOptionalLoading( _ enabled: Bool, message: String = "", + showsMessage: Bool = false, operation: () async throws -> T ) async rethrows -> T { if enabled { - return try await withLoading(message: message, operation: operation) + return try await withLoading(message: message, showsMessage: showsMessage, operation: operation) } return try await operation() } @@ -66,7 +76,8 @@ final class GlobalLoadingCenter { GlobalLoadingSnapshot( isVisible: state.isVisible, message: state.message, - activeCount: state.activeCount + activeCount: state.activeCount, + showsMessage: state.showsMessage ) } #endif @@ -77,7 +88,9 @@ final class GlobalLoadingCenter { fileprivate final class GlobalLoadingState: ObservableObject { @Published fileprivate var isVisible = false @Published fileprivate var message = "" + @Published fileprivate var showsMessage = false @Published fileprivate var activeCount = 0 + fileprivate var messageDisplayLevels: [Bool] = [] } /// Lottie Loading 动画容器,负责播放主包中的 loading.json。 @@ -146,6 +159,14 @@ private struct GlobalLoadingOverlayHost: View { VStack(spacing: 14) { loadingAnimation + if state.showsMessage, !state.message.isEmpty { + Text(state.message) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(AppDesign.textPrimary) + .multilineTextAlignment(.center) + .padding(.horizontal, 8) + .padding(.bottom, 6) + } } .padding(.horizontal, 10) .padding(.vertical, 10) @@ -155,10 +176,12 @@ private struct GlobalLoadingOverlayHost: View { .transition(.opacity) .zIndex(10_000) .accessibilityElement(children: .combine) - .accessibilityLabel("加载中") + .accessibilityLabel(state.showsMessage && !state.message.isEmpty ? state.message : "加载中") } } .animation(.easeInOut(duration: 0.2), value: state.isVisible) + .animation(.easeInOut(duration: 0.2), value: state.showsMessage) + .animation(.easeInOut(duration: 0.2), value: state.message) } @ViewBuilder @@ -200,6 +223,7 @@ struct GlobalLoadingSnapshot: Equatable { let isVisible: Bool let message: String let activeCount: Int + let showsMessage: Bool } #endif diff --git a/suixinkan/Core/Design/ReminderTimeOptionsMenu.swift b/suixinkan/Core/Design/ReminderTimeOptionsMenu.swift new file mode 100644 index 0000000..9b4f83b --- /dev/null +++ b/suixinkan/Core/Design/ReminderTimeOptionsMenu.swift @@ -0,0 +1,29 @@ +// +// ReminderTimeOptionsMenu.swift +// suixinkan +// +// Created by Codex on 2026/6/29. +// + +import SwiftUI + +/// 提前提醒时间选择控件。 +/// 使用 `Menu` 而非 `confirmationDialog`,避免 ScrollView 内 popover 箭头锚点偏移。 +struct ReminderTimeOptionsMenu: View { + let onSelect: (Int) -> Void + @ViewBuilder let label: () -> Label + + private let options = [0, 5, 10, 15, 30] + + var body: some View { + Menu { + ForEach(options, id: \.self) { minute in + Button(minute == 0 ? "不提醒" : "\(minute)分钟") { + onSelect(minute) + } + } + } label: { + label() + } + } +} diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index 8bdd131..931f4bf 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -44,6 +44,7 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作 首页工作状态区域由 `HomeLocationViewModel` 驱动,与 `LocationReport` 详情页共享同一实例(在 `RootView` 注入)。 - 在线/离线切换、立即上报、2 小时倒计时和提前提醒均走真实 API。 +- 「位置上报超时」弹窗仅在本地在线且倒计时进入提前提醒窗口时弹出;离线状态不弹窗。 - 本地状态通过 `LocationStateStore` 持久化在线标志、过期时间戳、上次上报时间和提醒分钟数。 - 首页大按钮直接触发 GPS 上报;菜单 `location_report` 仍进入详情页。 - 非 `homeMinimalTopRoles` 角色才展示工作状态卡片。 diff --git a/suixinkan/Features/Home/ViewModels/HomeLocationViewModel.swift b/suixinkan/Features/Home/ViewModels/HomeLocationViewModel.swift index 6ea3c88..5909847 100644 --- a/suixinkan/Features/Home/ViewModels/HomeLocationViewModel.swift +++ b/suixinkan/Features/Home/ViewModels/HomeLocationViewModel.swift @@ -61,6 +61,8 @@ final class HomeLocationViewModel: ObservableObject { isOnline = false secondsUntilReport = 0 expireTimestamp = nil + showTimeoutDialog = false + timeoutDialogTriggered = false return } @@ -156,31 +158,13 @@ final class HomeLocationViewModel: ObservableObject { await reportLocation(type: .immediate, staffId: staffId!, scenicId: scenicId!, toast: toast) } - /// 调用 detail 接口判断是否需要弹出超时提醒。 + /// 根据本地在线状态和倒计时判断是否需要弹出超时提醒。 func checkLocationTimeout(staffId: Int?) async { - guard reminderMinutes > 0 else { + guard isOnline, reminderMinutes > 0, secondsUntilReport > 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 - } + evaluateReminderThreshold(remainingSeconds: secondsUntilReport, forceDialog: true) } /// 关闭超时对话框并将用户置为离线。 @@ -313,6 +297,8 @@ final class HomeLocationViewModel: ObservableObject { secondsUntilReport = 0 lastReportAnchorTime = nil expireTimestamp = nil + showTimeoutDialog = false + timeoutDialogTriggered = false if clearPersisted { store.saveOnlineStatus(false) store.clearLastReportTime() @@ -344,9 +330,9 @@ final class HomeLocationViewModel: ObservableObject { return false } - /// 判断是否到达提前提醒阈值。 + /// 判断是否到达提前提醒阈值(仅在线且倒计时进入提醒窗口时触发)。 private func evaluateReminderThreshold(remainingSeconds: Int, forceDialog: Bool) { - guard reminderMinutes > 0 else { return } + guard isOnline, reminderMinutes > 0 else { return } let thresholdSeconds = reminderMinutes * 60 guard remainingSeconds <= thresholdSeconds else { return } diff --git a/suixinkan/Features/Home/Views/HomeView.swift b/suixinkan/Features/Home/Views/HomeView.swift index 7be3f3d..cc4b5b6 100644 --- a/suixinkan/Features/Home/Views/HomeView.swift +++ b/suixinkan/Features/Home/Views/HomeView.swift @@ -24,7 +24,6 @@ struct HomeView: View { @StateObject private var viewModel = HomeViewModel() @State private var commonUris: [String] = [] @State private var showOnlineDialog = false - @State private var showReminderDialog = false private let commonMenuStore = HomeCommonMenuStore() private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() @@ -134,14 +133,6 @@ struct HomeView: View { } 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) {} - } .alert("位置上报提醒", isPresented: $homeLocationViewModel.showReminderDialog) { Button("立即上报") { homeLocationViewModel.dismissReminderDialog() @@ -232,9 +223,7 @@ struct HomeView: View { Spacer(minLength: AppMetrics.Spacing.xxSmall) - Button { - showReminderDialog = true - } label: { + ReminderTimeOptionsMenu(onSelect: { homeLocationViewModel.updateReminderMinutes($0) }) { Label { Text(reminderText) .font(.system(size: AppMetrics.FontSize.body, weight: .medium)) @@ -472,7 +461,7 @@ struct HomeView: View { /// 包裹需要 GPS 定位的位置上报操作,定位期间展示全局 Loading。 private func runLocationReportTask(_ body: @escaping () async -> Void) { Task { - await globalLoading.withLoading(message: "正在定位...") { + await globalLoading.withLoading(message: "获取定位中", showsMessage: true) { await body() } } diff --git a/suixinkan/Features/LocationReport/Views/LocationReportViews.swift b/suixinkan/Features/LocationReport/Views/LocationReportViews.swift index 652c2d1..d65201f 100644 --- a/suixinkan/Features/LocationReport/Views/LocationReportViews.swift +++ b/suixinkan/Features/LocationReport/Views/LocationReportViews.swift @@ -21,7 +21,6 @@ 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 { @@ -59,7 +58,7 @@ struct LocationReportView: View { Button("取消", role: .cancel) {} Button("确定") { Task { - await globalLoading.withLoading(message: "正在定位...") { + await globalLoading.withLoading(message: "获取定位中", showsMessage: true) { await homeLocationViewModel.confirmOnlineToggle( staffId: staffId, scenicId: scenicId, @@ -71,14 +70,6 @@ struct LocationReportView: View { } 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) {} - } } /// 在线状态和倒计时区域。 @@ -188,9 +179,7 @@ struct LocationReportView: View { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { Text("提醒设置") .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) - Button { - showReminderDialog = true - } label: { + ReminderTimeOptionsMenu(onSelect: { homeLocationViewModel.updateReminderMinutes($0) }) { Text(homeLocationViewModel.reminderMinutes == 0 ? "不提醒" : "提前 \(homeLocationViewModel.reminderMinutes) 分钟提醒") .font(.system(size: AppMetrics.FontSize.subheadline)) .foregroundStyle(AppDesign.primary) diff --git a/suixinkan/Features/Orders/Views/OrderSourceComponents.swift b/suixinkan/Features/Orders/Views/OrderSourceComponents.swift index 040c539..4ed225d 100644 --- a/suixinkan/Features/Orders/Views/OrderSourceComponents.swift +++ b/suixinkan/Features/Orders/Views/OrderSourceComponents.swift @@ -80,6 +80,9 @@ struct SourcePersonRow: View { } struct SourceLeadContent: View { + /// 带客单缩略图固定三列等宽网格。 + private static let leadImageColumns = Array(repeating: GridItem(.flexible()), count: 3) + let lead: OrderSourceLead var compact = false var enablePhoneCall = true @@ -123,14 +126,15 @@ struct SourceLeadContent: View { .background(OrderSourceColors.imageEmptyBg, in: RoundedRectangle(cornerRadius: 6)) } } else { - HStack(spacing: 8) { - ForEach(Array(lead.images.prefix(3).enumerated()), id: \.offset) { index, imageUrl in - leadThumbnail(imageUrl: imageUrl, index: index) - } - ForEach(0.. some View { - Group { - if let url = URL(string: imageUrl), !imageUrl.isEmpty { - KFImage(url) - .resizable() - .scaledToFill() - } else { - OrderSourceColors.imageEmptyBg + Color.clear + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: .infinity) + .overlay { + leadThumbnailImage(imageUrl: imageUrl) } - } - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .contentShape(Rectangle()) - .onTapGesture { - onImageTap(lead.images, index) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .contentShape(Rectangle()) + .onTapGesture { + onImageTap(lead.images, index) + } + } + + @ViewBuilder + private func leadThumbnailImage(imageUrl: String) -> some View { + if let url = URL(string: imageUrl), !imageUrl.isEmpty { + KFImage(url) + .placeholder { OrderSourceColors.imageEmptyBg } + .resizable() + .scaledToFill() + } else { + OrderSourceColors.imageEmptyBg } } } diff --git a/suixinkan/Features/Profile/Views/ProfileView.swift b/suixinkan/Features/Profile/Views/ProfileView.swift index b06b7cf..bc0dfe5 100644 --- a/suixinkan/Features/Profile/Views/ProfileView.swift +++ b/suixinkan/Features/Profile/Views/ProfileView.swift @@ -62,19 +62,6 @@ struct ProfileView: View { await updatePassword(password) } } - .confirmationDialog("确认退出当前账号?", isPresented: $showsLogoutConfirm, titleVisibility: .visible) { - Button("退出登录", role: .destructive) { - authSessionCoordinator.logout( - appSession: appSession, - accountContext: accountContext, - permissionContext: permissionContext, - scenicSpotContext: scenicSpotContext, - appRouter: appRouter, - toastCenter: toastCenter - ) - } - Button("取消", role: .cancel) {} - } .onChange(of: viewModel.isEditingProfile) { isEditing in guard isEditing else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { @@ -371,6 +358,19 @@ struct ProfileView: View { .shadow(color: Color(hex: 0xC8D7EA, alpha: 0.24), radius: 14, x: 0, y: 7) } .buttonStyle(.plain) + .confirmationDialog("确认退出当前账号?", isPresented: $showsLogoutConfirm, titleVisibility: .visible) { + Button("退出登录", role: .destructive) { + authSessionCoordinator.logout( + appSession: appSession, + accountContext: accountContext, + permissionContext: permissionContext, + scenicSpotContext: scenicSpotContext, + appRouter: appRouter, + toastCenter: toastCenter + ) + } + Button("取消", role: .cancel) {} + } } /// 构造资料信息行,统一左右布局和点击区域。 diff --git a/suixinkanTests/GlobalLoadingCenterTests.swift b/suixinkanTests/GlobalLoadingCenterTests.swift index 10ab459..b78983f 100644 --- a/suixinkanTests/GlobalLoadingCenterTests.swift +++ b/suixinkanTests/GlobalLoadingCenterTests.swift @@ -18,13 +18,13 @@ final class GlobalLoadingCenterTests: XCTestCase { center.show(message: "加载账号") center.show(message: "加载权限") - XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 2)) + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 2, showsMessage: false)) center.hide() - XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 1)) + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载权限", activeCount: 1, showsMessage: false)) center.hide() - XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false)) } /// 测试 hide 调用次数超过 show 时不会产生负数计数。 @@ -33,7 +33,7 @@ final class GlobalLoadingCenterTests: XCTestCase { center.hide() - XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false)) } /// 测试更新文案只影响当前展示内容,不改变引用计数。 @@ -41,12 +41,12 @@ final class GlobalLoadingCenterTests: XCTestCase { let center = GlobalLoadingCenter() center.updateMessage("不会展示") - XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false)) center.show(message: "加载中") center.updateMessage("即将完成") - XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "即将完成", activeCount: 1)) + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "即将完成", activeCount: 1, showsMessage: false)) } /// 测试 withLoading 成功完成后自动隐藏 Loading。 @@ -54,12 +54,12 @@ final class GlobalLoadingCenterTests: XCTestCase { let center = GlobalLoadingCenter() let value = try await center.withLoading(message: "提交中") { - XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "提交中", activeCount: 1)) + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "提交中", activeCount: 1, showsMessage: false)) return 42 } XCTAssertEqual(value, 42) - XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false)) } /// 测试 withLoading 抛错时也会自动隐藏 Loading。 @@ -75,7 +75,7 @@ final class GlobalLoadingCenterTests: XCTestCase { XCTAssertEqual(error as? TestFailure, .expected) } - XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false)) } /// 测试 withOptionalLoading 关闭时不会改变展示状态。 @@ -83,12 +83,28 @@ final class GlobalLoadingCenterTests: XCTestCase { let center = GlobalLoadingCenter() let value = try await center.withOptionalLoading(false, message: "不展示") { - XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false)) return "done" } XCTAssertEqual(value, "done") - XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0)) + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false)) + } + + /// 测试默认不展示文案,仅在显式开启时记录展示状态。 + func testShowsMessageOnlyWhenEnabled() { + let center = GlobalLoadingCenter() + + center.show(message: "加载中") + XCTAssertEqual(center.snapshotForTests.message, "加载中") + XCTAssertFalse(center.snapshotForTests.showsMessage) + + center.hide() + center.show(message: "获取定位中", showsMessage: true) + XCTAssertTrue(center.snapshotForTests.showsMessage) + + center.hide() + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: false, message: "", activeCount: 0, showsMessage: false)) } /// 测试使用 Loading 不需要读取可观察展示状态,业务侧只能拿到命令中心类型。 @@ -97,7 +113,7 @@ final class GlobalLoadingCenterTests: XCTestCase { issueLoadingCommand(center) - XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载中", activeCount: 1)) + XCTAssertEqual(center.snapshotForTests, GlobalLoadingSnapshot(isVisible: true, message: "加载中", activeCount: 1, showsMessage: false)) center.hide() } diff --git a/suixinkanTests/LocationReport/LocationReportTests.swift b/suixinkanTests/LocationReport/LocationReportTests.swift index 52266e1..3e81ae9 100644 --- a/suixinkanTests/LocationReport/LocationReportTests.swift +++ b/suixinkanTests/LocationReport/LocationReportTests.swift @@ -331,19 +331,54 @@ final class HomeLocationViewModelTests: XCTestCase { XCTAssertEqual(viewModel.reminderMinutes, 10) } - /// 测试 detail 接口触发超时对话框。 - func testCheckLocationTimeoutShowsDialogWhenNearExpiry() async { - let api = MockLocationReportService() + /// 测试在线且倒计时进入提醒窗口时弹出超时对话框。 + func testCheckLocationTimeoutShowsDialogWhenOnlineAndWithinReminderWindow() async { + let store = makeIsolatedStore() 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) + store.saveOnlineStatus(true) + store.saveExpireTime(expireTime) + store.saveReminderMinutes(5) + + let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store) + viewModel.restoreState() await viewModel.checkLocationTimeout(staffId: 77) + XCTAssertTrue(viewModel.isOnline) XCTAssertTrue(viewModel.showTimeoutDialog) } + /// 测试离线状态不弹出超时对话框。 + func testCheckLocationTimeoutDoesNotShowDialogWhenOffline() async { + let store = makeIsolatedStore() + store.saveOnlineStatus(false) + store.saveReminderMinutes(5) + + let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store) + viewModel.restoreState() + + await viewModel.checkLocationTimeout(staffId: 77) + + XCTAssertFalse(viewModel.showTimeoutDialog) + } + + /// 测试在线但倒计时未进入提醒窗口时不弹窗。 + func testCheckLocationTimeoutDoesNotShowDialogWhenOutsideReminderWindow() async { + let store = makeIsolatedStore() + let expireTime = Int(Date().timeIntervalSince1970) + 3_600 + store.saveOnlineStatus(true) + store.saveExpireTime(expireTime) + store.saveReminderMinutes(5) + + let viewModel = HomeLocationViewModel(api: MockLocationReportService(), store: store) + viewModel.restoreState() + + await viewModel.checkLocationTimeout(staffId: 77) + + XCTAssertTrue(viewModel.isOnline) + XCTAssertFalse(viewModel.showTimeoutDialog) + } + private func makeIsolatedStore() -> LocationStateStore { let suiteName = "HomeLocationViewModelTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)!