添加位置上报地图页和历史列表并对齐 Android

This commit is contained in:
2026-07-07 11:34:54 +08:00
parent 3c25a0b789
commit 3acbf6315b
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() {

View File

@ -0,0 +1,207 @@
//
// LocationReportService.swift
// suixinkan
//
import Foundation
///
struct LocationReportSuccessResult: Equatable {
let reportTimeText: String
let locationInfoText: String
let shouldShowSuccessDialog: Bool
}
/// Android `HomeViewModel.reportLocation`
final class LocationReportService {
static let operationIntervalMillis: Int64 = 30_000
private(set) var lastOnlineStatusSwitchTime: Int64 = 0
private(set) var lastLocationReportTime: Int64 = 0
private(set) var isReporting = false
private let appStore: AppStore
private let locationStateStore: HomeLocationStateStore
private let locationProvider: HomeLocationProvider
init(
appStore: AppStore = .shared,
locationStateStore: HomeLocationStateStore,
locationProvider: HomeLocationProvider = HomeLocationProvider()
) {
self.appStore = appStore
self.locationStateStore = locationStateStore
self.locationProvider = locationProvider
}
/// 线/线
func switchOnlineStatus(api: HomeAPI) async throws -> LocationReportSuccessResult? {
let now = currentTimestampMillis()
if now - lastOnlineStatusSwitchTime < Self.operationIntervalMillis {
let remaining = Int((Self.operationIntervalMillis - (now - lastOnlineStatusSwitchTime)) / 1000)
throw LocationReportServiceError.tooFrequent(remainingSeconds: remaining)
}
guard appStore.currentScenicId > 0 else {
throw LocationReportServiceError.missingScenic
}
let goingOnline = !locationStateStore.isOnline
if goingOnline {
_ = try await locationProvider.requestSnapshot()
locationStateStore.updateOnlineStatus(true)
lastOnlineStatusSwitchTime = now
return try await reportWithCoordinates(
api: api,
latitude: nil,
longitude: nil,
address: nil,
type: 1,
showSuccessToast: true
)
}
locationStateStore.updateOnlineStatus(false)
lastOnlineStatusSwitchTime = now
_ = try await reportWithCoordinates(
api: api,
latitude: nil,
longitude: nil,
address: nil,
type: 3,
showSuccessToast: false
)
return nil
}
/// GPS
func manualReport(api: HomeAPI) async throws -> LocationReportSuccessResult {
let now = currentTimestampMillis()
if now - lastLocationReportTime < Self.operationIntervalMillis {
let remaining = Int((Self.operationIntervalMillis - (now - lastLocationReportTime)) / 1000)
throw LocationReportServiceError.tooFrequent(remainingSeconds: remaining)
}
return try await reportWithCoordinates(
api: api,
latitude: nil,
longitude: nil,
address: nil,
type: 1,
showSuccessToast: true
)
}
/// type=2
func reportWithCoordinates(
api: HomeAPI,
latitude: Double?,
longitude: Double?,
address: String?,
type: Int,
showSuccessToast: Bool = true
) async throws -> LocationReportSuccessResult {
if type != 3 {
let now = currentTimestampMillis()
if now - lastLocationReportTime < Self.operationIntervalMillis {
let remaining = Int((Self.operationIntervalMillis - (now - lastLocationReportTime)) / 1000)
throw LocationReportServiceError.tooFrequent(remainingSeconds: remaining)
}
}
guard appStore.currentScenicId > 0 else {
throw LocationReportServiceError.missingScenic
}
let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !staffId.isEmpty else {
throw LocationReportServiceError.missingStaffId
}
isReporting = true
defer { isReporting = false }
let snapshot: HomeLocationSnapshot
if let latitude, let longitude {
let resolvedAddress: String
if let address, !address.isEmpty {
resolvedAddress = address
} else {
resolvedAddress = await locationProvider.reverseGeocode(
latitude: latitude,
longitude: longitude
)
}
snapshot = HomeLocationSnapshot(
latitude: latitude,
longitude: longitude,
address: resolvedAddress
)
} else {
snapshot = try await locationProvider.requestSnapshot()
}
let locationText = 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()
let reportTime = formattedReportTime()
if showSuccessToast {
// Toast handled by caller via onShowMessage if needed
}
return LocationReportSuccessResult(
reportTimeText: reportTime,
locationInfoText: locationText,
shouldShowSuccessDialog: true
)
}
return LocationReportSuccessResult(
reportTimeText: "",
locationInfoText: locationText,
shouldShowSuccessDialog: false
)
}
private func formattedReportTime() -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "HH:mm"
return formatter.string(from: Date())
}
private func currentTimestampMillis() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
}
///
enum LocationReportServiceError: LocalizedError, Equatable {
case tooFrequent(remainingSeconds: Int)
case missingScenic
case missingStaffId
case locating
var errorDescription: String? {
switch self {
case .tooFrequent(let remaining):
"操作过于频繁,请\(remaining)秒后再试"
case .missingScenic:
"请先选择景区"
case .missingStaffId:
"获取用户ID失败"
case .locating:
"正在定位中,请稍候"
}
}
}

View File

