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

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

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

View File

@ -64,15 +64,11 @@
}; };
A00000022FE9000000000002 /* suixinkanTests */ = { A00000022FE9000000000002 /* suixinkanTests */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = suixinkanTests; path = suixinkanTests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
B00000022FEF000000000002 /* suixinkanUITests */ = { B00000022FEF000000000002 /* suixinkanUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = suixinkanUITests; path = suixinkanUITests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -328,10 +324,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";

View File

@ -0,0 +1,13 @@
//
// AMapConfig.swift
// suixinkan
//
// Key iOS Key apiKey
//
import Foundation
enum AMapConfig {
/// Key Android 线 iOS Key
static let apiKey = "79e82527e55a4927992f79efe93c112b"
}

View File

@ -84,7 +84,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
全局 Toast 由 `RootView` 挂载在页面最上层,业务页面只调用 `toastCenter.show(...)` 发出提示命令。 全局 Toast 由 `RootView` 挂载在页面最上层,业务页面只调用 `toastCenter.show(...)` 发出提示命令。
Toast 展示为顶部全宽横幅,背景使用不透明主色并延伸到屏幕顶部、左边和右边;文案居中展示,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。 Toast 展示为屏幕中央的黑色半透明圆角卡片(`Color.black.opacity(0.78)`),左侧带警告图标,文案 16pt 展示,以透明度淡入淡出,不提供关闭按钮,默认 2.2 秒后自动消失。连续展示新 Toast 时会覆盖旧文案并重新计时,旧的自动隐藏任务不会影响新的 Toast。
## 导航规则 ## 导航规则

View File

@ -44,6 +44,7 @@ struct RootView: View {
@State private var assetsAPI: AssetsAPI @State private var assetsAPI: AssetsAPI
@State private var punchPointAPI: PunchPointAPI @State private var punchPointAPI: PunchPointAPI
@State private var locationReportAPI: LocationReportAPI @State private var locationReportAPI: LocationReportAPI
@StateObject private var homeLocationViewModel: HomeLocationViewModel
@State private var cooperationOrderAPI: CooperationOrderAPI @State private var cooperationOrderAPI: CooperationOrderAPI
@State private var authSessionCoordinator: AuthSessionCoordinator @State private var authSessionCoordinator: AuthSessionCoordinator
@State private var sessionBootstrapper: SessionBootstrapper @State private var sessionBootstrapper: SessionBootstrapper
@ -82,7 +83,9 @@ struct RootView: View {
_inviteAPI = State(initialValue: InviteAPI(client: apiClient)) _inviteAPI = State(initialValue: InviteAPI(client: apiClient))
_assetsAPI = State(initialValue: AssetsAPI(client: apiClient)) _assetsAPI = State(initialValue: AssetsAPI(client: apiClient))
_punchPointAPI = State(initialValue: PunchPointAPI(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)) _cooperationOrderAPI = State(initialValue: CooperationOrderAPI(client: apiClient))
_authSessionCoordinator = State( _authSessionCoordinator = State(
initialValue: AuthSessionCoordinator( initialValue: AuthSessionCoordinator(
@ -146,6 +149,7 @@ struct RootView: View {
.environment(\.assetsAPI, assetsAPI) .environment(\.assetsAPI, assetsAPI)
.environment(\.punchPointAPI, punchPointAPI) .environment(\.punchPointAPI, punchPointAPI)
.environment(\.locationReportAPI, locationReportAPI) .environment(\.locationReportAPI, locationReportAPI)
.environmentObject(homeLocationViewModel)
.environment(\.cooperationOrderAPI, cooperationOrderAPI) .environment(\.cooperationOrderAPI, cooperationOrderAPI)
.environment(\.authSessionCoordinator, authSessionCoordinator) .environment(\.authSessionCoordinator, authSessionCoordinator)
.task { .task {

View File

@ -62,18 +62,17 @@ final class ToastCenter: ObservableObject {
#endif #endif
} }
/// Toast Toast /// Toast Toast
private struct GlobalToastOverlay: ViewModifier { private struct GlobalToastOverlay: ViewModifier {
@EnvironmentObject private var toastCenter: ToastCenter @EnvironmentObject private var toastCenter: ToastCenter
func body(content: Content) -> some View { func body(content: Content) -> some View {
content content
.overlay(alignment: .top) { .overlay {
GeometryReader { proxy in Group {
if let message = toastCenter.message { if let message = toastCenter.message {
toastBanner(message, topInset: proxy.safeAreaInsets.top) toastBanner(message)
.transition(.move(edge: .top).combined(with: .opacity)) .transition(.opacity)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
} }
} }
.allowsHitTesting(false) .allowsHitTesting(false)
@ -81,22 +80,23 @@ private struct GlobalToastOverlay: ViewModifier {
.animation(.easeInOut(duration: 0.18), value: toastCenter.message) .animation(.easeInOut(duration: 0.18), value: toastCenter.message)
} }
/// Toast /// Toast
private func toastBanner(_ message: String, topInset: CGFloat) -> some View { private func toastBanner(_ message: String) -> some View {
HStack { HStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 16, weight: .semibold))
Text(message) Text(message)
.font(.system(size: 14, weight: .medium)) .font(.system(size: 16, weight: .medium))
.lineLimit(2) .lineLimit(3)
.multilineTextAlignment(.center) .multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .center)
} }
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal) .padding(.horizontal, 20)
.padding(.top, topInset + AppMetrics.Spacing.small) .padding(.vertical, 14)
.padding(.bottom, AppMetrics.Spacing.small) .background(Color.black.opacity(0.78), in: RoundedRectangle(cornerRadius: 12))
.frame(maxWidth: .infinity, alignment: .center) .padding(.horizontal, 40)
.background(AppDesign.primary.ignoresSafeArea(edges: .top))
} }
} }

View File

@ -14,6 +14,7 @@ struct suixinkanApp: App {
init() { init() {
AppUITestLaunchState.resetIfNeeded() AppUITestLaunchState.resetIfNeeded()
AMapBootstrap.configure(apiKey: AMapConfig.apiKey)
} }
var body: some Scene { var body: some Scene {

View File

@ -79,7 +79,7 @@ Loading 的可观察状态只在 `GlobalLoadingOverlayHost` 内部订阅,并
`ToastCenter` 只用于展示轻量提示文案,业务页面通过 `toastCenter.show(...)` 发出提示命令,不直接读取 Toast 展示状态。 `ToastCenter` 只用于展示轻量提示文案,业务页面通过 `toastCenter.show(...)` 发出提示命令,不直接读取 Toast 展示状态。
Toast UI 是顶部全宽横幅,背景使用不透明主色并延伸到顶部安全区和屏幕左右边;文案居中展示,无关闭按钮,默认 2.2 秒自动隐藏。新的 Toast 会覆盖旧 Toast 并重新计时。 Toast UI 是屏幕中央的黑色半透明圆角卡片,左侧带警告图标,文案 16pt以透明度淡入淡出,无关闭按钮,默认 2.2 秒自动隐藏。新的 Toast 会覆盖旧 Toast 并重新计时。
## Upload ## Upload

View File

@ -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
}
}

View File

@ -37,6 +37,17 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
`HomeView` 不创建自己的 `NavigationStack`,而是使用 Main Tab 注入的 `RouterPath` 进行页面跳转。 `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` 接管。 目前 `payment_collection``payment_qr``payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`cooperation_order` 已由 `Features/CooperationOrder` 接管,`scenicselection``permission_apply``permission_apply_status``scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management``task_management_editor``task_create` 已由 `Features/Tasks` 接管,`cloud_management``cloud_storage_transit``asset_management``material_upload``album_list``album_trailer``sample_management``sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report``location_report_history` 已由 `Features/LocationReport` 接管,`pm``project_edit` 已由 `Features/Projects` 的摄影师项目管理接管,`pm_manager` 已由店铺项目管理接管,`schedule_management` 已由 `Features/Schedule` 接管,`registration_invitation``photographer_invite``invite_record` 已由 `Features/Invite` 接管,`deposit_order_detail``deposit_order``deposit_order_shooting_info` 已由 `Features/Orders` 的押金订单页接管,`withdrawal_audit` 已由 `Features/WithdrawalAudit` 接管,`scenic_settlement``scenic_settlement_review` 已由 `Features/ScenicSettlement` 接管,`message_center` 已由 `Features/MessageCenter` 接管,`/scenic-queue``queue_management` 已由 `Features/QueueManagement` 接管,`live_stream_management``live_album` 已由 `Features/Live` 接管,`operating-area` 已由 `Features/OperatingArea` 接管,`pilot_cert` 已由 `Features/PilotCertification` 接管。

View File

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

View File

@ -13,7 +13,6 @@ struct HomeMoreFunctionsView: View {
@EnvironmentObject private var permissionContext: PermissionContext @EnvironmentObject private var permissionContext: PermissionContext
@EnvironmentObject private var appRouter: AppRouter @EnvironmentObject private var appRouter: AppRouter
@EnvironmentObject private var router: RouterPath @EnvironmentObject private var router: RouterPath
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = HomeViewModel() @StateObject private var viewModel = HomeViewModel()
@State private var commonUris: [String] = [] @State private var commonUris: [String] = []
@ -31,22 +30,18 @@ struct HomeMoreFunctionsView: View {
} }
var body: some View { var body: some View {
VStack(spacing: 0) {
topBar
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 26) { VStack(alignment: .leading, spacing: 26) {
section(title: "常用应用", items: commonItems, isCommon: true) section(title: "常用应用", items: commonItems, isCommon: true)
section(title: "更多功能", items: moreItems, isCommon: false) section(title: "更多功能", items: moreItems, isCommon: false)
} }
.padding(.horizontal, AppMetrics.Spacing.mediumLarge) .padding(.horizontal, AppMetrics.Spacing.mediumLarge)
.padding(.top, AppMetrics.Spacing.xxLarge + 1) .padding(.top, AppMetrics.Spacing.medium)
.padding(.bottom, AppMetrics.Spacing.xxLarge) .padding(.bottom, AppMetrics.Spacing.xxLarge)
} }
.background(Color(hex: 0xF5F5F5)) .background(Color(hex: 0xF5F5F5))
} .navigationTitle("全部功能")
.background(Color(hex: 0xF5F5F5).ignoresSafeArea()) .navigationBarTitleDisplayMode(.inline)
.toolbar(.hidden, for: .navigationBar)
.task { .task {
rebuildMenus() 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 { private func section(title: String, items: [HomeMenuItem], isCommon: Bool) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) { VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
Text(title) Text(title)

View File

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

View File

@ -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 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 /// API
@ -78,4 +81,15 @@ final class LocationReportAPI: LocationReportServing {
APIRequest(method: .get, path: "/api/yf-handset-app/photog/loacation/list", queryItems: query) 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)")]
)
)
}
} }

View File

@ -4,7 +4,7 @@
LocationReport 模块负责首页 `location_report``location_report_history` 入口。 LocationReport 模块负责首页 `location_report``location_report_history` 入口。
本模块包含当前位置获取、标记点、在线状态、立即上报、提醒设置、历史记录、类型筛选、日期筛选和分页。上报状态只保存在页面 ViewModel 内,不进入全局 App 状态 本模块包含当前位置获取、标记点、在线状态、立即上报、提醒设置、历史记录、类型筛选、日期筛选和分页。在线状态、倒计时和提醒设置与首页共享 `HomeLocationViewModel`
## 数据来源 ## 数据来源
@ -12,27 +12,28 @@ LocationReport 模块负责首页 `location_report` 和 `location_report_history
- 上报人员 ID 从 `AccountSnapshotStore.load()?.businessUserId` 读取,避免把业务账号 ID 复制进页面全局状态。 - 上报人员 ID 从 `AccountSnapshotStore.load()?.businessUserId` 读取,避免把业务账号 ID 复制进页面全局状态。
- 提交接口使用旧工程拼写 `/api/yf-handset-app/photog/loacation/report` - 提交接口使用旧工程拼写 `/api/yf-handset-app/photog/loacation/report`
- 历史接口使用 `/api/yf-handset-app/photog/loacation/list` - 历史接口使用 `/api/yf-handset-app/photog/loacation/list`
- 超时检查接口使用 `/api/yf-handset-app/photog/loacation/detail`
## 上报流程 ## 上报流程
`LocationReportViewModel` 管理当前位置、标记点、在线状态、提醒分钟数和页面内倒计时 `LocationReportViewModel` 管理当前位置、标记点和提交表单;`HomeLocationViewModel` 管理在线状态、倒计时、提醒和 API 提交
1. 页面进入时请求一次前台定位。 1. 页面进入时请求一次前台定位。
2. 用户可以立即上报当前位置,也可以把当前位置设为标记点后上报。 2. 用户可以立即上报当前位置,也可以把当前位置设为标记点后上报。
3. 切换在线状态时提交一次在线状态上报。 3. 切换在线时提交 `type=1` 立即上报;切换离线时提交 `type=3` 离线上报。
4. 上报成功后按服务端 `expired` 重置倒计时;服务端未返回时兜底为 2 小时。 4. 上报成功后按服务端 `expired`(过期 Unix 时间戳)重置倒计时,并持久化到本地;下次启动优先用本地过期时间戳计算剩余时间。服务端未返回时兜底为 2 小时。
## 历史流程 ## 历史流程
`LocationReportHistoryViewModel` 管理类型筛选、日期筛选、分页和错误状态。 `LocationReportHistoryViewModel` 管理类型筛选、日期筛选、分页和错误状态。
历史记录支持 `全部``立即上报``标记点``在线状态` 四类筛选。分页失败只保留错误提示,不影响登录态和其他模块。 历史记录支持 `全部``立即上报``标记点``离线上报` 四类筛选。分页失败只保留错误提示,不影响登录态和其他模块。
## 定位边界 ## 定位边界
当前只做前台手动定位和页面内倒计时。本轮不做后台持续定位、后台定时上报、离线持久化队列或推送提醒。 当前只做前台手动定位和页面内倒计时。本轮不做后台持续定位、后台定时上报、离线持久化队列或推送提醒。
定位结果、在线状态、提醒倒计时和提交表单均不落盘 在线状态、过期时间戳、上次上报时间和提醒分钟通过 `LocationStateStore` 持久化;坐标表单仍只在页面内维护
## 测试要求 ## 测试要求

View File

@ -7,12 +7,12 @@
import Foundation import Foundation
/// 线 /// 线
enum LocationReportType: Int, CaseIterable, Identifiable { enum LocationReportType: Int, CaseIterable, Identifiable {
case all = 0 case all = 0
case immediate = 1 case immediate = 1
case marked = 2 case marked = 2
case onlineStatus = 3 case offlineReport = 3
var id: Int { rawValue } var id: Int { rawValue }
@ -22,7 +22,7 @@ enum LocationReportType: Int, CaseIterable, Identifiable {
case .all: "全部" case .all: "全部"
case .immediate: "立即上报" case .immediate: "立即上报"
case .marked: "标记点" case .marked: "标记点"
case .onlineStatus: "在线状态" case .offlineReport: "离线上报"
} }
} }
} }
@ -33,7 +33,40 @@ struct LocationCoordinate: Equatable {
var longitude: Double 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 { struct LocationReportSubmitResponse: Decodable, Equatable {
let staffId: String let staffId: String
let expired: Int let expired: Int

View File

@ -8,28 +8,16 @@
import Foundation import Foundation
import Combine import Combine
/// ViewModel线 /// ViewModel
@MainActor @MainActor
final class LocationReportViewModel: ObservableObject { final class LocationReportViewModel: ObservableObject {
@Published var isOnline = false
@Published var reminderMinutes = 30
@Published var secondsUntilReport = 0
@Published var currentCoordinate: LocationCoordinate? @Published var currentCoordinate: LocationCoordinate?
@Published var markedCoordinate: LocationCoordinate? @Published var markedCoordinate: LocationCoordinate?
@Published var currentAddress = "" @Published var currentAddress = ""
@Published var markedAddress = "" @Published var markedAddress = ""
@Published var lastReportText = ""
@Published var errorMessage: String? @Published var errorMessage: String?
@Published var isSubmitting = false @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) { func applyCurrentLocation(latitude: Double, longitude: Double, address: String) {
currentCoordinate = LocationCoordinate(latitude: latitude, longitude: longitude) currentCoordinate = LocationCoordinate(latitude: latitude, longitude: longitude)
@ -42,24 +30,13 @@ final class LocationReportViewModel: ObservableObject {
markedAddress = address 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( func submit(
type: LocationReportType, type: LocationReportType,
staffId: Int?, staffId: Int?,
scenicId: Int?, scenicId: Int?,
api: any LocationReportServing homeLocationViewModel: HomeLocationViewModel,
toast: ToastCenter
) async -> Bool { ) async -> Bool {
guard !isSubmitting else { return false } guard !isSubmitting else { return false }
guard let staffId, staffId > 0 else { guard let staffId, staffId > 0 else {
@ -86,28 +63,19 @@ final class LocationReportViewModel: ObservableObject {
errorMessage = nil errorMessage = nil
defer { isSubmitting = false } defer { isSubmitting = false }
do { let success = await homeLocationViewModel.reportCoordinate(
let response = try await api.reportLocation(
staffId: staffId,
latitude: coordinate.latitude, latitude: coordinate.latitude,
longitude: coordinate.longitude, longitude: coordinate.longitude,
address: address, address: address,
type: type, type: type,
scenicId: scenicId staffId: staffId,
scenicId: scenicId,
toast: toast
) )
secondsUntilReport = response.expired > 0 ? response.expired : 7200 if !success, errorMessage == nil {
lastReportText = LocationReportViewModel.displayFormatter.string(from: Date()) errorMessage = "上报失败"
return true
} catch {
errorMessage = error.localizedDescription
return false
} }
} return success
///
func tick() {
guard secondsUntilReport > 0 else { return }
secondsUntilReport -= 1
} }
/// ///
@ -115,16 +83,10 @@ final class LocationReportViewModel: ObservableObject {
switch type { switch type {
case .marked: case .marked:
(markedCoordinate ?? currentCoordinate, markedAddress.isEmpty ? currentAddress : markedAddress) (markedCoordinate ?? currentCoordinate, markedAddress.isEmpty ? currentAddress : markedAddress)
case .all, .immediate, .onlineStatus: case .all, .immediate, .offlineReport:
(currentCoordinate, currentAddress) (currentCoordinate, currentAddress)
} }
} }
private static let displayFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
} }
/// ViewModel /// ViewModel

View File

@ -11,6 +11,7 @@ import SwiftUI
/// 线 /// 线
struct LocationReportView: View { struct LocationReportView: View {
@EnvironmentObject private var accountContext: AccountContext @EnvironmentObject private var accountContext: AccountContext
@EnvironmentObject private var homeLocationViewModel: HomeLocationViewModel
@Environment(\.accountSnapshotStore) private var snapshotStore @Environment(\.accountSnapshotStore) private var snapshotStore
@Environment(\.locationReportAPI) private var locationReportAPI @Environment(\.locationReportAPI) private var locationReportAPI
@EnvironmentObject private var router: RouterPath @EnvironmentObject private var router: RouterPath
@ -19,6 +20,8 @@ struct LocationReportView: View {
@StateObject private var viewModel = LocationReportViewModel() @StateObject private var viewModel = LocationReportViewModel()
@State private var locationProvider = ForegroundLocationProvider() @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() private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View { var body: some View {
@ -46,11 +49,36 @@ struct LocationReportView: View {
.accessibilityLabel("上报历史") .accessibilityLabel("上报历史")
} }
} }
.onReceive(timer) { _ in viewModel.tick() } .onReceive(timer) { _ in homeLocationViewModel.tick() }
.task { .task {
homeLocationViewModel.restoreState()
guard viewModel.currentCoordinate == nil else { return } guard viewModel.currentCoordinate == nil else { return }
await locateCurrent() 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) { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack { HStack {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Text(viewModel.isOnline ? "当前在线" : "当前离线") Text(homeLocationViewModel.isOnline ? "当前在线" : "当前离线")
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary) .foregroundStyle(AppDesign.textPrimary)
Text(viewModel.countdownText) Text(countdownText)
.font(.system(size: AppMetrics.FontSize.subheadline)) .font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary) .foregroundStyle(AppDesign.textSecondary)
} }
Spacer() Spacer()
Toggle("", isOn: Binding( Button {
get: { viewModel.isOnline }, showOnlineDialog = true
set: { value in } label: {
Task { await toggleOnline(value) } 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)
.labelsHidden()
} }
if !viewModel.lastReportText.isEmpty { if !homeLocationViewModel.reportTimeText.isEmpty {
Text("上次上报:\(viewModel.lastReportText)") Text("上次上报:\(homeLocationViewModel.reportTimeText)")
.font(.system(size: AppMetrics.FontSize.caption)) .font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.placeholder) .foregroundStyle(AppDesign.placeholder)
} }
@ -84,6 +116,13 @@ struct LocationReportView: View {
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) .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 { private var currentLocationSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
@ -149,8 +188,14 @@ struct LocationReportView: View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("提醒设置") Text("提醒设置")
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
Stepper("提前 \(viewModel.reminderMinutes) 分钟提醒", value: $viewModel.reminderMinutes, in: 5...120, step: 5) Button {
showReminderDialog = true
} label: {
Text(homeLocationViewModel.reminderMinutes == 0 ? "不提醒" : "提前 \(homeLocationViewModel.reminderMinutes) 分钟提醒")
.font(.system(size: AppMetrics.FontSize.subheadline)) .font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.primary)
}
.buttonStyle(.plain)
} }
.padding(AppMetrics.Spacing.medium) .padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
@ -177,7 +222,7 @@ struct LocationReportView: View {
} }
.buttonStyle(.bordered) .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 { private func submit(type: LocationReportType) async {
let success = await globalLoading.withLoading { 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 ?? "上报失败")) toastCenter.show(success ? "上报成功" : (viewModel.errorMessage ?? "上报失败"))
} }

View File

@ -51,54 +51,88 @@ final class LocationReportAPITests: XCTestCase {
XCTAssertEqual(payload.list.first?.latitude, "30.1") 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 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 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 @MainActor
/// ViewModel /// ViewModel
final class LocationReportViewModelTests: XCTestCase { final class LocationReportViewModelTests: XCTestCase {
/// staffId scenicId /// staffId scenicId
func testSubmitRequiresStaffAndScenic() async { func testSubmitRequiresStaffAndScenic() async {
let api = MockLocationReportService() let api = MockLocationReportService()
let homeVM = HomeLocationViewModel(api: api, store: makeIsolatedStore())
let toast = ToastCenter()
let viewModel = LocationReportViewModel() let viewModel = LocationReportViewModel()
viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口")
let missingStaff = await viewModel.submit(type: .immediate, staffId: nil, scenicId: 88, 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, api: api) let missingScenic = await viewModel.submit(type: .immediate, staffId: 77, scenicId: nil, homeLocationViewModel: homeVM, toast: toast)
XCTAssertFalse(missingStaff) XCTAssertFalse(missingStaff)
XCTAssertFalse(missingScenic) XCTAssertFalse(missingScenic)
XCTAssertEqual(api.reportRequests.count, 0) XCTAssertEqual(api.reportRequests.count, 0)
} }
/// 线使 /// 使
func testSubmitUsesExpectedTypes() async { func testSubmitUsesExpectedCoordinates() async {
let api = MockLocationReportService() let api = MockLocationReportService()
let homeVM = HomeLocationViewModel(api: api, store: makeIsolatedStore())
let toast = ToastCenter()
let viewModel = LocationReportViewModel() let viewModel = LocationReportViewModel()
viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口")
viewModel.applyMarkedLocation(latitude: 31.1, longitude: 121.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, homeLocationViewModel: homeVM, toast: toast)
_ = await viewModel.submit(type: .marked, staffId: 77, scenicId: 88, api: api)
_ = await viewModel.setOnline(true, staffId: 77, scenicId: 88, api: api)
XCTAssertEqual(api.reportRequests.map(\.type), [.immediate, .marked, .onlineStatus]) XCTAssertEqual(api.reportRequests.map(\.type), [.marked])
XCTAssertEqual(api.reportRequests[1].latitude, 31.1) XCTAssertEqual(api.reportRequests.first?.latitude, 31.1)
XCTAssertEqual(viewModel.secondsUntilReport, 600) XCTAssertEqual(homeVM.secondsUntilReport, 600)
} }
/// ///
func testSubmitFailureKeepsState() async { func testSubmitImmediateUpdatesCountdown() async {
let api = MockLocationReportService() let api = MockLocationReportService()
api.shouldFailReport = true let homeVM = HomeLocationViewModel(api: api, store: makeIsolatedStore())
let toast = ToastCenter()
let viewModel = LocationReportViewModel() let viewModel = LocationReportViewModel()
viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") 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) XCTAssertFalse(success)
XCTAssertEqual(viewModel.secondsUntilReport, 0) XCTAssertEqual(homeVM.secondsUntilReport, 0)
XCTAssertNotNil(viewModel.errorMessage) XCTAssertNotNil(viewModel.errorMessage)
} }
@ -120,6 +154,201 @@ final class LocationReportViewModelTests: XCTestCase {
XCTAssertEqual(api.historyRequests.map(\.page), [1, 2]) XCTAssertEqual(api.historyRequests.map(\.page), [1, 2])
XCTAssertEqual(api.historyRequests.first?.type, .marked) 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 /// API URLSession
@ -142,8 +371,10 @@ private final class LocationRecordingSession: URLSessionProtocol {
@MainActor @MainActor
/// ///
private final class MockLocationReportService: LocationReportServing { final class MockLocationReportService: LocationReportServing {
var shouldFailReport = false 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 reportRequests: [(staffId: Int, latitude: Double, longitude: Double, address: String, type: LocationReportType, scenicId: Int)] = []
var historyPages: [ListPayload<LocationReportHistoryItem>] = [ListPayload(total: 0, list: [])] var historyPages: [ListPayload<LocationReportHistoryItem>] = [ListPayload(total: 0, list: [])]
var historyRequests: [(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?)] = [] 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 { 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) } if shouldFailReport { throw NSError(domain: "location", code: 1) }
reportRequests.append((staffId, latitude, longitude, address, type, scenicId)) 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<LocationReportHistoryItem> { func locationReportList(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?) async throws -> ListPayload<LocationReportHistoryItem> {
historyRequests.append((staffId, page, pageSize, type, startDate, endDate)) historyRequests.append((staffId, page, pageSize, type, startDate, endDate))
return historyPages.isEmpty ? ListPayload(total: 0, list: []) : historyPages.removeFirst() return historyPages.isEmpty ? ListPayload(total: 0, list: []) : historyPages.removeFirst()
} }
func locationDetail(staffId: Int) async throws -> LocationDetailResponse {
detailResponse
}
} }
private extension LocationReportHistoryItem { private extension LocationReportHistoryItem {

View File

@ -23,7 +23,7 @@ enum BackNavigator {
app.waitForGlobalLoadingToDisappear() app.waitForGlobalLoadingToDisappear()
} }
/// /// 使
static func dismissCustomTopBarIfNeeded(app: SuixinkanApp) { static func dismissCustomTopBarIfNeeded(app: SuixinkanApp) {
let backCandidates = app.application.buttons.matching(NSPredicate(format: "label CONTAINS 'chevron.left'")) let backCandidates = app.application.buttons.matching(NSPredicate(format: "label CONTAINS 'chevron.left'"))
if backCandidates.count > 0 { if backCandidates.count > 0 {