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

持久化服务端 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

@ -16,6 +16,9 @@ protocol LocationReportServing {
///
func locationReportList(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?) async throws -> ListPayload<LocationReportHistoryItem>
///
func locationDetail(staffId: Int) async throws -> LocationDetailResponse
}
/// API
@ -78,4 +81,15 @@ final class LocationReportAPI: LocationReportServing {
APIRequest(method: .get, path: "/api/yf-handset-app/photog/loacation/list", queryItems: query)
)
}
///
func locationDetail(staffId: Int) async throws -> LocationDetailResponse {
try await client.send(
APIRequest(
method: .get,
path: "/api/yf-handset-app/photog/loacation/detail",
queryItems: [URLQueryItem(name: "staff_id", value: "\(staffId)")]
)
)
}
}

View File

@ -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` 持久化;坐标表单仍只在页面内维护
## 测试要求

View File

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

View File

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

View File

@ -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 ?? "上报失败"))
}