@ -0,0 +1,124 @@
//
// LocationReportHistoryViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel Android `LocationReportHistoryViewModel`
final class LocationReportHistoryViewModel {
private(set) var reportList: [LocationReportHistoryItem] = []
private(set) var selectedFilter: LocationReportFilterType = .all
private(set) var isRefreshing = false
private(set) var canLoadMore = false
private(set) var startDate: Date?
private(set) var endDate: Date?
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private let appStore: AppStore
private var currentPage = 1
private let pageSize = 10
private var isLoading = false
init(appStore: AppStore = .shared) {
self.appStore = appStore
}
var startDateText: String { formattedDate(startDate) }
var endDateText: String { formattedDate(endDate) }
var hasDateFilter: Bool { startDate != nil || endDate != nil }
///
func loadReportList(api: HomeAPI, refresh: Bool = false) async {
guard !isLoading || refresh else { return }
let staffId = appStore.userId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !staffId.isEmpty else {
onShowMessage?("获取用户ID失败")
return
}
if refresh {
currentPage = 1
isRefreshing = true
notifyStateChange()
}
isLoading = true
defer {
isLoading = false
isRefreshing = false
notifyStateChange()
}
do {
let response = try await api.locationReportList(
staffId: staffId,
page: currentPage,
pageSize: pageSize,
type: selectedFilter.typeCode,
startDate: apiDateString(startDate),
endDate: apiDateString(endDate)
)
if refresh || currentPage == 1 {
reportList = response.list
} else {
reportList.append(contentsOf: response.list)
}
currentPage += 1
canLoadMore = reportList.count < response.total
notifyStateChange()
} catch is CancellationError {
return
} catch {
onShowMessage?(error.localizedDescription)
}
}
func selectFilter(_ filter: LocationReportFilterType, api: HomeAPI) async {
selectedFilter = filter
currentPage = 1
await loadReportList(api: api, refresh: true)
}
func setDateRange(start: Date?, end: Date?, api: HomeAPI) async {
startDate = start
endDate = end
currentPage = 1
await loadReportList(api: api, refresh: true)
}
func clearDateRange(api: HomeAPI) async {
startDate = nil
endDate = nil
currentPage = 1
await loadReportList(api: api, refresh: true)
}
func loadMoreIfNeeded(currentIndex: Int, api: HomeAPI) async {
guard canLoadMore, !isLoading else { return }
guard currentIndex >= reportList.count - 2 else { return }
await loadReportList(api: api, refresh: false)
}
private func apiDateString(_ date: Date?) -> String? {
guard let date else { return nil }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
private func formattedDate(_ date: Date?) -> String {
guard let date else { return "请选择" }
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
private func notifyStateChange() {
onStateChange?()
}
}

View File

@ -0,0 +1,197 @@
//
// LocationReportViewModel.swift
// suixinkan
//
import Foundation
/// ViewModel Android `LocationReportViewModel` +
final class LocationReportViewModel {
private(set) var currentLatitude: Double?
private(set) var currentLongitude: Double?
private(set) var currentAddress: String?
private(set) var isLocating = false
private(set) var isMarking = false
private(set) var markerLatitude: Double?
private(set) var markerLongitude: Double?
private(set) var markerAddress: String?
private(set) var showOnlineStatusDialog = false
private(set) var showLocationReportSuccessDialog = false
private(set) var reportTimeText = ""
let locationStateStore: HomeLocationStateStore
let locationReportService: LocationReportService
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
private let locationProvider: HomeLocationProvider
init(
locationStateStore: HomeLocationStateStore = HomeLocationStateStore(),
locationProvider: HomeLocationProvider = HomeLocationProvider()
) {
self.locationStateStore = locationStateStore
self.locationProvider = locationProvider
self.locationReportService = LocationReportService(
locationStateStore: locationStateStore,
locationProvider: locationProvider
)
self.locationStateStore.onStateChange = { [weak self] in
self?.notifyStateChange()
}
}
var isOnline: Bool { locationStateStore.isOnline }
var countdownDisplayText: String { locationStateStore.countdownDisplayTextSingleHour }
var reminderMinutes: Int { locationStateStore.reminderMinutes }
var isReporting: Bool { locationReportService.isReporting }
/// 线
func restoreStateIfNeeded() {
locationStateStore.restoreStateIfNeeded()
notifyStateChange()
}
///
func startLocation() {
guard !isLocating else { return }
isLocating = true
notifyStateChange()
Task {
defer {
isLocating = false
notifyStateChange()
}
do {
let snapshot = try await locationProvider.requestSnapshot()
currentLatitude = snapshot.latitude
currentLongitude = snapshot.longitude
currentAddress = snapshot.address.isEmpty
? String(format: "%.5f, %.5f", snapshot.latitude, snapshot.longitude)
: snapshot.address
notifyStateChange()
} catch {
onShowMessage?((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)
}
}
}
func toggleMarking() {
isMarking.toggle()
if !isMarking {
markerLatitude = nil
markerLongitude = nil
markerAddress = nil
}
notifyStateChange()
}
///
func setMarkerLocation(latitude: Double, longitude: Double) {
guard isMarking else { return }
markerLatitude = latitude
markerLongitude = longitude
markerAddress = nil
notifyStateChange()
Task {
let address = await locationProvider.reverseGeocode(latitude: latitude, longitude: longitude)
markerAddress = address.isEmpty
? String(format: "%.5f, %.5f", latitude, longitude)
: address
notifyStateChange()
}
}
func showOnlineStatusSwitchDialog() {
showOnlineStatusDialog = true
notifyStateChange()
}
func hideOnlineStatusSwitchDialog() {
showOnlineStatusDialog = false
notifyStateChange()
}
func hideLocationReportSuccessDialog() {
showLocationReportSuccessDialog = false
notifyStateChange()
}
func updateReminderMinutes(_ minutes: Int) {
locationStateStore.updateReminderMinutes(minutes)
notifyStateChange()
}
func switchOnlineStatus(api: HomeAPI) async {
hideOnlineStatusSwitchDialog()
do {
let goingOnline = !isOnline
if let result = try await locationReportService.switchOnlineStatus(api: api) {
applyReportSuccess(result)
onShowMessage?(goingOnline ? "已切换到在线状态,正在上报位置" : "已切换到离线状态")
} else {
onShowMessage?("已切换到离线状态")
}
} catch let error as LocationReportServiceError {
onShowMessage?(error.localizedDescription)
} catch {
onShowMessage?(error.localizedDescription)
}
}
/// type=2 type=1
func reportLocation(api: HomeAPI) async {
if isMarking {
guard let lat = markerLatitude, let lng = markerLongitude else {
onShowMessage?("请先在地图上选择标记点")
return
}
await reportCoordinates(api: api, latitude: lat, longitude: lng, address: markerAddress, type: 2)
return
}
guard let lat = currentLatitude, let lng = currentLongitude else {
onShowMessage?("正在定位中,请稍候")
return
}
await reportCoordinates(api: api, latitude: lat, longitude: lng, address: currentAddress, type: 1)
}
private func reportCoordinates(
api: HomeAPI,
latitude: Double,
longitude: Double,
address: String?,
type: Int
) async {
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 {
onShowMessage?(error.localizedDescription)
}
}
private func applyReportSuccess(_ result: LocationReportSuccessResult) {
if result.shouldShowSuccessDialog {
reportTimeText = result.reportTimeText
showLocationReportSuccessDialog = true
}
notifyStateChange()
}
private func notifyStateChange() {
onStateChange?()
}
}

View File

@ -382,65 +382,56 @@ final class HomeViewController: BaseViewController {
}
private func presentOnlineStatusDialog() {
let goingOnline = !viewModel.isOnline
let alert = UIAlertController(
title: goingOnline ? "切换在线" : "切换离线",
message: goingOnline ? "确认切换到在线状态并上报位置?" : "确认切换到离线状态?",
preferredStyle: .alert
viewModel.hideOnlineStatusSwitchDialog()
LocationReportDialogPresenter.presentOnlineStatusSwitch(
from: self,
isOnline: viewModel.isOnline,
onConfirm: { [weak self] in
guard let self else { return }
self.activeDialog = nil
Task { await self.viewModel.switchOnlineStatus(api: self.homeAPI) }
},
onCancel: { [weak self] in
self?.viewModel.hideOnlineStatusSwitchDialog()
self?.activeDialog = nil
}
)
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { [weak self] _ in
self?.viewModel.hideOnlineStatusSwitchDialog()
self?.activeDialog = nil
})
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
guard let self else { return }
self.activeDialog = nil
Task { await self.viewModel.switchOnlineStatus(api: self.homeAPI) }
})
present(alert, animated: true)
}
private func presentLocationTimeoutDialog() {
let alert = UIAlertController(
title: "位置上报提醒",
message: "您的位置上报即将超时,请立即上报",
preferredStyle: .alert
viewModel.hideLocationTimeoutDialog()
LocationReportDialogPresenter.presentLocationTimeout(
from: self,
onReport: { [weak self] in
guard let self else { return }
self.activeDialog = nil
Task { await self.viewModel.confirmLocationTimeoutReport(api: self.homeAPI) }
},
onLater: { [weak self] in
self?.viewModel.hideLocationTimeoutDialog()
self?.activeDialog = nil
}
)
alert.addAction(UIAlertAction(title: "稍后", style: .cancel) { [weak self] _ in
self?.viewModel.hideLocationTimeoutDialog()
self?.activeDialog = nil
})
alert.addAction(UIAlertAction(title: "立即上报", style: .default) { [weak self] _ in
guard let self else { return }
self.activeDialog = nil
Task { await self.viewModel.confirmLocationTimeoutReport(api: self.homeAPI) }
})
present(alert, animated: true)
}
private func presentLocationSuccessDialog() {
let alert = UIAlertController(
title: "上报成功",
message: "上报时间:\(viewModel.reportTimeText)",
preferredStyle: .alert
let reportTime = viewModel.reportTimeText
let countdown = viewModel.countdownDisplayText
viewModel.hideLocationReportSuccessDialog()
LocationReportDialogPresenter.presentReportSuccess(
from: self,
reportTime: reportTime,
countdown: countdown,
onDismiss: { [weak self] in
self?.activeDialog = nil
}
)
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
self?.viewModel.hideLocationReportSuccessDialog()
self?.activeDialog = nil
})
present(alert, animated: true)
}
private func presentReminderPicker() {
let alert = UIAlertController(title: "提前提醒", message: "选择位置超时提前提醒时间", preferredStyle: .actionSheet)
[0, 5, 10, 15, 30].forEach { minutes in
let title = minutes == 0 ? "不提醒" : "提前\(minutes)分钟"
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
self?.viewModel.updateReminderMinutes(minutes)
})
LocationReportDialogPresenter.presentReminderPicker(from: self) { [weak self] minutes in
self?.viewModel.updateReminderMinutes(minutes)
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
private func presentScenicSelection() {

View File

@ -0,0 +1,73 @@
//
// LocationReportDialogPresenter.swift
// suixinkan
//
import UIKit
/// Android
enum LocationReportDialogPresenter {
/// 线
@MainActor
static func presentOnlineStatusSwitch(
from viewController: UIViewController,
isOnline: Bool,
onConfirm: @escaping () -> Void,
onCancel: (() -> Void)? = nil
) {
let message = isOnline
? "是否确认切换为离线状态?\n离线后将暂停位置上报,直到下次手动切换为在线状态"
: "是否确认切换为在线状态?\n在线后将开始位置上报和计时"
let alert = UIAlertController(title: "切换在线状态", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel) { _ in onCancel?() })
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in onConfirm() })
viewController.present(alert, animated: true)
}
///
@MainActor
static func presentReportSuccess(
from viewController: UIViewController,
reportTime: String,
countdown: String,
onDismiss: (() -> Void)? = nil
) {
let message = "上报已成功,上报时间为\(reportTime)\n距离下次上报时间\(countdown)"
let alert = UIAlertController(title: "上报成功", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "确定", style: .default) { _ in onDismiss?() })
viewController.present(alert, animated: true)
}
///
@MainActor
static func presentLocationTimeout(
from viewController: UIViewController,
onReport: @escaping () -> Void,
onLater: (() -> Void)? = nil
) {
let alert = UIAlertController(
title: "位置上报提醒",
message: "倒计时即将结束,请立即上报位置",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "稍后", style: .cancel) { _ in onLater?() })
alert.addAction(UIAlertAction(title: "立即上报", style: .default) { _ in onReport() })
viewController.present(alert, animated: true)
}
///
@MainActor
static func presentReminderPicker(
from viewController: UIViewController,
onSelect: @escaping (Int) -> Void
) {
let alert = UIAlertController(title: "提醒设置", message: "提前提醒时间", preferredStyle: .actionSheet)
[0, 5, 10, 15, 30].forEach { minutes in
let title = minutes == 0 ? "不提醒" : "提前\(minutes)分钟"
alert.addAction(UIAlertAction(title: title, style: .default) { _ in onSelect(minutes) })
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
viewController.present(alert, animated: true)
}
}

