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:
2026-07-07 11:34:54 +08:00
parent 5a2b7b6097
commit d2879ad7e2
17 changed files with 1975 additions and 152 deletions

View File

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

View File

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

View File

@ -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: "需要位权限才能使用此功能"
}
}
}

View File

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

View File

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

View File

@ -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() {