Add location report map page and history list aligned with Android.
Extract shared LocationReportService, wire location_report menu routing, and add unit tests for reporting cooldown and history pagination. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -64,6 +64,36 @@ final class HomeAPI {
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取位置上报历史列表。
|
||||
func locationReportList(
|
||||
staffId: String,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
type: Int,
|
||||
startDate: String?,
|
||||
endDate: String?
|
||||
) async throws -> LocationReportHistoryListResponse {
|
||||
var queryItems = [
|
||||
URLQueryItem(name: "staff_id", value: staffId),
|
||||
URLQueryItem(name: "page", value: String(page)),
|
||||
URLQueryItem(name: "page_size", value: String(pageSize)),
|
||||
URLQueryItem(name: "type", value: String(type)),
|
||||
]
|
||||
if let startDate, !startDate.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "start_date", value: startDate))
|
||||
}
|
||||
if let endDate, !endDate.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "end_date", value: endDate))
|
||||
}
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/loacation/list",
|
||||
queryItems: queryItems
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 拉取待审核权限申请列表。
|
||||
func roleApplyPending() async throws -> [RoleApplyPendingResponse] {
|
||||
try await client.send(
|
||||
|
||||
@ -36,3 +36,130 @@ struct LocationReportResponse: Decodable, Sendable {
|
||||
case status
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史上报类型筛选,对齐 Android `LocationReportFilterType`。
|
||||
enum LocationReportFilterType: CaseIterable, Equatable {
|
||||
case all
|
||||
case immediate
|
||||
case marked
|
||||
case offline
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .all: "全部"
|
||||
case .immediate: "立即上报"
|
||||
case .marked: "标记上报"
|
||||
case .offline: "离线上报"
|
||||
}
|
||||
}
|
||||
|
||||
var typeCode: Int {
|
||||
switch self {
|
||||
case .all: 0
|
||||
case .immediate: 1
|
||||
case .marked: 2
|
||||
case .offline: 3
|
||||
}
|
||||
}
|
||||
|
||||
var tagColorHex: UInt {
|
||||
switch self {
|
||||
case .all, .immediate: 0x0073FF
|
||||
case .marked: 0x22C55E
|
||||
case .offline: 0x999999
|
||||
}
|
||||
}
|
||||
|
||||
static func from(typeCode: Int) -> LocationReportFilterType {
|
||||
switch typeCode {
|
||||
case 1: .immediate
|
||||
case 2: .marked
|
||||
case 3: .offline
|
||||
default: .all
|
||||
}
|
||||
}
|
||||
|
||||
static func fromReportType(_ type: Int) -> LocationReportFilterType {
|
||||
switch type {
|
||||
case 1: .immediate
|
||||
case 2: .marked
|
||||
case 3: .offline
|
||||
default: .all
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史上报单条记录,对齐 Android `LocationReportHistoryItem`。
|
||||
struct LocationReportHistoryItem: Decodable, Equatable, Identifiable {
|
||||
let id: Int64
|
||||
let staffId: Int64
|
||||
let type: Int
|
||||
let latitude: String
|
||||
let longitude: String
|
||||
let address: String
|
||||
let ip: String?
|
||||
let remark: String
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case staffId = "staff_id"
|
||||
case type
|
||||
case latitude
|
||||
case longitude
|
||||
case address
|
||||
case ip
|
||||
case remark
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeIfPresent(Int64.self, forKey: .id) ?? 0
|
||||
staffId = try container.decodeIfPresent(Int64.self, forKey: .staffId) ?? 0
|
||||
type = try container.decodeIfPresent(Int.self, forKey: .type) ?? 0
|
||||
latitude = try container.decodeIfPresent(String.self, forKey: .latitude) ?? ""
|
||||
longitude = try container.decodeIfPresent(String.self, forKey: .longitude) ?? ""
|
||||
address = try container.decodeIfPresent(String.self, forKey: .address) ?? ""
|
||||
ip = try container.decodeIfPresent(String.self, forKey: .ip)
|
||||
remark = try container.decodeIfPresent(String.self, forKey: .remark) ?? ""
|
||||
createdAt = try container.decodeIfPresent(String.self, forKey: .createdAt) ?? ""
|
||||
}
|
||||
|
||||
init(
|
||||
id: Int64 = 0,
|
||||
staffId: Int64 = 0,
|
||||
type: Int = 0,
|
||||
latitude: String = "",
|
||||
longitude: String = "",
|
||||
address: String = "",
|
||||
ip: String? = nil,
|
||||
remark: String = "",
|
||||
createdAt: String = ""
|
||||
) {
|
||||
self.id = id
|
||||
self.staffId = staffId
|
||||
self.type = type
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.address = address
|
||||
self.ip = ip
|
||||
self.remark = remark
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
var filterType: LocationReportFilterType {
|
||||
LocationReportFilterType.fromReportType(type)
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史上报列表响应。
|
||||
struct LocationReportHistoryListResponse: Decodable, Equatable {
|
||||
let list: [LocationReportHistoryItem]
|
||||
let total: Int
|
||||
|
||||
init(list: [LocationReportHistoryItem] = [], total: Int = 0) {
|
||||
self.list = list
|
||||
self.total = total
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,14 +46,8 @@ final class HomeLocationProvider: NSObject, CLLocationManagerDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
private func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.coordinateContinuation = continuation
|
||||
manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
/// 逆地理编码,供地图标记使用。
|
||||
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
|
||||
let location = CLLocation(latitude: latitude, longitude: longitude)
|
||||
let geocoder = CLGeocoder()
|
||||
do {
|
||||
@ -64,6 +58,13 @@ final class HomeLocationProvider: NSObject, CLLocationManagerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func requestCoordinate() async throws -> CLLocationCoordinate2D {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.coordinateContinuation = continuation
|
||||
manager.requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let location = locations.last else { return }
|
||||
coordinateContinuation?.resume(returning: location.coordinate)
|
||||
@ -82,7 +83,7 @@ enum HomeLocationProviderError: LocalizedError {
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .permissionDenied: "需要位置权限才能上线"
|
||||
case .permissionDenied: "需要定位权限才能使用此功能"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,6 +80,15 @@ final class HomeLocationStateStore {
|
||||
notifyChange()
|
||||
}
|
||||
|
||||
var countdownDisplayText: String {
|
||||
formattedCountdown(from: nextReportCountdownSeconds)
|
||||
}
|
||||
|
||||
/// Android 独立页倒计时格式 `H:MM:SS`。
|
||||
var countdownDisplayTextSingleHour: String {
|
||||
formattedCountdownSingleHour(from: nextReportCountdownSeconds)
|
||||
}
|
||||
|
||||
/// 格式化倒计时文本 `HH:MM:SS`。
|
||||
func formattedCountdown(from seconds: Int64) -> String {
|
||||
let hours = seconds / 3600
|
||||
@ -88,8 +97,12 @@ final class HomeLocationStateStore {
|
||||
return String(format: "%02d:%02d:%02d", hours, minutes, secs)
|
||||
}
|
||||
|
||||
var countdownDisplayText: String {
|
||||
formattedCountdown(from: nextReportCountdownSeconds)
|
||||
/// 格式化倒计时文本 `H:MM:SS`。
|
||||
func formattedCountdownSingleHour(from seconds: Int64) -> String {
|
||||
let hours = seconds / 3600
|
||||
let minutes = (seconds % 3600) / 60
|
||||
let secs = seconds % 60
|
||||
return String(format: "%d:%02d:%02d", hours, minutes, secs)
|
||||
}
|
||||
|
||||
/// 是否到达提前提醒窗口。
|
||||
|
||||
@ -48,6 +48,8 @@ enum HomeRouteHandler {
|
||||
return
|
||||
}
|
||||
viewController.navigationController?.pushViewController(CooperationOrderListViewController(), animated: true)
|
||||
case "location_report":
|
||||
viewController.navigationController?.pushViewController(LocationReportViewController(), animated: true)
|
||||
case "task_management", "task_management_editor":
|
||||
viewController.navigationController?.pushViewController(TaskAddViewController(), animated: true)
|
||||
default:
|
||||
|
||||
@ -27,15 +27,13 @@ final class HomeViewModel {
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
|
||||
let locationStateStore: HomeLocationStateStore
|
||||
let locationReportService: LocationReportService
|
||||
private let appStore: AppStore
|
||||
private let commonMenuStore: HomeCommonMenuStore
|
||||
private let locationProvider: HomeLocationProvider
|
||||
|
||||
private var lastOnlineStatusSwitchTime: Int64 = 0
|
||||
private var lastLocationReportTime: Int64 = 0
|
||||
private var timeoutDialogTriggered = false
|
||||
private var dismissedLocationTimeoutDialog = false
|
||||
private let operationIntervalMillis: Int64 = 30_000
|
||||
|
||||
init(
|
||||
appStore: AppStore = .shared,
|
||||
@ -45,8 +43,13 @@ final class HomeViewModel {
|
||||
) {
|
||||
self.appStore = appStore
|
||||
self.locationStateStore = locationStateStore
|
||||
self.commonMenuStore = commonMenuStore
|
||||
self.locationProvider = locationProvider
|
||||
self.locationReportService = LocationReportService(
|
||||
appStore: appStore,
|
||||
locationStateStore: locationStateStore,
|
||||
locationProvider: locationProvider
|
||||
)
|
||||
self.commonMenuStore = commonMenuStore
|
||||
self.locationStateStore.onStateChange = { [weak self] in
|
||||
self?.notifyStateChange()
|
||||
}
|
||||
@ -288,60 +291,92 @@ final class HomeViewModel {
|
||||
|
||||
/// 切换在线/离线并触发位置上报。
|
||||
func switchOnlineStatus(api: HomeAPI) async {
|
||||
let now = currentTimestampMillis()
|
||||
if now - lastOnlineStatusSwitchTime < operationIntervalMillis {
|
||||
let remaining = Int((operationIntervalMillis - (now - lastOnlineStatusSwitchTime)) / 1000)
|
||||
onShowMessage?("操作过于频繁,请\(remaining)秒后再试")
|
||||
hideOnlineStatusSwitchDialog()
|
||||
return
|
||||
}
|
||||
guard appStore.currentScenicId > 0 else {
|
||||
onShowMessage?("请先选择景区")
|
||||
hideOnlineStatusSwitchDialog()
|
||||
return
|
||||
hideOnlineStatusSwitchDialog()
|
||||
isLocationReporting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLocationReporting = locationReportService.isReporting
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
let goingOnline = !isOnline
|
||||
if goingOnline {
|
||||
do {
|
||||
_ = try await locationProvider.requestSnapshot()
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
hideOnlineStatusSwitchDialog()
|
||||
return
|
||||
do {
|
||||
let goingOnline = !isOnline
|
||||
if let result = try await locationReportService.switchOnlineStatus(api: api) {
|
||||
applyReportSuccess(result)
|
||||
onShowMessage?(goingOnline ? "已切换到在线状态,正在上报位置" : "已切换到离线状态")
|
||||
} else {
|
||||
onShowMessage?("已切换到离线状态")
|
||||
}
|
||||
locationStateStore.updateOnlineStatus(true)
|
||||
timeoutDialogTriggered = false
|
||||
dismissedLocationTimeoutDialog = false
|
||||
lastOnlineStatusSwitchTime = now
|
||||
onShowMessage?("已切换到在线状态,正在上报位置")
|
||||
hideOnlineStatusSwitchDialog()
|
||||
await reportLocation(api: api, type: 1)
|
||||
} else {
|
||||
locationStateStore.updateOnlineStatus(false)
|
||||
lastOnlineStatusSwitchTime = now
|
||||
onShowMessage?("已切换到离线状态")
|
||||
hideOnlineStatusSwitchDialog()
|
||||
await reportLocation(api: api, type: 3)
|
||||
} catch let error as LocationReportServiceError {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动上报位置。
|
||||
func manualReportLocation(api: HomeAPI) async {
|
||||
let now = currentTimestampMillis()
|
||||
if now - lastLocationReportTime < operationIntervalMillis {
|
||||
let remaining = Int((operationIntervalMillis - (now - lastLocationReportTime)) / 1000)
|
||||
onShowMessage?("操作过于频繁,请\(remaining)秒后再试")
|
||||
return
|
||||
isLocationReporting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLocationReporting = locationReportService.isReporting
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
let result = try await locationReportService.manualReport(api: api)
|
||||
applyReportSuccess(result)
|
||||
onShowMessage?("位置上报成功")
|
||||
} catch let error as LocationReportServiceError {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定坐标上报(标记上报 type=2)。
|
||||
func reportLocation(
|
||||
api: HomeAPI,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
address: String?,
|
||||
type: Int
|
||||
) async {
|
||||
isLocationReporting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLocationReporting = locationReportService.isReporting
|
||||
notifyStateChange()
|
||||
}
|
||||
do {
|
||||
let result = try await locationReportService.reportWithCoordinates(
|
||||
api: api,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
address: address,
|
||||
type: type
|
||||
)
|
||||
applyReportSuccess(result)
|
||||
onShowMessage?("位置上报成功")
|
||||
} catch let error as LocationReportServiceError {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
await reportLocation(api: api, type: 1)
|
||||
}
|
||||
|
||||
/// 位置超时弹窗确认上报。
|
||||
func confirmLocationTimeoutReport(api: HomeAPI) async {
|
||||
showLocationTimeoutDialog = false
|
||||
dismissedLocationTimeoutDialog = false
|
||||
await reportLocation(api: api, type: 1)
|
||||
await manualReportLocation(api: api)
|
||||
}
|
||||
|
||||
/// 触发本地倒计时提醒。
|
||||
@ -371,62 +406,15 @@ final class HomeViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func reportLocation(api: HomeAPI, type: Int) async {
|
||||
guard appStore.currentScenicId > 0 else {
|
||||
onShowMessage?("请先选择景区")
|
||||
return
|
||||
private func applyReportSuccess(_ result: LocationReportSuccessResult) {
|
||||
locationInfoText = result.locationInfoText
|
||||
if result.shouldShowSuccessDialog {
|
||||
reportTimeText = result.reportTimeText
|
||||
showLocationReportSuccessDialog = true
|
||||
timeoutDialogTriggered = false
|
||||
dismissedLocationTimeoutDialog = false
|
||||
}
|
||||
let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !staffId.isEmpty else {
|
||||
onShowMessage?("获取用户ID失败")
|
||||
return
|
||||
}
|
||||
|
||||
isLocationReporting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLocationReporting = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
let snapshot = try await locationProvider.requestSnapshot()
|
||||
locationInfoText = snapshot.address.isEmpty
|
||||
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
|
||||
: snapshot.address
|
||||
_ = try await api.reportLocation(
|
||||
staffId: staffId,
|
||||
latitude: snapshot.latitude,
|
||||
longitude: snapshot.longitude,
|
||||
address: snapshot.address,
|
||||
type: type,
|
||||
scenicId: String(appStore.currentScenicId)
|
||||
)
|
||||
lastLocationReportTime = currentTimestampMillis()
|
||||
if type != 3 {
|
||||
locationStateStore.startLocationReport()
|
||||
reportTimeText = formattedNow()
|
||||
showLocationReportSuccessDialog = true
|
||||
timeoutDialogTriggered = false
|
||||
dismissedLocationTimeoutDialog = false
|
||||
}
|
||||
notifyStateChange()
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func formattedNow() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
|
||||
private func currentTimestampMillis() -> Int64 {
|
||||
Int64(Date().timeIntervalSince1970 * 1000)
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
|
||||
Reference in New Issue
Block a user