View File

@ -0,0 +1,184 @@
//
// LocationReportHistoryViewController.swift
// suixinkan
//
import SnapKit
import UIKit
/// Android `LocationReportHistoryScreen`
final class LocationReportHistoryViewController: BaseViewController, UITableViewDelegate, UITableViewDataSource {
private let viewModel: LocationReportHistoryViewModel
private let homeAPI: HomeAPI
private let filterBar = LocationReportHistoryFilterBar()
private let tableView = UITableView(frame: .zero, style: .plain)
private let emptyLabel = UILabel()
private let footerLabel = UILabel()
private let refreshControl = UIRefreshControl()
init(
viewModel: LocationReportHistoryViewModel = LocationReportHistoryViewModel(),
homeAPI: HomeAPI = NetworkServices.shared.homeAPI
) {
self.viewModel = viewModel
self.homeAPI = homeAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "位置上报历史"
}
override func setupUI() {
tableView.delegate = self
tableView.dataSource = self
tableView.separatorStyle = .none
tableView.backgroundColor = AppColor.pageBackground
tableView.register(LocationReportHistoryCell.self, forCellReuseIdentifier: LocationReportHistoryCell.reuseIdentifier)
tableView.refreshControl = refreshControl
emptyLabel.text = "暂无位置上报记录"
emptyLabel.textColor = AppColor.textTertiary
emptyLabel.textAlignment = .center
emptyLabel.isHidden = true
footerLabel.text = "没有更多"
footerLabel.font = .systemFont(ofSize: 13)
footerLabel.textColor = AppColor.textTertiary
footerLabel.textAlignment = .center
footerLabel.isHidden = true
view.addSubview(filterBar)
view.addSubview(tableView)
view.addSubview(emptyLabel)
view.addSubview(footerLabel)
filterBar.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
}
tableView.snp.makeConstraints { make in
make.top.equalTo(filterBar.snp.bottom)
make.leading.trailing.bottom.equalTo(view.safeAreaLayoutGuide)
}
emptyLabel.snp.makeConstraints { make in
make.center.equalTo(tableView)
}
footerLabel.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
refreshControl.addTarget(self, action: #selector(refreshTapped), for: .valueChanged)
filterBar.onFilterTap = { [weak self] in self?.presentFilterPicker() }
filterBar.onDateFilterTap = { [weak self] in self?.presentDateFilter() }
filterBar.onClearDates = { [weak self] in
guard let self else { return }
Task { await self.viewModel.clearDateRange(api: self.homeAPI) }
}
Task { await viewModel.loadReportList(api: homeAPI, refresh: true) }
}
private func applyViewModel() {
filterBar.apply(
filterTitle: viewModel.selectedFilter.displayName,
startText: viewModel.startDateText,
endText: viewModel.endDateText,
showsDateRange: viewModel.hasDateFilter
)
tableView.reloadData()
emptyLabel.isHidden = !viewModel.reportList.isEmpty
footerLabel.isHidden = viewModel.reportList.isEmpty || viewModel.canLoadMore
if !viewModel.isRefreshing {
refreshControl.endRefreshing()
}
}
@objc private func refreshTapped() {
Task { await viewModel.loadReportList(api: homeAPI, refresh: true) }
}
private func presentFilterPicker() {
let alert = UIAlertController(title: "筛选类型", message: nil, preferredStyle: .actionSheet)
LocationReportFilterType.allCases.forEach { filter in
alert.addAction(UIAlertAction(title: filter.displayName, style: .default) { [weak self] _ in
guard let self else { return }
Task { await self.viewModel.selectFilter(filter, api: self.homeAPI) }
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
private func presentDateFilter() {
let alert = UIAlertController(title: "时间筛选", message: nil, preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "选择开始日期", style: .default) { [weak self] _ in
self?.presentDatePicker(isStart: true)
})
alert.addAction(UIAlertAction(title: "选择结束日期", style: .default) { [weak self] _ in
self?.presentDatePicker(isStart: false)
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
private func presentDatePicker(isStart: Bool) {
let alert = UIAlertController(title: isStart ? "开始日期" : "结束日期", message: "\n\n\n\n\n\n\n\n", preferredStyle: .actionSheet)
let picker = UIDatePicker()
picker.datePickerMode = .date
if #available(iOS 13.4, *) {
picker.preferredDatePickerStyle = .wheels
}
picker.date = isStart ? (viewModel.startDate ?? Date()) : (viewModel.endDate ?? Date())
alert.view.addSubview(picker)
picker.snp.makeConstraints { make in
make.centerX.equalToSuperview()
make.top.equalToSuperview().offset(24)
}
alert.addAction(UIAlertAction(title: "确定", style: .default) { [weak self] _ in
guard let self else { return }
Task {
if isStart {
await self.viewModel.setDateRange(start: picker.date, end: self.viewModel.endDate, api: self.homeAPI)
} else {
await self.viewModel.setDateRange(start: self.viewModel.startDate, end: picker.date, api: self.homeAPI)
}
}
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
present(alert, animated: true)
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
viewModel.reportList.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(
withIdentifier: LocationReportHistoryCell.reuseIdentifier,
for: indexPath
) as! LocationReportHistoryCell
cell.apply(item: viewModel.reportList[indexPath.row])
return cell
}
func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
Task { await viewModel.loadMoreIfNeeded(currentIndex: indexPath.row, api: homeAPI) }
}
}

View File

@ -0,0 +1,177 @@
//
// LocationReportViewController.swift
// suixinkan
//
import CoreLocation
import SnapKit
import UIKit
/// Android `LocationReportScreen`
final class LocationReportViewController: BaseViewController {
private let viewModel: LocationReportViewModel
private let homeAPI: HomeAPI
private let mapContainer = LocationReportMapView()
private let mapControls = LocationReportMapControlStack()
private let statusCard = LocationReportStatusCardView()
private let actionCard = LocationReportActionCardView()
private let markButton = LocationReportBottomActionView(title: "标记上报", symbol: "mappin.and.ellipse")
private let historyButton = LocationReportBottomActionView(title: "历史记录", symbol: "clock")
private let onlineButton = LocationReportBottomActionView(title: "离线", symbol: "power")
private var hasCenteredMap = false
init(
viewModel: LocationReportViewModel = LocationReportViewModel(),
homeAPI: HomeAPI = NetworkServices.shared.homeAPI
) {
self.viewModel = viewModel
self.homeAPI = homeAPI
super.init(nibName: nil, bundle: nil)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setupNavigationBar() {
title = "位置上报"
}
override func setupUI() {
view.backgroundColor = AppColor.pageBackground
let bottomStack = UIStackView(arrangedSubviews: [markButton, historyButton, onlineButton])
bottomStack.axis = .horizontal
bottomStack.spacing = 12
bottomStack.distribution = .fillEqually
let panelStack = UIStackView(arrangedSubviews: [statusCard, actionCard, bottomStack])
panelStack.axis = .vertical
panelStack.spacing = 12
view.addSubview(mapContainer)
view.addSubview(mapControls)
view.addSubview(panelStack)
mapContainer.snp.makeConstraints { make in
make.top.leading.trailing.equalTo(view.safeAreaLayoutGuide)
make.height.equalTo(view.snp.height).multipliedBy(0.45)
}
mapControls.snp.makeConstraints { make in
make.trailing.equalTo(mapContainer).offset(-16)
make.centerY.equalTo(mapContainer)
}
panelStack.snp.makeConstraints { make in
make.top.equalTo(mapContainer.snp.bottom).offset(-20)
make.leading.trailing.equalToSuperview().inset(15)
make.bottom.lessThanOrEqualTo(view.safeAreaLayoutGuide).offset(-12)
}
}
override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) }
}
mapControls.onZoomIn = { [weak self] in self?.mapContainer.zoomIn() }
mapControls.onZoomOut = { [weak self] in self?.mapContainer.zoomOut() }
mapControls.onRelocate = { [weak self] in
self?.viewModel.startLocation()
self?.mapContainer.centerOnUserLocation()
}
mapContainer.onMapTap = { [weak self] coordinate in
self?.viewModel.setMarkerLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
}
statusCard.onOnlineStatusTap = { [weak self] in self?.viewModel.showOnlineStatusSwitchDialog() }
statusCard.onReminderTap = { [weak self] in
guard let self else { return }
LocationReportDialogPresenter.presentReminderPicker(from: self) { minutes in
self.viewModel.updateReminderMinutes(minutes)
}
}
actionCard.onReportTap = { [weak self] in
guard let self else { return }
Task { await self.viewModel.reportLocation(api: self.homeAPI) }
}
markButton.addTarget(self, action: #selector(markTapped), for: .touchUpInside)
historyButton.addTarget(self, action: #selector(historyTapped), for: .touchUpInside)
onlineButton.addTarget(self, action: #selector(onlineTapped), for: .touchUpInside)
viewModel.restoreStateIfNeeded()
requestLocationIfNeeded()
applyViewModel()
}
private func requestLocationIfNeeded() {
let status = CLLocationManager.authorizationStatus()
switch status {
case .authorizedAlways, .authorizedWhenInUse:
viewModel.startLocation()
case .notDetermined:
CLLocationManager().requestWhenInUseAuthorization()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.viewModel.startLocation()
}
default:
showToast("需要定位权限才能使用此功能")
}
}
private func applyViewModel() {
statusCard.apply(
isOnline: viewModel.isOnline,
countdown: viewModel.countdownDisplayText,
reminderMinutes: viewModel.reminderMinutes
)
actionCard.setReporting(viewModel.isReporting)
markButton.isSelected = viewModel.isMarking
onlineButton.applyOnlineStyle(isOnline: viewModel.isOnline)
mapContainer.updateMarker(latitude: viewModel.markerLatitude, longitude: viewModel.markerLongitude)
if !hasCenteredMap, let lat = viewModel.currentLatitude, let lng = viewModel.currentLongitude {
mapContainer.setCenter(CLLocationCoordinate2D(latitude: lat, longitude: lng), animated: false)
hasCenteredMap = true
}
if viewModel.showOnlineStatusDialog {
viewModel.hideOnlineStatusSwitchDialog()
LocationReportDialogPresenter.presentOnlineStatusSwitch(
from: self,
isOnline: viewModel.isOnline
) { [weak self] in
guard let self else { return }
Task { await self.viewModel.switchOnlineStatus(api: self.homeAPI) }
}
}
if viewModel.showLocationReportSuccessDialog {
let reportTime = viewModel.reportTimeText
let countdown = viewModel.countdownDisplayText
viewModel.hideLocationReportSuccessDialog()
LocationReportDialogPresenter.presentReportSuccess(
from: self,
reportTime: reportTime,
countdown: countdown
)
}
}
@objc private func markTapped() {
viewModel.toggleMarking()
}
@objc private func historyTapped() {
navigationController?.pushViewController(LocationReportHistoryViewController(), animated: true)
}
@objc private func onlineTapped() {
viewModel.showOnlineStatusSwitchDialog()
}
}

View File

@ -0,0 +1,177 @@
//
// LocationReportHistoryViews.swift
// suixinkan
//
import SnapKit
import UIKit
///
final class LocationReportHistoryCell: UITableViewCell {
static let reuseIdentifier = "LocationReportHistoryCell"
private let typeBadge = UILabel()
private let timeLabel = UILabel()
private let coordinateLabel = UILabel()
private let addressLabel = UILabel()
private let remarkLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
selectionStyle = .none
backgroundColor = .clear
let card = UIView()
card.backgroundColor = .white
card.layer.cornerRadius = 10
typeBadge.font = .systemFont(ofSize: 12, weight: .medium)
typeBadge.textAlignment = .center
typeBadge.layer.cornerRadius = 4
typeBadge.clipsToBounds = true
timeLabel.font = .systemFont(ofSize: 14, weight: .medium)
timeLabel.textColor = AppColor.textPrimary
coordinateLabel.font = .systemFont(ofSize: 12)
coordinateLabel.textColor = AppColor.textSecondary
addressLabel.font = .systemFont(ofSize: 13)
addressLabel.textColor = AppColor.textPrimary
addressLabel.numberOfLines = 2
remarkLabel.font = .systemFont(ofSize: 12)
remarkLabel.textColor = AppColor.textTertiary
remarkLabel.numberOfLines = 2
let stack = UIStackView(arrangedSubviews: [timeLabel, coordinateLabel, addressLabel, remarkLabel])
stack.axis = .vertical
stack.spacing = 6
card.addSubview(typeBadge)
card.addSubview(stack)
contentView.addSubview(card)
card.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(UIEdgeInsets(top: 6, left: 16, bottom: 6, right: 16))
}
typeBadge.snp.makeConstraints { make in
make.top.leading.equalToSuperview().offset(12)
make.height.equalTo(22)
make.width.greaterThanOrEqualTo(64)
}
stack.snp.makeConstraints { make in
make.top.equalTo(typeBadge.snp.bottom).offset(8)
make.leading.trailing.bottom.equalToSuperview().inset(12)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(item: LocationReportHistoryItem) {
let filterType = item.filterType
typeBadge.text = " \(filterType.displayName) "
typeBadge.textColor = UIColor(hex: filterType.tagColorHex)
typeBadge.backgroundColor = UIColor(hex: filterType.tagColorHex).withAlphaComponent(0.12)
timeLabel.text = item.createdAt.isEmpty ? "--" : item.createdAt
coordinateLabel.text = "经纬度:\(item.latitude), \(item.longitude)"
addressLabel.text = item.address.isEmpty ? "--" : item.address
if item.remark.isEmpty {
remarkLabel.isHidden = true
} else {
remarkLabel.isHidden = false
remarkLabel.text = "备注:\(item.remark)"
}
}
}
///
final class LocationReportHistoryFilterBar: UIView {
var onFilterTap: (() -> Void)?
var onDateFilterTap: (() -> Void)?
var onClearDates: (() -> Void)?
private let filterButton = UIButton(type: .system)
private let dateButton = UIButton(type: .system)
private let startLabel = UILabel()
private let endLabel = UILabel()
private let clearButton = UIButton(type: .system)
private let dateRangeStack = UIStackView()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
filterButton.setTitle("全部", for: .normal)
filterButton.setTitleColor(AppColor.textPrimary, for: .normal)
filterButton.titleLabel?.font = .systemFont(ofSize: 14)
filterButton.setImage(UIImage(systemName: "chevron.down"), for: .normal)
filterButton.semanticContentAttribute = .forceRightToLeft
filterButton.addTarget(self, action: #selector(filterTapped), for: .touchUpInside)
dateButton.setTitle("时间筛选", for: .normal)
dateButton.setTitleColor(AppColor.primary, for: .normal)
dateButton.titleLabel?.font = .systemFont(ofSize: 14)
dateButton.addTarget(self, action: #selector(dateTapped), for: .touchUpInside)
startLabel.font = .systemFont(ofSize: 13)
endLabel.font = .systemFont(ofSize: 13)
startLabel.textColor = AppColor.textSecondary
endLabel.textColor = AppColor.textSecondary
clearButton.setTitle("清除", for: .normal)
clearButton.setTitleColor(AppColor.danger, for: .normal)
clearButton.titleLabel?.font = .systemFont(ofSize: 13)
clearButton.addTarget(self, action: #selector(clearTapped), for: .touchUpInside)
dateRangeStack.axis = .horizontal
dateRangeStack.spacing = 8
dateRangeStack.alignment = .center
dateRangeStack.addArrangedSubview(startLabel)
dateRangeStack.addArrangedSubview(UILabel(text: ""))
dateRangeStack.addArrangedSubview(endLabel)
dateRangeStack.addArrangedSubview(clearButton)
dateRangeStack.isHidden = true
let topRow = UIStackView(arrangedSubviews: [filterButton, UIView(), dateButton])
topRow.axis = .horizontal
let stack = UIStackView(arrangedSubviews: [topRow, dateRangeStack])
stack.axis = .vertical
stack.spacing = 8
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(12)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(filterTitle: String, startText: String, endText: String, showsDateRange: Bool) {
filterButton.setTitle(filterTitle, for: .normal)
startLabel.text = startText
endLabel.text = endText
dateRangeStack.isHidden = !showsDateRange
}
@objc private func filterTapped() { onFilterTap?() }
@objc private func dateTapped() { onDateFilterTap?() }
@objc private func clearTapped() { onClearDates?() }
}
private extension UILabel {
convenience init(text: String) {
self.init()
self.text = text
font = .systemFont(ofSize: 13)
textColor = AppColor.textSecondary
}
}

View File

@ -0,0 +1,357 @@
//
// LocationReportViews.swift
// suixinkan
//
import MapKit
import SnapKit
import UIKit
/// /
final class LocationReportMapControlStack: UIView {
var onZoomIn: (() -> Void)?
var onZoomOut: (() -> Void)?
var onRelocate: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
let zoomIn = makeButton(symbol: "plus", action: #selector(zoomInTapped))
let zoomOut = makeButton(symbol: "minus", action: #selector(zoomOutTapped))
let relocate = makeButton(symbol: "location.fill", action: #selector(relocateTapped))
let zoomStack = UIStackView(arrangedSubviews: [zoomIn, zoomOut])
zoomStack.axis = .vertical
zoomStack.spacing = 0
zoomStack.backgroundColor = .white
zoomStack.layer.cornerRadius = 12
zoomStack.clipsToBounds = true
relocate.backgroundColor = .white
relocate.layer.cornerRadius = 12
let stack = UIStackView(arrangedSubviews: [zoomStack, relocate])
stack.axis = .vertical
stack.spacing = 12
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
[zoomIn, zoomOut, relocate].forEach { button in
button.snp.makeConstraints { make in
make.size.equalTo(44)
}
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func makeButton(symbol: String, action: Selector) -> UIButton {
let button = UIButton(type: .system)
button.setImage(UIImage(systemName: symbol), for: .normal)
button.tintColor = AppColor.primary
button.addTarget(self, action: action, for: .touchUpInside)
return button
}
@objc private func zoomInTapped() { onZoomIn?() }
@objc private func zoomOutTapped() { onZoomOut?() }
@objc private func relocateTapped() { onRelocate?() }
}
///
final class LocationReportStatusCardView: UIView {
var onOnlineStatusTap: (() -> Void)?
var onReminderTap: (() -> Void)?
private let onlineBadge = UILabel()
private let countdownLabel = UILabel()
private let reminderLabel = UILabel()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 10
let statusTitle = makeTitle("当前状态")
onlineBadge.font = .systemFont(ofSize: 12, weight: .medium)
onlineBadge.textAlignment = .center
onlineBadge.layer.cornerRadius = 4
onlineBadge.clipsToBounds = true
onlineBadge.isUserInteractionEnabled = true
onlineBadge.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(onlineTapped)))
let countdownTitle = makeTitle("距离下次上报时间")
countdownLabel.font = .systemFont(ofSize: 16, weight: .medium)
countdownLabel.textColor = AppColor.primary
let reminderTitle = makeTitle("提醒设置")
reminderLabel.font = .systemFont(ofSize: 16, weight: .medium)
reminderLabel.textColor = AppColor.primary
reminderLabel.isUserInteractionEnabled = true
reminderLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reminderTapped)))
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 12
stack.addArrangedSubview(makeRow(left: statusTitle, right: onlineBadge))
stack.addArrangedSubview(makeRow(left: countdownTitle, right: countdownLabel))
stack.addArrangedSubview(makeRow(left: reminderTitle, right: reminderLabel))
addSubview(stack)
stack.snp.makeConstraints { make in
make.edges.equalToSuperview().inset(15)
}
onlineBadge.snp.makeConstraints { make in
make.width.greaterThanOrEqualTo(52)
make.height.equalTo(28)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func apply(isOnline: Bool, countdown: String, reminderMinutes: Int) {
onlineBadge.text = isOnline ? "在线" : "离线"
onlineBadge.textColor = isOnline ? AppColor.online : UIColor(hex: 0x7B8EAA)
onlineBadge.backgroundColor = isOnline ? UIColor(hex: 0xF0FDF4) : AppColor.inputBackground
countdownLabel.text = countdown
reminderLabel.text = reminderMinutes == 0 ? "不提醒" : "提前\(reminderMinutes)分钟"
}
@objc private func onlineTapped() { onOnlineStatusTap?() }
@objc private func reminderTapped() { onReminderTap?() }
private func makeTitle(_ text: String) -> UILabel {
let label = UILabel()
label.text = text
label.font = .systemFont(ofSize: 14)
label.textColor = AppColor.textPrimary
return label
}
private func makeRow(left: UIView, right: UIView) -> UIView {
let row = UIStackView(arrangedSubviews: [left, right])
row.axis = .horizontal
row.distribution = .equalSpacing
row.alignment = .center
return row
}
}
///
final class LocationReportActionCardView: UIView {
var onReportTap: (() -> Void)?
private let reportButton = UIButton(type: .system)
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
layer.cornerRadius = 10
let icon = UIImageView(image: UIImage(systemName: "arrow.up"))
icon.tintColor = AppColor.primary
let title = UILabel()
title.text = "立即上报"
title.font = .systemFont(ofSize: 16, weight: .bold)
title.textColor = AppColor.textPrimary
let subtitle = UILabel()
subtitle.text = "您已进入打卡范围"
subtitle.font = .systemFont(ofSize: 12)
subtitle.textColor = AppColor.textTertiary
let textStack = UIStackView(arrangedSubviews: [title, subtitle])
textStack.axis = .vertical
textStack.spacing = 4
reportButton.backgroundColor = AppColor.primary
reportButton.tintColor = .white
reportButton.setImage(UIImage(systemName: "location.fill"), for: .normal)
reportButton.layer.cornerRadius = 36
reportButton.addTarget(self, action: #selector(reportTapped), for: .touchUpInside)
addSubview(icon)
addSubview(textStack)
addSubview(reportButton)
icon.snp.makeConstraints { make in
make.leading.equalToSuperview().offset(15)
make.centerY.equalToSuperview()
make.size.equalTo(24)
}
textStack.snp.makeConstraints { make in
make.leading.equalTo(icon.snp.trailing).offset(8)
make.centerY.equalToSuperview()
}
reportButton.snp.makeConstraints { make in
make.trailing.equalToSuperview().offset(-15)
make.centerY.equalToSuperview()
make.size.equalTo(72)
}
snp.makeConstraints { make in
make.height.equalTo(96)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setReporting(_ reporting: Bool) {
reportButton.isEnabled = !reporting
reportButton.alpha = reporting ? 0.6 : 1
}
@objc private func reportTapped() { onReportTap?() }
}
///
final class LocationReportBottomActionView: UIControl {
private let iconView = UIImageView()
private let titleLabel = UILabel()
init(title: String, symbol: String) {
super.init(frame: .zero)
backgroundColor = .white
layer.cornerRadius = 10
iconView.image = UIImage(systemName: symbol)
iconView.tintColor = AppColor.primary
iconView.contentMode = .scaleAspectFit
titleLabel.text = title
titleLabel.font = .systemFont(ofSize: 12)
titleLabel.textColor = AppColor.textPrimary
titleLabel.textAlignment = .center
let stack = UIStackView(arrangedSubviews: [iconView, titleLabel])
stack.axis = .vertical
stack.spacing = 4
stack.alignment = .center
stack.isUserInteractionEnabled = false
addSubview(stack)
stack.snp.makeConstraints { make in
make.center.equalToSuperview()
}
iconView.snp.makeConstraints { make in
make.size.equalTo(24)
}
snp.makeConstraints { make in
make.height.equalTo(80)
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var isSelected: Bool {
didSet { applySelectedStyle() }
}
func applyOnlineStyle(isOnline: Bool) {
if isOnline {
backgroundColor = AppColor.online
iconView.tintColor = .white
titleLabel.text = "在线"
titleLabel.textColor = .white
} else {
backgroundColor = .white
iconView.tintColor = UIColor(hex: 0x7B8EAA)
titleLabel.text = "离线"
titleLabel.textColor = UIColor(hex: 0x7B8EAA)
}
}
private func applySelectedStyle() {
guard titleLabel.text == "标记上报" else { return }
if isSelected {
backgroundColor = AppColor.online
iconView.tintColor = UIColor(hex: 0xF0FDF4)
titleLabel.textColor = UIColor(hex: 0xF0FDF4)
} else {
backgroundColor = .white
iconView.tintColor = AppColor.primary
titleLabel.textColor = AppColor.textPrimary
}
}
}
/// MKMapView
final class LocationReportMapView: UIView, MKMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
let mapView = MKMapView()
private var markerAnnotation: MKPointAnnotation?
override init(frame: CGRect) {
super.init(frame: frame)
mapView.delegate = self
mapView.showsUserLocation = true
mapView.showsCompass = false
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
mapView.addGestureRecognizer(tap)
addSubview(mapView)
mapView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func centerOnUserLocation() {
guard let coordinate = mapView.userLocation.location?.coordinate else { return }
setCenter(coordinate)
}
func setCenter(_ coordinate: CLLocationCoordinate2D, animated: Bool = true) {
let region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 800, longitudinalMeters: 800)
mapView.setRegion(region, animated: animated)
}
func zoomIn() {
var region = mapView.region
region.span.latitudeDelta *= 0.5
region.span.longitudeDelta *= 0.5
mapView.setRegion(region, animated: true)
}
func zoomOut() {
var region = mapView.region
region.span.latitudeDelta = min(region.span.latitudeDelta * 2, 180)
region.span.longitudeDelta = min(region.span.longitudeDelta * 2, 180)
mapView.setRegion(region, animated: true)
}
func updateMarker(latitude: Double?, longitude: Double?) {
if let annotation = markerAnnotation {
mapView.removeAnnotation(annotation)
markerAnnotation = nil
}
guard let latitude, let longitude else { return }
let annotation = MKPointAnnotation()
annotation.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
markerAnnotation = annotation
mapView.addAnnotation(annotation)
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: mapView)
let coordinate = mapView.convert(point, toCoordinateFrom: mapView)
onMapTap?(coordinate)
}
}

View File

@ -0,0 +1,77 @@
//
// LocationReportHistoryViewModelTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
@MainActor
/// ViewModel
final class LocationReportHistoryViewModelTests: XCTestCase {
private var appStore: AppStore!
private var defaults: UserDefaults!
override func setUp() {
defaults = UserDefaults(suiteName: "LocationReportHistoryViewModelTests")!
defaults.removePersistentDomain(forName: "LocationReportHistoryViewModelTests")
appStore = AppStore(defaults: defaults)
appStore.userId = "staff-99"
}
override func tearDown() {
appStore.logout()
super.tearDown()
}
func testLoadReportListPopulatesItems() async throws {
let listJSON = """
{"code":100000,"msg":"success","data":{"list":[{"id":1,"staff_id":99,"type":1,"latitude":"30.1","longitude":"120.2","address":"","remark":"","created_at":"2026-01-01 10:00:00"}],"total":1}}
""".data(using: .utf8)!
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [listJSON])))
let viewModel = LocationReportHistoryViewModel(appStore: appStore)
await viewModel.loadReportList(api: api, refresh: true)
XCTAssertEqual(viewModel.reportList.count, 1)
XCTAssertEqual(viewModel.reportList.first?.address, "测试地址")
XCTAssertFalse(viewModel.canLoadMore)
}
func testSelectFilterReloadsWithTypeCode() async throws {
let allJSON = """
{"code":100000,"msg":"success","data":{"list":[{"id":1,"staff_id":99,"type":1,"latitude":"30","longitude":"120","address":"A","remark":"","created_at":"2026-01-01 10:00:00"}],"total":1}}
""".data(using: .utf8)!
let markedJSON = """
{"code":100000,"msg":"success","data":{"list":[{"id":2,"staff_id":99,"type":2,"latitude":"31","longitude":"121","address":"B","remark":"","created_at":"2026-01-02 10:00:00"}],"total":1}}
""".data(using: .utf8)!
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [allJSON, markedJSON])))
let viewModel = LocationReportHistoryViewModel(appStore: appStore)
await viewModel.loadReportList(api: api, refresh: true)
await viewModel.selectFilter(.marked, api: api)
XCTAssertEqual(viewModel.selectedFilter, .marked)
XCTAssertEqual(viewModel.reportList.first?.type, 2)
XCTAssertEqual(viewModel.reportList.first?.address, "B")
}
func testPaginationAppendsItems() async throws {
let page1JSON = """
{"code":100000,"msg":"success","data":{"list":[{"id":1,"staff_id":99,"type":1,"latitude":"30","longitude":"120","address":"","remark":"","created_at":"2026-01-01 10:00:00"}],"total":2}}
""".data(using: .utf8)!
let page2JSON = """
{"code":100000,"msg":"success","data":{"list":[{"id":2,"staff_id":99,"type":1,"latitude":"31","longitude":"121","address":"","remark":"","created_at":"2026-01-02 10:00:00"}],"total":2}}
""".data(using: .utf8)!
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [page1JSON, page2JSON])))
let viewModel = LocationReportHistoryViewModel(appStore: appStore)
await viewModel.loadReportList(api: api, refresh: true)
await viewModel.loadMoreIfNeeded(currentIndex: 0, api: api)
XCTAssertEqual(viewModel.reportList.count, 2)
XCTAssertEqual(viewModel.reportList.last?.address, "第二页")
XCTAssertFalse(viewModel.canLoadMore)
}
}

View File

@ -0,0 +1,98 @@
//
// LocationReportServiceTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
@MainActor
///
final class LocationReportServiceTests: XCTestCase {
private var appStore: AppStore!
private var defaults: UserDefaults!
override func setUp() {
defaults = UserDefaults(suiteName: "LocationReportServiceTests")!
defaults.removePersistentDomain(forName: "LocationReportServiceTests")
appStore = AppStore(defaults: defaults)
appStore.userId = "staff-1"
appStore.currentScenicId = 100
}
override func tearDown() {
appStore.logout()
super.tearDown()
}
func testReportWithCoordinatesType2UsesProvidedLocation() async throws {
let reportJSON = """
{"code":100000,"msg":"success","data":{"staff_id":"staff-1","expired":0,"status":1}}
""".data(using: .utf8)!
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [reportJSON])))
let stateStore = HomeLocationStateStore(store: appStore)
let service = LocationReportService(appStore: appStore, locationStateStore: stateStore)
let result = try await service.reportWithCoordinates(
api: api,
latitude: 30.1,
longitude: 120.2,
address: "标记地址",
type: 2
)
XCTAssertTrue(result.shouldShowSuccessDialog)
XCTAssertEqual(result.locationInfoText, "标记地址")
XCTAssertTrue(stateStore.isOnline)
}
func testTooFrequentManualReportThrows() async throws {
let reportJSON = """
{"code":100000,"msg":"success","data":{"staff_id":"staff-1","expired":0,"status":1}}
""".data(using: .utf8)!
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [reportJSON, reportJSON])))
let stateStore = HomeLocationStateStore(store: appStore)
let service = LocationReportService(appStore: appStore, locationStateStore: stateStore)
_ = try await service.reportWithCoordinates(
api: api,
latitude: 30.0,
longitude: 120.0,
address: "测试地址",
type: 1
)
do {
_ = try await service.reportWithCoordinates(
api: api,
latitude: 30.0,
longitude: 120.0,
address: "测试地址",
type: 1
)
XCTFail("Expected too frequent error")
} catch let error as LocationReportServiceError {
if case .tooFrequent = error {
XCTAssertTrue(true)
} else {
XCTFail("Unexpected error \(error)")
}
}
}
func testMissingScenicThrows() async {
appStore.currentScenicId = 0
let api = HomeAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [])))
let stateStore = HomeLocationStateStore(store: appStore)
let service = LocationReportService(appStore: appStore, locationStateStore: stateStore)
do {
_ = try await service.manualReport(api: api)
XCTFail("Expected missing scenic")
} catch let error as LocationReportServiceError {
XCTAssertEqual(error, .missingScenic)
} catch {
XCTFail("Unexpected error \(error)")
}
}
}