From 0d4b63d273abb6f7befecadbedecf626e127d4b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Wed, 24 Jun 2026 11:08:41 +0800 Subject: [PATCH] Add PunchPoint and LocationReport modules with home routing. Migrate check-in point management and location reporting from placeholders to full MVVM flows, including foreground location support and unit tests. Co-authored-by: Cursor --- suixinkan.xcodeproj/project.pbxproj | 4 +- suixinkan/App/RootView.swift | 6 + .../Location/ForegroundLocationProvider.swift | 107 ++++ suixinkan/Features/Home/Home.md | 2 +- .../Features/Home/Models/HomeMenuItem.swift | 6 + .../Home/Routing/HomeMenuRouter.swift | 9 +- .../Home/Views/HomeSupportViews.swift | 12 + suixinkan/Features/Home/Views/HomeView.swift | 3 +- .../API/LocationReportAPI.swift | 82 +++ .../Features/LocationReport/LocationReport.md | 39 ++ .../Models/LocationReportModels.swift | 127 ++++ .../ViewModels/LocationReportViewModels.swift | 233 +++++++ .../Views/LocationReportViews.swift | 405 ++++++++++++ .../PunchPoint/API/PunchPointAPI.swift | 88 +++ .../PunchPoint/Models/PunchPointModels.swift | 252 ++++++++ suixinkan/Features/PunchPoint/PunchPoint.md | 37 ++ .../ViewModels/PunchPointViewModels.swift | 280 +++++++++ .../PunchPoint/Views/PunchPointViews.swift | 592 ++++++++++++++++++ suixinkanTests/HomeMenuRouterTests.swift | 7 + .../LocationReport/LocationReportTests.swift | 179 ++++++ .../PunchPoint/PunchPointAPITests.swift | 232 +++++++ 功能同步Checklist.md | 8 +- 22 files changed, 2698 insertions(+), 12 deletions(-) create mode 100644 suixinkan/Core/Location/ForegroundLocationProvider.swift create mode 100644 suixinkan/Features/LocationReport/API/LocationReportAPI.swift create mode 100644 suixinkan/Features/LocationReport/LocationReport.md create mode 100644 suixinkan/Features/LocationReport/Models/LocationReportModels.swift create mode 100644 suixinkan/Features/LocationReport/ViewModels/LocationReportViewModels.swift create mode 100644 suixinkan/Features/LocationReport/Views/LocationReportViews.swift create mode 100644 suixinkan/Features/PunchPoint/API/PunchPointAPI.swift create mode 100644 suixinkan/Features/PunchPoint/Models/PunchPointModels.swift create mode 100644 suixinkan/Features/PunchPoint/PunchPoint.md create mode 100644 suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift create mode 100644 suixinkan/Features/PunchPoint/Views/PunchPointViews.swift create mode 100644 suixinkanTests/LocationReport/LocationReportTests.swift create mode 100644 suixinkanTests/PunchPoint/PunchPointAPITests.swift diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index c3ea9a1..0b44a30 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -453,7 +453,7 @@ "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单"; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "用于在景区选择页面展示附近景区和距离"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "用于景区选择、打卡点选点和位置上报时获取当前位置"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -497,7 +497,7 @@ "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSCameraUsageDescription = "用于扫码核销订单"; - INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "用于在景区选择页面展示附近景区和距离"; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "用于景区选择、打卡点选点和位置上报时获取当前位置"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "用于保存收款二维码到相册"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/suixinkan/App/RootView.swift b/suixinkan/App/RootView.swift index 7399e74..7894018 100644 --- a/suixinkan/App/RootView.swift +++ b/suixinkan/App/RootView.swift @@ -30,6 +30,8 @@ struct RootView: View { @State private var scenicPermissionAPI: ScenicPermissionAPI @State private var taskAPI: TaskAPI @State private var assetsAPI: AssetsAPI + @State private var punchPointAPI: PunchPointAPI + @State private var locationReportAPI: LocationReportAPI @State private var authSessionCoordinator: AuthSessionCoordinator @State private var sessionBootstrapper: SessionBootstrapper @@ -53,6 +55,8 @@ struct RootView: View { _scenicPermissionAPI = State(initialValue: ScenicPermissionAPI(client: apiClient)) _taskAPI = State(initialValue: TaskAPI(client: apiClient)) _assetsAPI = State(initialValue: AssetsAPI(client: apiClient)) + _punchPointAPI = State(initialValue: PunchPointAPI(client: apiClient)) + _locationReportAPI = State(initialValue: LocationReportAPI(client: apiClient)) _authSessionCoordinator = State( initialValue: AuthSessionCoordinator( tokenStore: tokenStore, @@ -93,6 +97,8 @@ struct RootView: View { .environment(scenicPermissionAPI) .environment(taskAPI) .environment(assetsAPI) + .environment(punchPointAPI) + .environment(locationReportAPI) .environment(authSessionCoordinator) .task { apiClient.bindAuthTokenProvider { appSession.token } diff --git a/suixinkan/Core/Location/ForegroundLocationProvider.swift b/suixinkan/Core/Location/ForegroundLocationProvider.swift new file mode 100644 index 0000000..ac8d559 --- /dev/null +++ b/suixinkan/Core/Location/ForegroundLocationProvider.swift @@ -0,0 +1,107 @@ +// +// ForegroundLocationProvider.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import CoreLocation +import Foundation +import Observation + +/// 前台定位结果实体,表示一次即时定位得到的坐标和可展示地址。 +struct ForegroundLocationResult: Equatable { + let latitude: Double + let longitude: Double + let address: String +} + +/// 前台定位 Provider,只负责当前页面主动请求位置,不缓存、不后台持续定位。 +@MainActor +@Observable +final class ForegroundLocationProvider: NSObject { + @ObservationIgnored private let manager = CLLocationManager() + @ObservationIgnored private let geocoder = CLGeocoder() + @ObservationIgnored private var continuation: CheckedContinuation? + + override init() { + super.init() + manager.delegate = self + manager.desiredAccuracy = kCLLocationAccuracyBest + } + + /// 请求一次当前位置,成功后尝试反解析为地址。 + func requestCurrentLocation() async throws -> ForegroundLocationResult { + if let continuation { + continuation.resume(throwing: LocationProviderError.duplicatedRequest) + self.continuation = nil + } + let status = manager.authorizationStatus + if status == .notDetermined { + manager.requestWhenInUseAuthorization() + } + + return try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + manager.requestLocation() + } + } + + /// 将 CLLocation 转为页面可展示的定位结果。 + private func makeResult(from location: CLLocation) async -> ForegroundLocationResult { + let address: String + if let placemark = try? await geocoder.reverseGeocodeLocation(location).first { + address = [ + placemark.administrativeArea, + placemark.locality, + placemark.subLocality, + placemark.thoroughfare, + placemark.name + ] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: "") + } else { + address = "当前位置" + } + return ForegroundLocationResult( + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude, + address: address.isEmpty ? "当前位置" : address + ) + } +} + +extension ForegroundLocationProvider: CLLocationManagerDelegate { + /// 定位成功回调,返回最近一次坐标。 + nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + Task { @MainActor in + guard let continuation = self.continuation else { return } + self.continuation = nil + let result = await self.makeResult(from: location) + continuation.resume(returning: result) + } + } + + /// 定位失败回调,将错误传给当前请求方。 + nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + Task { @MainActor in + guard let continuation = self.continuation else { return } + self.continuation = nil + continuation.resume(throwing: error) + } + } +} + +/// 前台定位 Provider 错误实体。 +enum LocationProviderError: LocalizedError { + case duplicatedRequest + + var errorDescription: String? { + switch self { + case .duplicatedRequest: + "已有定位请求正在执行" + } + } +} diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index 0610f99..c774e99 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -33,6 +33,6 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作 ## 后续迁移 -目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement` 和 `scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。 +目前 `payment_collection`、`payment_qr`、`payment_code` 已由 `Features/Payment` 的真实收款页面接管,`wallet` 已由 `Features/Wallet` 的真实钱包页面接管,`scenicselection`、`permission_apply`、`permission_apply_status`、`scenicapplication` 已由 `Features/ScenicPermission` 接管,`task_management`、`task_management_editor`、`task_create` 已由 `Features/Tasks` 接管,`cloud_management`、`cloud_storage_transit`、`asset_management`、`material_upload`、`album_list`、`album_trailer`、`sample_management`、`sample_upload` 已由 `Features/Assets` 接管,`checkin_points` 已由 `Features/PunchPoint` 接管,`location_report` 和 `location_report_history` 已由 `Features/LocationReport` 接管。`withdrawal_audit` 仍属于后台性质的提现审核管理模块,`scenic_settlement` 和 `scenic_settlement_review` 仍属于独立结算模块,本轮保留占位,后续应作为独立功能迁移。 后续迁移具体首页子模块时,应先在 `HomeRoute` 增加目标页面,再更新 `HomeMenuRouter.resolve` 对应 URI 的映射,并同步补充单元测试。 diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index bcbb001..025365a 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -40,6 +40,12 @@ enum HomeRoute: Hashable { case sampleUpload case albumList case albumTrailer + case punchPointList + case punchPointDetail(id: Int, summary: PunchPointItem?) + case punchPointEditor(id: Int?) + case punchPointQR(id: Int, title: String, qrURL: String) + case locationReport + case locationReportHistory case modulePlaceholder(uri: String, title: String) } diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index ba11a9c..39e0d27 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -111,6 +111,12 @@ enum HomeMenuRouter { return .destination(.albumList) case "album_trailer": return .destination(.albumTrailer) + case "checkin_points": + return .destination(.punchPointList) + case "location_report": + return .destination(.locationReport) + case "location_report_history": + return .destination(.locationReportHistory) case "store", "more_functions": return .destination(.moreFunctions) case "fly", "pilot_controller": @@ -127,7 +133,6 @@ enum HomeMenuRouter { "deposit_order_shooting_info", "withdrawal_audit", "schedule_management", - "checkin_points", "pm", "pm_manager", "project_edit", @@ -136,8 +141,6 @@ enum HomeMenuRouter { "scenic_settlement", "scenic_settlement_review", "operating-area", - "location_report_history", - "location_report", "registration_invitation", "photographer_invite", "invite_record", diff --git a/suixinkan/Features/Home/Views/HomeSupportViews.swift b/suixinkan/Features/Home/Views/HomeSupportViews.swift index 903d83e..e2b1896 100644 --- a/suixinkan/Features/Home/Views/HomeSupportViews.swift +++ b/suixinkan/Features/Home/Views/HomeSupportViews.swift @@ -52,6 +52,18 @@ extension HomeRoute { AlbumListView() case .albumTrailer: AlbumTrailerEntryView() + case .punchPointList: + PunchPointListView() + case .punchPointDetail(let id, let summary): + PunchPointDetailView(punchPointId: id, summary: summary) + case .punchPointEditor(let id): + PunchPointEditorView(punchPointId: id) + case .punchPointQR(_, let title, let qrURL): + PunchPointQRView(title: title, qrURL: qrURL) + case .locationReport: + LocationReportView() + case .locationReportHistory: + LocationReportHistoryView() case let .modulePlaceholder(uri, title): HomeMigrationModuleView(title: title, uri: uri) } diff --git a/suixinkan/Features/Home/Views/HomeView.swift b/suixinkan/Features/Home/Views/HomeView.swift index ba2a5d9..a17d25b 100644 --- a/suixinkan/Features/Home/Views/HomeView.swift +++ b/suixinkan/Features/Home/Views/HomeView.swift @@ -241,8 +241,7 @@ struct HomeView: View { Spacer() Button { - secondsUntilReport = 7_200 - showReportSuccess = true + openRoute(HomeMenuRouter.resolve(uri: "location_report", title: "位置上报")) } label: { Image(systemName: "hand.tap.fill") .font(.system(size: 38, weight: .semibold)) diff --git a/suixinkan/Features/LocationReport/API/LocationReportAPI.swift b/suixinkan/Features/LocationReport/API/LocationReportAPI.swift new file mode 100644 index 0000000..b6d7823 --- /dev/null +++ b/suixinkan/Features/LocationReport/API/LocationReportAPI.swift @@ -0,0 +1,82 @@ +// +// LocationReportAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation +import Observation + +/// 位置上报服务协议,抽象上报和历史接口以便 ViewModel 测试替换。 +@MainActor +protocol LocationReportServing { + /// 提交一次位置上报。 + func reportLocation(staffId: Int, latitude: Double, longitude: Double, address: String, type: LocationReportType, scenicId: Int) async throws -> LocationReportSubmitResponse + + /// 获取位置上报历史列表。 + func locationReportList(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?) async throws -> ListPayload +} + +/// 位置上报 API,负责封装旧工程定位上报相关接口。 +@MainActor +@Observable +final class LocationReportAPI: LocationReportServing { + @ObservationIgnored private let client: APIClient + + /// 初始化位置上报 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 提交一次位置上报。 + func reportLocation( + staffId: Int, + latitude: Double, + longitude: Double, + address: String, + type: LocationReportType, + scenicId: Int + ) async throws -> LocationReportSubmitResponse { + try await client.send( + APIRequest( + method: .post, + path: "/api/yf-handset-app/photog/loacation/report", + queryItems: [ + URLQueryItem(name: "staff_id", value: "\(staffId)"), + URLQueryItem(name: "latitude", value: "\(latitude)"), + URLQueryItem(name: "longitude", value: "\(longitude)"), + URLQueryItem(name: "address", value: address), + URLQueryItem(name: "type", value: "\(type.rawValue)"), + URLQueryItem(name: "scenic_id", value: "\(scenicId)") + ] + ) + ) + } + + /// 获取位置上报历史列表。 + func locationReportList( + staffId: Int, + page: Int = 1, + pageSize: Int = 20, + type: LocationReportType = .all, + startDate: String? = nil, + endDate: String? = nil + ) async throws -> ListPayload { + var query = [ + URLQueryItem(name: "staff_id", value: "\(staffId)"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))"), + URLQueryItem(name: "type", value: "\(max(type.rawValue, 0))") + ] + if let startDate, !startDate.isEmpty { + query.append(URLQueryItem(name: "start_date", value: startDate)) + } + if let endDate, !endDate.isEmpty { + query.append(URLQueryItem(name: "end_date", value: endDate)) + } + return try await client.send( + APIRequest(method: .get, path: "/api/yf-handset-app/photog/loacation/list", queryItems: query) + ) + } +} diff --git a/suixinkan/Features/LocationReport/LocationReport.md b/suixinkan/Features/LocationReport/LocationReport.md new file mode 100644 index 0000000..e7df61a --- /dev/null +++ b/suixinkan/Features/LocationReport/LocationReport.md @@ -0,0 +1,39 @@ +# LocationReport 模块业务逻辑 + +## 模块职责 + +LocationReport 模块负责首页 `location_report` 和 `location_report_history` 入口。 + +本模块包含当前位置获取、标记点、在线状态、立即上报、提醒设置、历史记录、类型筛选、日期筛选和分页。上报状态只保存在页面 ViewModel 内,不进入全局 App 状态。 + +## 数据来源 + +- 当前景区 ID 从 `AccountContext.currentScenic` 读取。 +- 上报人员 ID 从 `AccountSnapshotStore.load()?.businessUserId` 读取,避免把业务账号 ID 复制进页面全局状态。 +- 提交接口使用旧工程拼写 `/api/yf-handset-app/photog/loacation/report`。 +- 历史接口使用 `/api/yf-handset-app/photog/loacation/list`。 + +## 上报流程 + +`LocationReportViewModel` 管理当前位置、标记点、在线状态、提醒分钟数和页面内倒计时。 + +1. 页面进入时请求一次前台定位。 +2. 用户可以立即上报当前位置,也可以把当前位置设为标记点后上报。 +3. 切换到在线状态时提交一次在线状态上报。 +4. 上报成功后按服务端 `expired` 重置倒计时;服务端未返回时兜底为 2 小时。 + +## 历史流程 + +`LocationReportHistoryViewModel` 管理类型筛选、日期筛选、分页和错误状态。 + +历史记录支持 `全部`、`立即上报`、`标记点`、`在线状态` 四类筛选。分页失败只保留错误提示,不影响登录态和其他模块。 + +## 定位边界 + +当前只做前台手动定位和页面内倒计时。本轮不做后台持续定位、后台定时上报、离线持久化队列或推送提醒。 + +定位结果、在线状态、提醒倒计时和提交表单均不落盘。 + +## 测试要求 + +新增位置上报逻辑时,需要同步补充 API、ViewModel 和路由测试。测试失败时先修复问题,再继续迁移后续功能。 diff --git a/suixinkan/Features/LocationReport/Models/LocationReportModels.swift b/suixinkan/Features/LocationReport/Models/LocationReportModels.swift new file mode 100644 index 0000000..f22868a --- /dev/null +++ b/suixinkan/Features/LocationReport/Models/LocationReportModels.swift @@ -0,0 +1,127 @@ +// +// LocationReportModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 位置上报类型实体,表示立即上报、标记点上报和在线状态上报。 +enum LocationReportType: Int, CaseIterable, Identifiable { + case all = 0 + case immediate = 1 + case marked = 2 + case onlineStatus = 3 + + var id: Int { rawValue } + + /// 上报类型展示标题。 + var title: String { + switch self { + case .all: "全部" + case .immediate: "立即上报" + case .marked: "标记点" + case .onlineStatus: "在线状态" + } + } +} + +/// 位置坐标实体,表示定位或手动选点得到的经纬度。 +struct LocationCoordinate: Equatable { + var latitude: Double + var longitude: Double +} + +/// 位置上报提交响应实体,表示服务端返回的上报人员、过期秒数和状态。 +struct LocationReportSubmitResponse: Decodable, Equatable { + let staffId: String + let expired: Int + let status: Int + + enum CodingKeys: String, CodingKey { + case staffId = "staff_id" + case expired + case status + } + + /// 创建位置上报提交响应,主要用于测试替身返回。 + init(staffId: String, expired: Int, status: Int) { + self.staffId = staffId + self.expired = expired + self.status = status + } + + /// 宽松解码提交响应字段。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + staffId = try container.decodeLossyString(forKey: .staffId) + expired = try container.decodeLossyInt(forKey: .expired) ?? 0 + status = try container.decodeLossyInt(forKey: .status) ?? 0 + } +} + +/// 位置上报历史项实体,表示一次历史上报记录。 +struct LocationReportHistoryItem: Decodable, Equatable, Identifiable { + let id: Int + let staffId: Int + 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.decodeLossyInt(forKey: .id) ?? 0 + staffId = try container.decodeLossyInt(forKey: .staffId) ?? 0 + type = try container.decodeLossyInt(forKey: .type) ?? 0 + latitude = try container.decodeLossyString(forKey: .latitude) + longitude = try container.decodeLossyString(forKey: .longitude) + address = try container.decodeLossyString(forKey: .address) + ip = try container.decodeLossyString(forKey: .ip) + remark = try container.decodeLossyString(forKey: .remark) + createdAt = try container.decodeLossyString(forKey: .createdAt) + } + + /// 上报类型展示文案。 + var typeTitle: String { + LocationReportType(rawValue: type)?.title ?? "未知" + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { return value } + if let value = try? decodeIfPresent(Int.self, forKey: key) { return String(value) } + if let value = try? decodeIfPresent(Double.self, forKey: key) { return String(value) } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value ? "1" : "0" } + return "" + } + + /// 将 String、Double 和 Int 宽松解码为 Int。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } + if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } +} diff --git a/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModels.swift b/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModels.swift new file mode 100644 index 0000000..bfc2dd6 --- /dev/null +++ b/suixinkan/Features/LocationReport/ViewModels/LocationReportViewModels.swift @@ -0,0 +1,233 @@ +// +// LocationReportViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation +import Observation + +/// 位置上报 ViewModel,负责页面内在线状态、坐标、倒计时和提交动作。 +@MainActor +@Observable +final class LocationReportViewModel { + var isOnline = false + var reminderMinutes = 30 + var secondsUntilReport = 0 + var currentCoordinate: LocationCoordinate? + var markedCoordinate: LocationCoordinate? + var currentAddress = "" + var markedAddress = "" + var lastReportText = "" + var errorMessage: String? + var isSubmitting = false + + /// 倒计时展示文案。 + var countdownText: String { + guard secondsUntilReport > 0 else { return "可立即上报" } + let hours = secondsUntilReport / 3600 + let minutes = (secondsUntilReport % 3600) / 60 + return "\(hours)小时\(minutes)分钟后可再次提醒" + } + + /// 设置当前位置。 + func applyCurrentLocation(latitude: Double, longitude: Double, address: String) { + currentCoordinate = LocationCoordinate(latitude: latitude, longitude: longitude) + currentAddress = address + } + + /// 设置标记点位置。 + func applyMarkedLocation(latitude: Double, longitude: Double, address: String) { + markedCoordinate = LocationCoordinate(latitude: latitude, longitude: longitude) + markedAddress = address + } + + /// 切换在线状态;切到在线时会提交一次状态上报。 + func setOnline( + _ value: Bool, + staffId: Int?, + scenicId: Int?, + api: any LocationReportServing + ) async -> Bool { + isOnline = value + guard value else { return true } + return await submit(type: .onlineStatus, staffId: staffId, scenicId: scenicId, api: api) + } + + /// 提交指定类型的位置上报。 + func submit( + type: LocationReportType, + staffId: Int?, + scenicId: Int?, + api: any LocationReportServing + ) async -> Bool { + guard !isSubmitting else { return false } + guard let staffId, staffId > 0 else { + errorMessage = "缺少上报人员" + return false + } + guard let scenicId, scenicId > 0 else { + errorMessage = "缺少当前景区" + return false + } + + let source = reportSource(for: type) + guard let coordinate = source.coordinate else { + errorMessage = "请先获取或选择位置" + return false + } + let address = source.address.trimmingCharacters(in: .whitespacesAndNewlines) + guard !address.isEmpty else { + errorMessage = "请填写位置地址" + return false + } + + isSubmitting = true + errorMessage = nil + defer { isSubmitting = false } + + do { + let response = try await api.reportLocation( + staffId: staffId, + latitude: coordinate.latitude, + longitude: coordinate.longitude, + address: address, + type: type, + scenicId: scenicId + ) + secondsUntilReport = response.expired > 0 ? response.expired : 7200 + lastReportText = LocationReportViewModel.displayFormatter.string(from: Date()) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 页面内倒计时每秒递减。 + func tick() { + guard secondsUntilReport > 0 else { return } + secondsUntilReport -= 1 + } + + /// 根据上报类型选择当前坐标或标记点坐标。 + private func reportSource(for type: LocationReportType) -> (coordinate: LocationCoordinate?, address: String) { + switch type { + case .marked: + (markedCoordinate ?? currentCoordinate, markedAddress.isEmpty ? currentAddress : markedAddress) + case .all, .immediate, .onlineStatus: + (currentCoordinate, currentAddress) + } + } + + private static let displayFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter + }() +} + +/// 位置上报历史 ViewModel,负责筛选、日期和分页加载。 +@MainActor +@Observable +final class LocationReportHistoryViewModel { + var selectedType: LocationReportType = .all + var startDate: Date? + var endDate: Date? + var items: [LocationReportHistoryItem] = [] + var errorMessage: String? + var isLoading = false + var isLoadingMore = false + var total = 0 + + private var page = 1 + private let pageSize = 20 + + /// 是否还有下一页历史记录。 + var hasMore: Bool { + items.count < total + } + + /// 重新加载历史记录。 + func reload(staffId: Int?, api: any LocationReportServing) async { + guard let staffId, staffId > 0 else { + reset() + errorMessage = "缺少上报人员" + return + } + page = 1 + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let payload = try await api.locationReportList( + staffId: staffId, + page: page, + pageSize: pageSize, + type: selectedType, + startDate: dateString(startDate), + endDate: dateString(endDate) + ) + items = payload.list + total = payload.total + } catch { + items = [] + total = 0 + errorMessage = error.localizedDescription + } + } + + /// 加载下一页历史记录。 + func loadMore(staffId: Int?, api: any LocationReportServing) async { + guard let staffId, staffId > 0, hasMore, !isLoading, !isLoadingMore else { return } + isLoadingMore = true + let nextPage = page + 1 + defer { isLoadingMore = false } + + do { + let payload = try await api.locationReportList( + staffId: staffId, + page: nextPage, + pageSize: pageSize, + type: selectedType, + startDate: dateString(startDate), + endDate: dateString(endDate) + ) + page = nextPage + items.append(contentsOf: payload.list) + total = payload.total + } catch { + errorMessage = error.localizedDescription + } + } + + /// 切换历史类型筛选并刷新。 + func selectType(_ type: LocationReportType, staffId: Int?, api: any LocationReportServing) async { + guard selectedType != type else { return } + selectedType = type + await reload(staffId: staffId, api: api) + } + + /// 清空历史状态。 + func reset() { + items = [] + total = 0 + page = 1 + isLoading = false + isLoadingMore = false + } + + /// 将日期转为接口需要的 yyyy-MM-dd 字符串。 + private func dateString(_ date: Date?) -> String? { + guard let date else { return nil } + return Self.dateFormatter.string(from: date) + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() +} diff --git a/suixinkan/Features/LocationReport/Views/LocationReportViews.swift b/suixinkan/Features/LocationReport/Views/LocationReportViews.swift new file mode 100644 index 0000000..b88a452 --- /dev/null +++ b/suixinkan/Features/LocationReport/Views/LocationReportViews.swift @@ -0,0 +1,405 @@ +// +// LocationReportViews.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Combine +import SwiftUI + +/// 位置上报页面,支持当前位置上报、标记点上报、在线状态和提醒设置。 +struct LocationReportView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(AccountSnapshotStore.self) private var snapshotStore + @Environment(LocationReportAPI.self) private var locationReportAPI + @Environment(RouterPath.self) private var router + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading + + @State private var viewModel = LocationReportViewModel() + @State private var locationProvider = ForegroundLocationProvider() + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + statusSection + currentLocationSection + markedLocationSection + reminderSection + actionSection + } + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("位置上报") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + router.navigate(to: .home(.locationReportHistory)) + } label: { + Image(systemName: "clock.arrow.circlepath") + } + .accessibilityLabel("上报历史") + } + } + .onReceive(timer) { _ in viewModel.tick() } + .task { + guard viewModel.currentCoordinate == nil else { return } + await locateCurrent() + } + } + + /// 在线状态和倒计时区域。 + private var statusSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + Text(viewModel.isOnline ? "当前在线" : "当前离线") + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Text(viewModel.countdownText) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + } + Spacer() + Toggle("", isOn: Binding( + get: { viewModel.isOnline }, + set: { value in + Task { await toggleOnline(value) } + } + )) + .labelsHidden() + } + if !viewModel.lastReportText.isEmpty { + Text("上次上报:\(viewModel.lastReportText)") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.placeholder) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 当前定位区域。 + private var currentLocationSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack { + Text("当前位置") + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + Spacer() + Button("重新定位") { + Task { await locateCurrent() } + } + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + } + locationText(coordinate: viewModel.currentCoordinate, address: viewModel.currentAddress) + TextField("当前位置地址", text: $viewModel.currentAddress) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 标记点区域。 + private var markedLocationSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack { + Text("标记点") + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + Spacer() + Button("使用当前位置") { + if let coordinate = viewModel.currentCoordinate { + viewModel.applyMarkedLocation( + latitude: coordinate.latitude, + longitude: coordinate.longitude, + address: viewModel.currentAddress + ) + } + } + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + } + locationText(coordinate: viewModel.markedCoordinate, address: viewModel.markedAddress) + HStack(spacing: AppMetrics.Spacing.small) { + TextField("纬度", text: Binding( + get: { viewModel.markedCoordinate.map { String($0.latitude) } ?? "" }, + set: { text in updateMarkedCoordinate(latitudeText: text, longitudeText: nil) } + )) + .keyboardType(.decimalPad) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + TextField("经度", text: Binding( + get: { viewModel.markedCoordinate.map { String($0.longitude) } ?? "" }, + set: { text in updateMarkedCoordinate(latitudeText: nil, longitudeText: text) } + )) + .keyboardType(.decimalPad) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + } + TextField("标记点地址", text: $viewModel.markedAddress) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 提醒设置区域。 + private var reminderSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + Text("提醒设置") + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + Stepper("提前 \(viewModel.reminderMinutes) 分钟提醒", value: $viewModel.reminderMinutes, in: 5...120, step: 5) + .font(.system(size: AppMetrics.FontSize.subheadline)) + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 上报按钮区域。 + private var actionSection: some View { + VStack(spacing: AppMetrics.Spacing.small) { + Button { + Task { await submit(type: .immediate) } + } label: { + Label("立即上报当前位置", systemImage: "location.fill") + .frame(maxWidth: .infinity) + .frame(height: AppMetrics.ControlSize.primaryButtonHeight) + } + .buttonStyle(.borderedProminent) + + Button { + Task { await submit(type: .marked) } + } label: { + Label("上报标记点", systemImage: "mappin.and.ellipse") + .frame(maxWidth: .infinity) + .frame(height: AppMetrics.ControlSize.primaryButtonHeight) + } + .buttonStyle(.bordered) + } + .disabled(viewModel.isSubmitting) + } + + /// 坐标和地址展示。 + private func locationText(coordinate: LocationCoordinate?, address: String) -> some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + Text(address.isEmpty ? "暂无地址" : address) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textPrimary) + Text(coordinate.map { "\($0.latitude), \($0.longitude)" } ?? "暂无坐标") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var staffId: Int? { + snapshotStore.load()?.businessUserId + } + + private var scenicId: Int? { + accountContext.currentScenic?.id + } + + /// 请求当前位置。 + private func locateCurrent() async { + do { + let result = try await locationProvider.requestCurrentLocation() + viewModel.applyCurrentLocation(latitude: result.latitude, longitude: result.longitude, address: result.address) + } catch { + toastCenter.show("定位失败:\(error.localizedDescription)") + } + } + + /// 切换在线状态。 + private func toggleOnline(_ value: Bool) async { + let success = await globalLoading.withOptionalLoading(value) { + await viewModel.setOnline(value, staffId: staffId, scenicId: scenicId, api: locationReportAPI) + } + if !success { + toastCenter.show(viewModel.errorMessage ?? "状态上报失败") + } + } + + /// 提交指定类型的位置上报。 + private func submit(type: LocationReportType) async { + let success = await globalLoading.withLoading { + await viewModel.submit(type: type, staffId: staffId, scenicId: scenicId, api: locationReportAPI) + } + toastCenter.show(success ? "上报成功" : (viewModel.errorMessage ?? "上报失败")) + } + + /// 手动更新标记点经纬度。 + private func updateMarkedCoordinate(latitudeText: String?, longitudeText: String?) { + let current = viewModel.markedCoordinate + let latitude = Double(latitudeText ?? current.map { String($0.latitude) } ?? "") + let longitude = Double(longitudeText ?? current.map { String($0.longitude) } ?? "") + if let latitude, let longitude { + viewModel.applyMarkedLocation(latitude: latitude, longitude: longitude, address: viewModel.markedAddress) + } + } +} + +/// 位置上报历史页面,展示历史记录筛选和分页。 +struct LocationReportHistoryView: View { + @Environment(AccountSnapshotStore.self) private var snapshotStore + @Environment(LocationReportAPI.self) private var locationReportAPI + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading + + @State private var viewModel = LocationReportHistoryViewModel() + + var body: some View { + ScrollView { + LazyVStack(spacing: AppMetrics.Spacing.medium) { + filterSection + contentSection + } + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("位置上报历史") + .navigationBarTitleDisplayMode(.inline) + .refreshable { await reload(showLoading: false) } + .task { + guard viewModel.items.isEmpty else { return } + await reload(showLoading: true) + } + } + + /// 筛选区域。 + private var filterSection: some View { + VStack(spacing: AppMetrics.Spacing.small) { + Picker("上报类型", selection: $viewModel.selectedType) { + ForEach(LocationReportType.allCases) { type in + Text(type.title).tag(type) + } + } + .pickerStyle(.segmented) + .onChange(of: viewModel.selectedType) { _, _ in + Task { await reload(showLoading: true) } + } + + HStack(spacing: AppMetrics.Spacing.small) { + optionalDatePicker(title: "开始", date: $viewModel.startDate) + optionalDatePicker(title: "结束", date: $viewModel.endDate) + } + HStack { + Button("清除日期") { + viewModel.startDate = nil + viewModel.endDate = nil + Task { await reload(showLoading: true) } + } + .foregroundStyle(AppDesign.textSecondary) + Spacer() + Button("应用日期") { + Task { await reload(showLoading: true) } + } + .font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + } + .font(.system(size: AppMetrics.FontSize.subheadline)) + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 内容区域。 + @ViewBuilder + private var contentSection: some View { + if viewModel.items.isEmpty { + LocationReportEmptyState(title: "暂无历史记录", message: viewModel.errorMessage ?? "当前筛选条件下没有位置上报记录。") + } else { + ForEach(viewModel.items) { item in + LocationReportHistoryCard(item: item) + .onAppear { + guard item.id == viewModel.items.last?.id else { return } + Task { await viewModel.loadMore(staffId: staffId, api: locationReportAPI) } + } + } + if viewModel.isLoadingMore { + ProgressView() + .padding(.vertical, AppMetrics.Spacing.medium) + } + } + } + + private var staffId: Int? { + snapshotStore.load()?.businessUserId + } + + /// 重新加载历史。 + private func reload(showLoading: Bool) async { + await globalLoading.withOptionalLoading(showLoading) { + await viewModel.reload(staffId: staffId, api: locationReportAPI) + } + if let error = viewModel.errorMessage { + toastCenter.show(error) + } + } + + /// 可选日期选择器。 + private func optionalDatePicker(title: String, date: Binding) -> some View { + DatePicker( + title, + selection: Binding( + get: { date.wrappedValue ?? Date() }, + set: { date.wrappedValue = $0 } + ), + displayedComponents: .date + ) + .font(.system(size: AppMetrics.FontSize.subheadline)) + } +} + +/// 位置上报历史卡片。 +private struct LocationReportHistoryCard: View { + let item: LocationReportHistoryItem + + var body: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack { + Text(item.typeTitle) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Spacer() + Text(item.createdAt) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.placeholder) + } + Text(item.address.isEmpty ? "暂无地址" : item.address) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + Text("\(item.latitude), \(item.longitude)") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.placeholder) + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } +} + +/// 位置上报空状态组件。 +private struct LocationReportEmptyState: View { + let title: String + let message: String + + var body: some View { + VStack(spacing: AppMetrics.Spacing.small) { + Image(systemName: "location.slash") + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(AppDesign.placeholder) + Text(title) + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Text(message) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(AppMetrics.Spacing.xLarge) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } +} diff --git a/suixinkan/Features/PunchPoint/API/PunchPointAPI.swift b/suixinkan/Features/PunchPoint/API/PunchPointAPI.swift new file mode 100644 index 0000000..924f63d --- /dev/null +++ b/suixinkan/Features/PunchPoint/API/PunchPointAPI.swift @@ -0,0 +1,88 @@ +// +// PunchPointAPI.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation +import Observation + +/// 打卡点服务协议,抽象列表、详情和增删改接口以便单元测试替换。 +@MainActor +protocol PunchPointServing { + /// 获取指定景区的打卡点列表。 + func punchPointList(scenicId: Int, status: Int, page: Int, pageSize: Int) async throws -> ListPayload + + /// 获取指定打卡点详情。 + func punchPointInfo(id: Int) async throws -> PunchPointItem + + /// 新增打卡点。 + func addPunchPoint(_ request: AddPunchPointRequest) async throws + + /// 编辑打卡点。 + func editPunchPoint(_ request: EditPunchPointRequest) async throws + + /// 删除打卡点。 + func deletePunchPoint(id: Int) async throws +} + +/// 打卡点 API,负责封装旧工程打卡点管理相关接口。 +@MainActor +@Observable +final class PunchPointAPI: PunchPointServing { + @ObservationIgnored private let client: APIClient + + /// 初始化打卡点 API,并注入共享网络客户端。 + init(client: APIClient) { + self.client = client + } + + /// 获取指定景区的打卡点列表。 + func punchPointList(scenicId: Int, status: Int = 0, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/scenic-spot/list", + queryItems: [ + URLQueryItem(name: "scenic_area_id", value: "\(scenicId)"), + URLQueryItem(name: "status", value: "\(max(status, 0))"), + URLQueryItem(name: "page", value: "\(max(page, 1))"), + URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))") + ] + ) + ) + } + + /// 获取指定打卡点详情。 + func punchPointInfo(id: Int) async throws -> PunchPointItem { + try await client.send( + APIRequest( + method: .get, + path: "/api/yf-handset-app/photog/scenic-spot/info", + queryItems: [URLQueryItem(name: "id", value: "\(id)")] + ) + ) + } + + /// 新增打卡点。 + func addPunchPoint(_ request: AddPunchPointRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/scenic-spot/add", body: request) + ) as EmptyPayload + } + + /// 编辑打卡点。 + func editPunchPoint(_ request: EditPunchPointRequest) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/scenic-spot/edit", body: request) + ) as EmptyPayload + } + + /// 删除打卡点。 + func deletePunchPoint(id: Int) async throws { + _ = try await client.send( + APIRequest(method: .post, path: "/api/yf-handset-app/photog/scenic-spot/delete", body: PunchPointDeleteRequest(id: id)) + ) as EmptyPayload + } +} diff --git a/suixinkan/Features/PunchPoint/Models/PunchPointModels.swift b/suixinkan/Features/PunchPoint/Models/PunchPointModels.swift new file mode 100644 index 0000000..8ad66e6 --- /dev/null +++ b/suixinkan/Features/PunchPoint/Models/PunchPointModels.swift @@ -0,0 +1,252 @@ +// +// PunchPointModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation + +/// 打卡点筛选实体,表示列表页可选择的审核或运营状态。 +enum PunchPointFilter: Int, CaseIterable, Identifiable { + case all = 0 + case operating = 1 + case paused = 2 + case pendingReview = 3 + case rejected = 4 + + var id: Int { rawValue } + + /// 筛选项展示标题。 + var title: String { + switch self { + case .all: "全部" + case .operating: "运营中" + case .paused: "已暂停" + case .pendingReview: "待审核" + case .rejected: "已驳回" + } + } +} + +/// 打卡点区域实体,表示点位经纬度和地址。 +struct PunchPointRegion: Codable, Hashable { + var lat: Double + var lot: Double + var address: String + var scenicSpotStr: String? + + enum CodingKeys: String, CodingKey { + case lat + case lot + case address + case scenicSpotStr = "scenic_spot_str" + } + + /// 创建打卡点区域实体。 + init(lat: Double, lot: Double, address: String, scenicSpotStr: String? = nil) { + self.lat = lat + self.lot = lot + self.address = address + self.scenicSpotStr = scenicSpotStr + } + + /// 宽松解码区域字段,兼容经纬度数字和字符串混合返回。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + lat = try container.decodeLossyDouble(forKey: .lat) ?? 0 + lot = try container.decodeLossyDouble(forKey: .lot) ?? 0 + address = try container.decodeLossyString(forKey: .address) + scenicSpotStr = try container.decodeIfPresent(String.self, forKey: .scenicSpotStr) + } +} + +/// 打卡点列表项实体,表示一个景区下可管理的打卡点。 +struct PunchPointItem: Decodable, Hashable, Identifiable { + let id: Int + let scenicAreaId: Int + let name: String + let status: Int + let statusLabel: String + let description: String + let region: PunchPointRegion? + let scenicSpotStr: String + let guideImages: [String] + let mpQrcode: String + let createdAt: String + let creator: String + let creatorPhone: String + let auditor: String + let auditTime: String + let auditRemark: String + + enum CodingKeys: String, CodingKey { + case id + case scenicAreaId = "scenic_area_id" + case name + case status + case statusLabel = "status_label" + case description + case region + case scenicSpotStr = "scenic_spot_str" + case guideImages = "guide_imgs" + case mpQrcode = "mp_qrcode" + case createdAt = "created_at" + case creator + case creatorPhone = "creator_phone" + case auditor + case auditTime = "audit_time" + case auditRemark = "audit_remark" + } + + /// 创建打卡点列表项,主要用于测试和表单兜底展示。 + init( + id: Int, + scenicAreaId: Int = 0, + name: String, + status: Int = 0, + statusLabel: String = "", + description: String = "", + region: PunchPointRegion? = nil, + scenicSpotStr: String = "", + guideImages: [String] = [], + mpQrcode: String = "", + createdAt: String = "", + creator: String = "", + creatorPhone: String = "", + auditor: String = "", + auditTime: String = "", + auditRemark: String = "" + ) { + self.id = id + self.scenicAreaId = scenicAreaId + self.name = name + self.status = status + self.statusLabel = statusLabel + self.description = description + self.region = region + self.scenicSpotStr = scenicSpotStr + self.guideImages = guideImages + self.mpQrcode = mpQrcode + self.createdAt = createdAt + self.creator = creator + self.creatorPhone = creatorPhone + self.auditor = auditor + self.auditTime = auditTime + self.auditRemark = auditRemark + } + + /// 宽松解码打卡点字段,兼容后端字符串和数字混合返回。 + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeLossyInt(forKey: .id) ?? 0 + scenicAreaId = try container.decodeLossyInt(forKey: .scenicAreaId) ?? 0 + name = try container.decodeLossyString(forKey: .name) + status = try container.decodeLossyInt(forKey: .status) ?? 0 + statusLabel = try container.decodeLossyString(forKey: .statusLabel) + description = try container.decodeLossyString(forKey: .description) + region = try container.decodeIfPresent(PunchPointRegion.self, forKey: .region) + scenicSpotStr = try container.decodeLossyString(forKey: .scenicSpotStr) + guideImages = (try? container.decodeIfPresent([String].self, forKey: .guideImages)) ?? [] + mpQrcode = try container.decodeLossyString(forKey: .mpQrcode) + createdAt = try container.decodeLossyString(forKey: .createdAt) + creator = try container.decodeLossyString(forKey: .creator) + creatorPhone = try container.decodeLossyString(forKey: .creatorPhone) + auditor = try container.decodeLossyString(forKey: .auditor) + auditTime = try container.decodeLossyString(forKey: .auditTime) + auditRemark = try container.decodeLossyString(forKey: .auditRemark) + } +} + +/// 新增打卡点请求实体,表示提交给后端的点位表单。 +struct AddPunchPointRequest: Encodable, Equatable { + let scenicAreaId: String + let name: String + let description: String + let region: PunchPointRegion + let scenicSpotStr: String + let guideImages: [String] + + enum CodingKeys: String, CodingKey { + case scenicAreaId = "scenic_area_id" + case name + case description + case region + case scenicSpotStr = "scenic_spot_str" + case guideImages = "guide_imgs" + } +} + +/// 编辑打卡点请求实体,表示已有点位的完整修改内容。 +struct EditPunchPointRequest: Encodable, Equatable { + let id: Int + let scenicAreaId: String + let name: String + let description: String + let region: PunchPointRegion + let scenicSpotStr: String + let guideImages: [String] + + enum CodingKeys: String, CodingKey { + case id + case scenicAreaId = "scenic_area_id" + case name + case description + case region + case scenicSpotStr = "scenic_spot_str" + case guideImages = "guide_imgs" + } +} + +/// 删除打卡点请求实体,表示要删除的点位 ID。 +struct PunchPointDeleteRequest: Encodable, Equatable { + let id: Int +} + +/// 打卡点本地待上传图片实体,保存 PhotosPicker 读取后的内存图片。 +struct PunchPointLocalImage: Identifiable, Equatable { + let id = UUID() + let data: Data + let fileName: String + var remoteURL: String? + var progress: Int + + /// 创建待上传打卡点图片。 + init(data: Data, fileName: String, remoteURL: String? = nil, progress: Int = 0) { + self.data = data + self.fileName = fileName + self.remoteURL = remoteURL + self.progress = progress + } +} + +private extension KeyedDecodingContainer { + /// 将 String、数字和 Bool 宽松解码为字符串。 + func decodeLossyString(forKey key: Key) throws -> String { + if let value = try? decodeIfPresent(String.self, forKey: key) { return value } + if let value = try? decodeIfPresent(Int.self, forKey: key) { return String(value) } + if let value = try? decodeIfPresent(Double.self, forKey: key) { return String(value) } + if let value = try? decodeIfPresent(Bool.self, forKey: key) { return value ? "1" : "0" } + return "" + } + + /// 将 String、Double 和 Int 宽松解码为 Int。 + func decodeLossyInt(forKey key: Key) throws -> Int? { + if let value = try? decodeIfPresent(Int.self, forKey: key) { return value } + if let value = try? decodeIfPresent(Double.self, forKey: key) { return Int(value) } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } + + /// 将 String、Double 和 Int 宽松解码为 Double。 + func decodeLossyDouble(forKey key: Key) throws -> Double? { + if let value = try? decodeIfPresent(Double.self, forKey: key) { return value } + if let value = try? decodeIfPresent(Int.self, forKey: key) { return Double(value) } + if let value = try? decodeIfPresent(String.self, forKey: key) { + return Double(value.trimmingCharacters(in: .whitespacesAndNewlines)) + } + return nil + } +} diff --git a/suixinkan/Features/PunchPoint/PunchPoint.md b/suixinkan/Features/PunchPoint/PunchPoint.md new file mode 100644 index 0000000..14fb6e9 --- /dev/null +++ b/suixinkan/Features/PunchPoint/PunchPoint.md @@ -0,0 +1,37 @@ +# PunchPoint 模块业务逻辑 + +## 模块职责 + +PunchPoint 模块负责首页 `checkin_points` 打卡点管理入口。 + +本模块包含打卡点列表、状态筛选、分页、详情、二维码、新建、编辑、删除、前台定位选点和图片 OSS 上传。打卡点列表和表单状态只保存在模块 ViewModel 内,不进入 `AppSession`、`AccountContext`、TabBar 或首页状态。 + +## 数据来源 + +- 当前景区 ID 从 `AccountContext.currentScenic` 读取。 +- 列表接口使用 `/api/yf-handset-app/photog/scenic-spot/list`。 +- 详情接口使用 `/api/yf-handset-app/photog/scenic-spot/info`。 +- 新建、编辑、删除分别使用 `/add`、`/edit`、`/delete`。 +- 图片上传统一使用 `OSSUploadService.uploadPunchPointImage`。 + +## 页面流程 + +`PunchPointListViewModel` 管理状态筛选、分页、详情兜底和删除刷新。缺少当前景区时清空列表并停止请求。 + +`PunchPointEditorViewModel` 管理新增和编辑表单。提交前校验名称、坐标、地址和图片;本地图片会先上传 OSS,全部拿到最终 URL 后再提交打卡点接口。上传失败时不提交业务接口。 + +编辑或删除成功后,页面会触发 `ScenicSpotContext.reload`,保证素材、样片等依赖打卡点选择器的页面能看到最新数据。 + +## 定位边界 + +当前实现使用 `ForegroundLocationProvider` 做前台即时定位和地址反解析,并允许手动填写经纬度。本轮不做后台定位、不缓存定位结果,也不把定位权限状态落盘。 + +后续如果接入完整高德地图选点 UI,只需要替换编辑页的选点组件,继续把经纬度、地址回填到 `PunchPointEditorViewModel`。 + +## 缓存边界 + +打卡点列表、详情、表单、本地图片、上传进度和 OSS STS 都不落盘。远程图片缓存继续交给 Kingfisher 的 `RemoteImage`。 + +## 测试要求 + +新增打卡点逻辑时,需要同步补充 API、ViewModel 和路由测试。测试失败时先修复问题,再继续迁移后续功能。 diff --git a/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift b/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift new file mode 100644 index 0000000..cdb6949 --- /dev/null +++ b/suixinkan/Features/PunchPoint/ViewModels/PunchPointViewModels.swift @@ -0,0 +1,280 @@ +// +// PunchPointViewModels.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import Foundation +import Observation + +/// 打卡点列表 ViewModel,负责筛选、分页、详情加载和删除刷新。 +@MainActor +@Observable +final class PunchPointListViewModel { + var selectedFilter: PunchPointFilter = .all + var items: [PunchPointItem] = [] + var selectedDetail: PunchPointItem? + var errorMessage: String? + var isLoading = false + var isLoadingMore = false + var total = 0 + + private var page = 1 + private let pageSize = 20 + + /// 是否还存在下一页数据。 + var hasMore: Bool { + items.count < total + } + + /// 重新加载当前筛选下的打卡点列表。 + func reload(scenicId: Int?, api: any PunchPointServing) async { + guard let scenicId else { + reset() + return + } + page = 1 + isLoading = true + errorMessage = nil + defer { isLoading = false } + + do { + let payload = try await api.punchPointList( + scenicId: scenicId, + status: selectedFilter.rawValue, + page: page, + pageSize: pageSize + ) + items = payload.list + total = payload.total + } catch { + items = [] + total = 0 + errorMessage = error.localizedDescription + } + } + + /// 加载下一页打卡点列表。 + func loadMore(scenicId: Int?, api: any PunchPointServing) async { + guard let scenicId, hasMore, !isLoadingMore, !isLoading else { return } + isLoadingMore = true + let nextPage = page + 1 + defer { isLoadingMore = false } + + do { + let payload = try await api.punchPointList( + scenicId: scenicId, + status: selectedFilter.rawValue, + page: nextPage, + pageSize: pageSize + ) + page = nextPage + items.append(contentsOf: payload.list) + total = payload.total + } catch { + errorMessage = error.localizedDescription + } + } + + /// 切换筛选并重载列表。 + func selectFilter(_ filter: PunchPointFilter, scenicId: Int?, api: any PunchPointServing) async { + guard selectedFilter != filter else { return } + selectedFilter = filter + await reload(scenicId: scenicId, api: api) + } + + /// 加载单个打卡点详情。 + func loadDetail(id: Int, api: any PunchPointServing) async { + errorMessage = nil + do { + selectedDetail = try await api.punchPointInfo(id: id) + } catch { + selectedDetail = items.first { $0.id == id } + errorMessage = error.localizedDescription + } + } + + /// 删除打卡点并刷新当前列表。 + func delete(_ item: PunchPointItem, scenicId: Int?, api: any PunchPointServing) async -> Bool { + do { + try await api.deletePunchPoint(id: item.id) + await reload(scenicId: scenicId, api: api) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 清空列表状态,通常用于缺少当前景区时。 + func reset() { + items = [] + selectedDetail = nil + errorMessage = nil + total = 0 + page = 1 + isLoading = false + isLoadingMore = false + } +} + +/// 打卡点编辑 ViewModel,负责新增、编辑、表单校验和 OSS 图片上传。 +@MainActor +@Observable +final class PunchPointEditorViewModel { + var name = "" + var description = "" + var address = "" + var latitudeText = "" + var longitudeText = "" + var scenicSpotText = "" + var remoteImages: [String] = [] + var localImages: [PunchPointLocalImage] = [] + var errorMessage: String? + var isSubmitting = false + + let editingItem: PunchPointItem? + + /// 初始化编辑 ViewModel,可传入已有打卡点作为编辑表单初值。 + init(item: PunchPointItem? = nil) { + editingItem = item + if let item { + name = item.name + description = item.description + address = item.region?.address ?? "" + latitudeText = item.region.map { String($0.lat) } ?? "" + longitudeText = item.region.map { String($0.lot) } ?? "" + scenicSpotText = item.scenicSpotStr + remoteImages = item.guideImages + } + } + + /// 设置经纬度和地址,通常由定位或地图选点回填。 + func applyLocation(latitude: Double, longitude: Double, address: String) { + latitudeText = String(format: "%.6f", latitude) + longitudeText = String(format: "%.6f", longitude) + self.address = address + if scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + scenicSpotText = address + } + } + + /// 添加本地待上传图片。 + func addLocalImages(_ images: [PunchPointLocalImage]) { + localImages.append(contentsOf: images) + } + + /// 删除远程图片。 + func removeRemoteImage(_ url: String) { + remoteImages.removeAll { $0 == url } + } + + /// 删除本地待上传图片。 + func removeLocalImage(id: UUID) { + localImages.removeAll { $0.id == id } + } + + /// 提交新增或编辑表单,图片会先上传 OSS,再提交业务接口。 + func submit( + scenicId: Int?, + api: any PunchPointServing, + uploadService: any OSSUploadServing + ) async -> Bool { + guard !isSubmitting else { return false } + guard let scenicId else { + errorMessage = "缺少当前景区" + return false + } + guard let region = makeRegion() else { return false } + + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedName.isEmpty else { + errorMessage = "请输入打卡点名称" + return false + } + guard !region.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + errorMessage = "请选择或填写打卡点地址" + return false + } + guard !remoteImages.isEmpty || !localImages.isEmpty else { + errorMessage = "请至少上传一张打卡点图片" + return false + } + + isSubmitting = true + errorMessage = nil + defer { isSubmitting = false } + + do { + let uploadedImages = try await uploadImages(scenicId: scenicId, uploadService: uploadService) + let allImages = remoteImages + uploadedImages + if let editingItem { + try await api.editPunchPoint( + EditPunchPointRequest( + id: editingItem.id, + scenicAreaId: "\(scenicId)", + name: trimmedName, + description: description.trimmingCharacters(in: .whitespacesAndNewlines), + region: region, + scenicSpotStr: scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines), + guideImages: allImages + ) + ) + } else { + try await api.addPunchPoint( + AddPunchPointRequest( + scenicAreaId: "\(scenicId)", + name: trimmedName, + description: description.trimmingCharacters(in: .whitespacesAndNewlines), + region: region, + scenicSpotStr: scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines), + guideImages: allImages + ) + ) + } + remoteImages = allImages + localImages = [] + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// 构建提交区域,校验经纬度是否可用。 + private func makeRegion() -> PunchPointRegion? { + let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard let lat = Double(latitudeText.trimmingCharacters(in: .whitespacesAndNewlines)), + let lot = Double(longitudeText.trimmingCharacters(in: .whitespacesAndNewlines)) else { + errorMessage = "请选择打卡点坐标" + return nil + } + return PunchPointRegion( + lat: lat, + lot: lot, + address: trimmedAddress, + scenicSpotStr: scenicSpotText.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } + + /// 上传本地图片,并返回 OSS URL 列表。 + private func uploadImages(scenicId: Int, uploadService: any OSSUploadServing) async throws -> [String] { + var uploaded: [String] = [] + for index in localImages.indices { + let local = localImages[index] + let url = try await uploadService.uploadPunchPointImage( + data: local.data, + fileName: local.fileName, + scenicId: scenicId + ) { [weak self] progress in + Task { @MainActor in + self?.localImages[index].progress = progress + } + } + localImages[index].remoteURL = url + uploaded.append(url) + } + return uploaded + } +} diff --git a/suixinkan/Features/PunchPoint/Views/PunchPointViews.swift b/suixinkan/Features/PunchPoint/Views/PunchPointViews.swift new file mode 100644 index 0000000..5ebbdd9 --- /dev/null +++ b/suixinkan/Features/PunchPoint/Views/PunchPointViews.swift @@ -0,0 +1,592 @@ +// +// PunchPointViews.swift +// suixinkan +// +// Created by Codex on 2026/6/24. +// + +import PhotosUI +import SwiftUI +import UIKit + +/// 打卡点列表页面,展示当前景区下的打卡点并提供新增入口。 +struct PunchPointListView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(AccountContextAPI.self) private var accountContextAPI + @Environment(PunchPointAPI.self) private var punchPointAPI + @Environment(RouterPath.self) private var router + @Environment(ScenicSpotContext.self) private var scenicSpotContext + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading + + @State private var viewModel = PunchPointListViewModel() + + var body: some View { + ScrollView { + LazyVStack(spacing: AppMetrics.Spacing.medium) { + filterSection + contentSection + } + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("打卡点管理") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + router.navigate(to: .home(.punchPointEditor(id: nil))) + } label: { + Image(systemName: "plus") + } + .accessibilityLabel("新增打卡点") + } + } + .refreshable { await reload(showLoading: false) } + .task { + guard viewModel.items.isEmpty else { return } + await reload(showLoading: true) + } + } + + /// 筛选区域。 + private var filterSection: some View { + Picker("打卡点状态", selection: $viewModel.selectedFilter) { + ForEach(PunchPointFilter.allCases) { filter in + Text(filter.title).tag(filter) + } + } + .pickerStyle(.segmented) + .onChange(of: viewModel.selectedFilter) { _, newValue in + Task { + await globalLoading.withOptionalLoading(currentScenicId != nil) { + await viewModel.selectFilter(newValue, scenicId: currentScenicId, api: punchPointAPI) + } + } + } + } + + /// 内容区域。 + @ViewBuilder + private var contentSection: some View { + if currentScenicId == nil { + PunchPointEmptyState(title: "缺少景区上下文", message: "请先在首页选择景区后再管理打卡点。") + } else if viewModel.items.isEmpty { + PunchPointEmptyState(title: "暂无打卡点", message: viewModel.errorMessage ?? "当前筛选条件下没有打卡点。") + } else { + ForEach(viewModel.items) { item in + Button { + router.navigate(to: .home(.punchPointDetail(id: item.id, summary: item))) + } label: { + PunchPointCardView(item: item) + } + .buttonStyle(.plain) + .onAppear { + guard item.id == viewModel.items.last?.id else { return } + Task { await loadMore() } + } + } + if viewModel.isLoadingMore { + ProgressView() + .padding(.vertical, AppMetrics.Spacing.medium) + } + } + } + + private var currentScenicId: Int? { + accountContext.currentScenic?.id + } + + /// 重新加载列表。 + private func reload(showLoading: Bool) async { + await globalLoading.withOptionalLoading(showLoading && currentScenicId != nil) { + await viewModel.reload(scenicId: currentScenicId, api: punchPointAPI) + } + } + + /// 加载下一页。 + private func loadMore() async { + await viewModel.loadMore(scenicId: currentScenicId, api: punchPointAPI) + } +} + +/// 打卡点详情页面,展示点位信息、图片和管理操作。 +struct PunchPointDetailView: View { + let punchPointId: Int + let summary: PunchPointItem? + + @Environment(AccountContext.self) private var accountContext + @Environment(AccountContextAPI.self) private var accountContextAPI + @Environment(PunchPointAPI.self) private var punchPointAPI + @Environment(RouterPath.self) private var router + @Environment(ScenicSpotContext.self) private var scenicSpotContext + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.globalLoading) private var globalLoading + + @State private var viewModel = PunchPointListViewModel() + @State private var showDeleteConfirm = false + + var item: PunchPointItem? { + viewModel.selectedDetail ?? summary + } + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + if let item { + detailHeader(item) + imageSection(item) + infoSection(item) + } else { + PunchPointEmptyState(title: "暂无详情", message: viewModel.errorMessage ?? "请稍后重试。") + } + } + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("打卡点详情") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + if let item { + Button { + router.navigate(to: .home(.punchPointQR(id: item.id, title: item.name, qrURL: item.mpQrcode))) + } label: { + Image(systemName: "qrcode") + } + Button { + router.navigate(to: .home(.punchPointEditor(id: item.id))) + } label: { + Image(systemName: "square.and.pencil") + } + Button(role: .destructive) { + showDeleteConfirm = true + } label: { + Image(systemName: "trash") + } + } + } + } + .confirmationDialog("确认删除该打卡点?", isPresented: $showDeleteConfirm, titleVisibility: .visible) { + Button("删除", role: .destructive) { + Task { await deleteCurrentItem() } + } + } + .task { + await globalLoading.withOptionalLoading(summary == nil) { + await viewModel.loadDetail(id: punchPointId, api: punchPointAPI) + } + } + } + + /// 详情头部。 + private func detailHeader(_ item: PunchPointItem) -> some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack { + Text(item.name) + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Spacer() + Text(item.statusLabel.isEmpty ? "状态 \(item.status)" : item.statusLabel) + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(AppDesign.primary) + .padding(.horizontal, AppMetrics.Spacing.small) + .padding(.vertical, AppMetrics.Spacing.xSmall) + .background(AppDesign.primarySoft, in: Capsule()) + } + Text(item.description.isEmpty ? "暂无描述" : item.description) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 图片区域。 + private func imageSection(_ item: PunchPointItem) -> some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + Text("打卡点图片") + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + if item.guideImages.isEmpty { + Text("暂无图片") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: AppMetrics.Spacing.small) { + ForEach(item.guideImages, id: \.self) { url in + RemoteImage(urlString: url) { + Image(systemName: "photo") + .foregroundStyle(AppDesign.placeholder) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(hex: 0xEEF2F6)) + } + .frame(width: 128, height: 92) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + } + } + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 基础信息区域。 + private func infoSection(_ item: PunchPointItem) -> some View { + VStack(spacing: AppMetrics.Spacing.small) { + PunchPointInfoRow(title: "地址", value: item.region?.address ?? "暂无") + PunchPointInfoRow(title: "坐标", value: coordinateText(for: item)) + PunchPointInfoRow(title: "创建时间", value: nonEmpty(item.createdAt) ?? "暂无") + PunchPointInfoRow(title: "创建人", value: nonEmpty(item.creator) ?? "暂无") + if !item.auditRemark.isEmpty { + PunchPointInfoRow(title: "审核备注", value: item.auditRemark) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 坐标展示文案。 + private func coordinateText(for item: PunchPointItem) -> String { + guard let region = item.region else { return "暂无" } + return "\(region.lat), \(region.lot)" + } + + /// 删除当前打卡点。 + private func deleteCurrentItem() async { + guard let item else { return } + let success = await globalLoading.withLoading { + await viewModel.delete(item, scenicId: accountContext.currentScenic?.id, api: punchPointAPI) + } + if success { + toastCenter.show("删除成功") + await scenicSpotContext.reload(scenicId: accountContext.currentScenic?.id, api: accountContextAPI) + router.path.removeLast() + } else { + toastCenter.show(viewModel.errorMessage ?? "删除失败") + } + } +} + +/// 打卡点编辑页面,用于新增或修改点位。 +struct PunchPointEditorView: View { + let punchPointId: Int? + + @Environment(AccountContext.self) private var accountContext + @Environment(AccountContextAPI.self) private var accountContextAPI + @Environment(PunchPointAPI.self) private var punchPointAPI + @Environment(OSSUploadService.self) private var uploadService + @Environment(ScenicSpotContext.self) private var scenicSpotContext + @Environment(ToastCenter.self) private var toastCenter + @Environment(\.dismiss) private var dismiss + @Environment(\.globalLoading) private var globalLoading + + @State private var detailLoader = PunchPointListViewModel() + @State private var viewModel = PunchPointEditorViewModel() + @State private var locationProvider = ForegroundLocationProvider() + @State private var selectedItems: [PhotosPickerItem] = [] + + var body: some View { + ScrollView { + VStack(spacing: AppMetrics.Spacing.medium) { + formSection + locationSection + imageSection + submitButton + } + .padding(.horizontal, AppMetrics.Spacing.pageHorizontal) + .padding(.vertical, AppMetrics.Spacing.medium) + } + .background(Color(hex: 0xF5F7FA)) + .navigationTitle(punchPointId == nil ? "新增打卡点" : "编辑打卡点") + .navigationBarTitleDisplayMode(.inline) + .task { + guard let punchPointId else { return } + await globalLoading.withOptionalLoading(detailLoader.selectedDetail == nil) { + await detailLoader.loadDetail(id: punchPointId, api: punchPointAPI) + if let detail = detailLoader.selectedDetail { + viewModel = PunchPointEditorViewModel(item: detail) + } + } + } + .onChange(of: selectedItems) { _, items in + Task { await loadPickedImages(items) } + } + } + + /// 表单区域。 + private var formSection: some View { + VStack(spacing: AppMetrics.Spacing.small) { + TextField("打卡点名称", text: $viewModel.name) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + TextField("打卡点描述", text: $viewModel.description, axis: .vertical) + .lineLimit(3, reservesSpace: true) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: 96) + TextField("打卡点展示名称", text: $viewModel.scenicSpotText) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 坐标和地址区域。 + private var locationSection: some View { + VStack(spacing: AppMetrics.Spacing.small) { + TextField("地址", text: $viewModel.address) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + HStack(spacing: AppMetrics.Spacing.small) { + TextField("纬度", text: $viewModel.latitudeText) + .keyboardType(.decimalPad) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + TextField("经度", text: $viewModel.longitudeText) + .keyboardType(.decimalPad) + .appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight) + } + Button { + Task { await locateForPunchPoint() } + } label: { + Label("使用当前位置", systemImage: "location") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 图片选择区域。 + private var imageSection: some View { + VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) { + HStack { + Text("打卡点图片") + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + Spacer() + PhotosPicker(selection: $selectedItems, maxSelectionCount: 9, matching: .images) { + Label("选择图片", systemImage: "photo.on.rectangle") + } + } + + LazyVGrid(columns: [GridItem(.adaptive(minimum: 92), spacing: AppMetrics.Spacing.small)], spacing: AppMetrics.Spacing.small) { + ForEach(viewModel.remoteImages, id: \.self) { url in + ZStack(alignment: .topTrailing) { + RemoteImage(urlString: url) { + Image(systemName: "photo") + .foregroundStyle(AppDesign.placeholder) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(hex: 0xEEF2F6)) + } + .frame(height: 92) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + imageRemoveButton { viewModel.removeRemoteImage(url) } + } + } + ForEach(viewModel.localImages) { image in + ZStack(alignment: .topTrailing) { + if let uiImage = UIImage(data: image.data) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .frame(height: 92) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + if image.progress > 0 && image.progress < 100 { + Text("\(image.progress)%") + .font(.system(size: AppMetrics.FontSize.caption, weight: .semibold)) + .foregroundStyle(.white) + .padding(4) + .background(.black.opacity(0.55), in: Capsule()) + .padding(4) + } + imageRemoveButton { viewModel.removeLocalImage(id: image.id) } + } + } + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } + + /// 提交按钮。 + private var submitButton: some View { + Button { + Task { await submit() } + } label: { + Text(viewModel.isSubmitting ? "提交中..." : "保存") + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .frame(maxWidth: .infinity) + .frame(height: AppMetrics.ControlSize.primaryButtonHeight) + } + .buttonStyle(.borderedProminent) + .disabled(viewModel.isSubmitting) + } + + /// 图片删除按钮。 + private func imageRemoveButton(action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.white, AppDesign.textSecondary) + .padding(4) + } + .buttonStyle(.plain) + } + + /// 使用当前位置回填点位。 + private func locateForPunchPoint() async { + do { + let result = try await locationProvider.requestCurrentLocation() + viewModel.applyLocation(latitude: result.latitude, longitude: result.longitude, address: result.address) + } catch { + toastCenter.show("定位失败:\(error.localizedDescription)") + } + } + + /// 读取 PhotosPicker 图片。 + private func loadPickedImages(_ items: [PhotosPickerItem]) async { + var images: [PunchPointLocalImage] = [] + for item in items { + guard let data = try? await item.loadTransferable(type: Data.self) else { continue } + images.append(PunchPointLocalImage(data: data, fileName: "punch_\(UUID().uuidString).jpg")) + } + viewModel.addLocalImages(images) + selectedItems = [] + } + + /// 提交表单。 + private func submit() async { + let success = await globalLoading.withLoading { + await viewModel.submit(scenicId: accountContext.currentScenic?.id, api: punchPointAPI, uploadService: uploadService) + } + if success { + toastCenter.show("保存成功") + await scenicSpotContext.reload(scenicId: accountContext.currentScenic?.id, api: accountContextAPI) + dismiss() + } else { + toastCenter.show(viewModel.errorMessage ?? "保存失败") + } + } +} + +/// 打卡点二维码页面。 +struct PunchPointQRView: View { + let title: String + let qrURL: String + + var body: some View { + VStack(spacing: AppMetrics.Spacing.large) { + Text(title) + .font(.system(size: AppMetrics.FontSize.title2, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + RemoteImage(urlString: qrURL, contentMode: .fit) { + Image(systemName: "qrcode") + .font(.system(size: 96, weight: .semibold)) + .foregroundStyle(AppDesign.placeholder) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(hex: 0xEEF2F6)) + } + .frame(width: 240, height: 240) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + Text(qrURL.isEmpty ? "暂无二维码" : qrURL) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, AppMetrics.Spacing.large) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(hex: 0xF5F7FA)) + .navigationTitle("打卡点二维码") + .navigationBarTitleDisplayMode(.inline) + } +} + +/// 打卡点卡片组件。 +private struct PunchPointCardView: View { + let item: PunchPointItem + + var body: some View { + HStack(spacing: AppMetrics.Spacing.medium) { + RemoteImage(urlString: item.guideImages.first) { + Image(systemName: "mappin.and.ellipse") + .foregroundStyle(AppDesign.primary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(AppDesign.primarySoft) + } + .frame(width: 82, height: 72) + .clipShape(RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + + VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) { + HStack { + Text(item.name) + .font(.system(size: AppMetrics.FontSize.body, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + .lineLimit(1) + Spacer() + Text(item.statusLabel.isEmpty ? "状态 \(item.status)" : item.statusLabel) + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.primary) + } + Text(item.region?.address ?? "暂无地址") + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + .lineLimit(2) + Text(nonEmpty(item.createdAt) ?? "暂无创建时间") + .font(.system(size: AppMetrics.FontSize.caption)) + .foregroundStyle(AppDesign.placeholder) + } + } + .padding(AppMetrics.Spacing.medium) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } +} + +/// 返回去除空白后的非空字符串。 +private func nonEmpty(_ text: String) -> String? { + let value = text.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value +} + +/// 打卡点信息行组件。 +private struct PunchPointInfoRow: View { + let title: String + let value: String + + var body: some View { + HStack(alignment: .top) { + Text(title) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + .frame(width: 76, alignment: .leading) + Text(value) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textPrimary) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + } + } +} + +/// 打卡点空状态组件。 +private struct PunchPointEmptyState: View { + let title: String + let message: String + + var body: some View { + VStack(spacing: AppMetrics.Spacing.small) { + Image(systemName: "mappin.slash") + .font(.system(size: 34, weight: .semibold)) + .foregroundStyle(AppDesign.placeholder) + Text(title) + .font(.system(size: AppMetrics.FontSize.title3, weight: .semibold)) + .foregroundStyle(AppDesign.textPrimary) + Text(message) + .font(.system(size: AppMetrics.FontSize.subheadline)) + .foregroundStyle(AppDesign.textSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(AppMetrics.Spacing.xLarge) + .background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)) + } +} diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index 870b321..5e51841 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -43,6 +43,9 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_trailer", title: ""), .destination(.albumTrailer)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report_history", title: ""), .destination(.locationReportHistory)) } /// 测试已知但未迁移的首页路由会进入安全占位。 @@ -168,9 +171,13 @@ final class HomeMenuRouterTests: XCTestCase { XCTAssertTrue(uris.contains("album_list")) XCTAssertTrue(uris.contains("wallet")) XCTAssertTrue(uris.contains("sample_management")) + XCTAssertTrue(uris.contains("checkin_points")) + XCTAssertTrue(uris.contains("location_report")) XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary)) XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList)) + XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport)) } #endif diff --git a/suixinkanTests/LocationReport/LocationReportTests.swift b/suixinkanTests/LocationReport/LocationReportTests.swift new file mode 100644 index 0000000..20143b7 --- /dev/null +++ b/suixinkanTests/LocationReport/LocationReportTests.swift @@ -0,0 +1,179 @@ +// +// LocationReportTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/24. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 位置上报 API 测试,覆盖旧工程接口路径、参数和宽松解码。 +final class LocationReportAPITests: XCTestCase { + /// 测试上报接口携带旧工程需要的 query 参数。 + func testReportLocationUsesExpectedQuery() async throws { + let session = LocationRecordingSession(data: Self.submitResponse) + let api = LocationReportAPI(client: APIClient(session: session)) + + let response = try await api.reportLocation(staffId: 77, latitude: 30.1, longitude: 120.2, address: "入口", type: .immediate, scenicId: 88) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/loacation/report") + let query = locationQueryItems(from: request) + XCTAssertEqual(query["staff_id"], "77") + XCTAssertEqual(query["latitude"], "30.1") + XCTAssertEqual(query["longitude"], "120.2") + XCTAssertEqual(query["address"], "入口") + XCTAssertEqual(query["type"], "1") + XCTAssertEqual(query["scenic_id"], "88") + XCTAssertEqual(response.expired, 7200) + } + + /// 测试历史接口支持类型、日期和分页参数。 + func testHistoryUsesExpectedQueryAndDecodesLossyFields() async throws { + let session = LocationRecordingSession(data: Self.historyResponse) + let api = LocationReportAPI(client: APIClient(session: session)) + + let payload = try await api.locationReportList(staffId: 77, page: 0, pageSize: 0, type: .marked, startDate: "2026-06-01", endDate: "2026-06-24") + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/loacation/list") + let query = locationQueryItems(from: request) + XCTAssertEqual(query["staff_id"], "77") + XCTAssertEqual(query["page"], "1") + XCTAssertEqual(query["page_size"], "1") + XCTAssertEqual(query["type"], "2") + XCTAssertEqual(query["start_date"], "2026-06-01") + XCTAssertEqual(query["end_date"], "2026-06-24") + XCTAssertEqual(payload.total, 1) + XCTAssertEqual(payload.list.first?.staffId, 77) + XCTAssertEqual(payload.list.first?.latitude, "30.1") + } + + private static let submitResponse = Data(#"{"code":100000,"msg":"ok","data":{"staff_id":77,"expired":"7200","status":"1"}}"#.utf8) + private static let historyResponse = Data(#"{"code":100000,"msg":"ok","data":{"total":"1","list":[{"id":"1","staff_id":"77","type":"2","latitude":30.1,"longitude":"120.2","address":"入口","ip":"127.0.0.1","remark":"ok","created_at":"2026-06-24 10:00:00"}]}}"#.utf8) +} + +@MainActor +/// 位置上报 ViewModel 测试,覆盖提交保护、类型和历史分页。 +final class LocationReportViewModelTests: XCTestCase { + /// 测试缺 staffId 或 scenicId 时禁止上报。 + func testSubmitRequiresStaffAndScenic() async { + let api = MockLocationReportService() + let viewModel = LocationReportViewModel() + viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") + + let missingStaff = await viewModel.submit(type: .immediate, staffId: nil, scenicId: 88, api: api) + let missingScenic = await viewModel.submit(type: .immediate, staffId: 77, scenicId: nil, api: api) + + XCTAssertFalse(missingStaff) + XCTAssertFalse(missingScenic) + XCTAssertEqual(api.reportRequests.count, 0) + } + + /// 测试立即上报、标记点上报和在线状态使用正确类型。 + func testSubmitUsesExpectedTypes() async { + let api = MockLocationReportService() + let viewModel = LocationReportViewModel() + viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") + viewModel.applyMarkedLocation(latitude: 31.1, longitude: 121.2, address: "标记点") + + _ = await viewModel.submit(type: .immediate, staffId: 77, scenicId: 88, api: api) + _ = await viewModel.submit(type: .marked, staffId: 77, scenicId: 88, api: api) + _ = await viewModel.setOnline(true, staffId: 77, scenicId: 88, api: api) + + XCTAssertEqual(api.reportRequests.map(\.type), [.immediate, .marked, .onlineStatus]) + XCTAssertEqual(api.reportRequests[1].latitude, 31.1) + XCTAssertEqual(viewModel.secondsUntilReport, 600) + } + + /// 测试上报失败保留当前状态并暴露错误。 + func testSubmitFailureKeepsState() async { + let api = MockLocationReportService() + api.shouldFailReport = true + let viewModel = LocationReportViewModel() + viewModel.applyCurrentLocation(latitude: 30.1, longitude: 120.2, address: "入口") + + let success = await viewModel.submit(type: .immediate, staffId: 77, scenicId: 88, api: api) + + XCTAssertFalse(success) + XCTAssertEqual(viewModel.secondsUntilReport, 0) + XCTAssertNotNil(viewModel.errorMessage) + } + + /// 测试历史筛选和分页请求正确。 + func testHistoryFilterAndPagination() async { + let api = MockLocationReportService() + api.historyPages = [ + ListPayload(total: 2, list: [LocationReportHistoryItem.fixture(id: 1)]), + ListPayload(total: 2, list: [LocationReportHistoryItem.fixture(id: 2)]) + ] + let viewModel = LocationReportHistoryViewModel() + viewModel.selectedType = .marked + + await viewModel.reload(staffId: 77, api: api) + await viewModel.loadMore(staffId: 77, api: api) + await viewModel.loadMore(staffId: 77, api: api) + + XCTAssertEqual(viewModel.items.map(\.id), [1, 2]) + XCTAssertEqual(api.historyRequests.map(\.page), [1, 2]) + XCTAssertEqual(api.historyRequests.first?.type, .marked) + } +} + +/// 位置上报 API 测试用 URLSession。 +private final class LocationRecordingSession: URLSessionProtocol { + let data: Data + private(set) var requests: [URLRequest] = [] + + /// 初始化测试 Session。 + init(data: Data) { + self.data = data + } + + /// 记录请求并返回成功响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (data, response) + } +} + +@MainActor +/// 位置上报服务测试替身。 +private final class MockLocationReportService: LocationReportServing { + var shouldFailReport = false + var reportRequests: [(staffId: Int, latitude: Double, longitude: Double, address: String, type: LocationReportType, scenicId: Int)] = [] + var historyPages: [ListPayload] = [ListPayload(total: 0, list: [])] + var historyRequests: [(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?)] = [] + + func reportLocation(staffId: Int, latitude: Double, longitude: Double, address: String, type: LocationReportType, scenicId: Int) async throws -> LocationReportSubmitResponse { + if shouldFailReport { throw NSError(domain: "location", code: 1) } + reportRequests.append((staffId, latitude, longitude, address, type, scenicId)) + return LocationReportSubmitResponse(staffId: "\(staffId)", expired: 600, status: 1) + } + + func locationReportList(staffId: Int, page: Int, pageSize: Int, type: LocationReportType, startDate: String?, endDate: String?) async throws -> ListPayload { + historyRequests.append((staffId, page, pageSize, type, startDate, endDate)) + return historyPages.isEmpty ? ListPayload(total: 0, list: []) : historyPages.removeFirst() + } +} + +private extension LocationReportHistoryItem { + /// 创建历史记录测试实体。 + static func fixture(id: Int) -> LocationReportHistoryItem { + let data = Data(#"{"id":\#(id),"staff_id":77,"type":1,"latitude":"30.1","longitude":"120.2","address":"入口","ip":"","remark":"","created_at":"2026-06-24"}"#.utf8) + return try! JSONDecoder().decode(LocationReportHistoryItem.self, from: data) + } +} + +/// 从请求中提取 query 字典。 +private func locationQueryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} diff --git a/suixinkanTests/PunchPoint/PunchPointAPITests.swift b/suixinkanTests/PunchPoint/PunchPointAPITests.swift new file mode 100644 index 0000000..b7368e6 --- /dev/null +++ b/suixinkanTests/PunchPoint/PunchPointAPITests.swift @@ -0,0 +1,232 @@ +// +// PunchPointAPITests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/24. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 打卡点 API 测试,覆盖旧工程接口路径、参数和宽松解码。 +final class PunchPointAPITests: XCTestCase { + /// 测试打卡点列表接口使用正确 path 和 query。 + func testPunchPointListUsesExpectedPathAndQuery() async throws { + let session = PunchPointRecordingSession(data: Self.listResponse) + let api = PunchPointAPI(client: APIClient(session: session)) + + let payload = try await api.punchPointList(scenicId: 88, status: -1, page: 0, pageSize: 0) + + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/scenic-spot/list") + let query = queryItems(from: request) + XCTAssertEqual(query["scenic_area_id"], "88") + XCTAssertEqual(query["status"], "0") + XCTAssertEqual(query["page"], "1") + XCTAssertEqual(query["page_size"], "1") + XCTAssertEqual(payload.total, 1) + XCTAssertEqual(payload.list.first?.id, 7) + XCTAssertEqual(payload.list.first?.region?.lat, 30.1) + } + + /// 测试详情、新增、编辑、删除接口路径和请求体。 + func testPunchPointMutationRequestsUseExpectedBodies() async throws { + let session = PunchPointRecordingSession(responses: [Self.infoResponse, Self.emptyResponse, Self.emptyResponse, Self.emptyResponse]) + let api = PunchPointAPI(client: APIClient(session: session)) + let region = PunchPointRegion(lat: 30.1, lot: 120.2, address: "入口", scenicSpotStr: "入口点") + + _ = try await api.punchPointInfo(id: 7) + try await api.addPunchPoint(AddPunchPointRequest(scenicAreaId: "88", name: "入口", description: "描述", region: region, scenicSpotStr: "入口点", guideImages: ["https://cdn/a.jpg"])) + try await api.editPunchPoint(EditPunchPointRequest(id: 7, scenicAreaId: "88", name: "入口2", description: "描述2", region: region, scenicSpotStr: "入口点", guideImages: [])) + try await api.deletePunchPoint(id: 7) + + XCTAssertEqual(session.requests.map { $0.url?.path }, [ + "/api/yf-handset-app/photog/scenic-spot/info", + "/api/yf-handset-app/photog/scenic-spot/add", + "/api/yf-handset-app/photog/scenic-spot/edit", + "/api/yf-handset-app/photog/scenic-spot/delete" + ]) + XCTAssertEqual(queryItems(from: session.requests[0])["id"], "7") + let addBody = try bodyObject(from: session.requests[1]) + XCTAssertEqual(addBody["scenic_area_id"] as? String, "88") + XCTAssertEqual(addBody["name"] as? String, "入口") + XCTAssertEqual((addBody["guide_imgs"] as? [String])?.first, "https://cdn/a.jpg") + let deleteBody = try bodyObject(from: session.requests[3]) + XCTAssertEqual(deleteBody["id"] as? Int, 7) + } + + private static let emptyResponse = Data(#"{"code":100000,"msg":"ok","data":{}}"#.utf8) + private static let listResponse = Data(#"{"code":100000,"msg":"ok","data":{"total":"1","list":[{"id":"7","scenic_area_id":"88","name":100,"status":"1","status_label":"运营中","description":"描述","region":{"lat":"30.1","lot":120.2,"address":"入口","scenic_spot_str":"入口点"},"scenic_spot_str":"入口点","guide_imgs":["https://cdn/a.jpg"],"mp_qrcode":"https://cdn/qr.png","created_at":"2026-06-24"}]}}"#.utf8) + private static let infoResponse = Data(#"{"code":100000,"msg":"ok","data":{"id":7,"name":"入口","status":1,"region":{"lat":30.1,"lot":"120.2","address":"入口"},"guide_imgs":[]}}"#.utf8) +} + +@MainActor +/// 打卡点 ViewModel 测试,覆盖分页、删除和图片上传提交。 +final class PunchPointViewModelTests: XCTestCase { + /// 测试无景区时清空列表且不请求接口。 + func testListClearsWhenScenicMissing() async { + let api = MockPunchPointService() + let viewModel = PunchPointListViewModel() + viewModel.items = [PunchPointItem(id: 1, name: "旧数据")] + + await viewModel.reload(scenicId: nil, api: api) + + XCTAssertTrue(viewModel.items.isEmpty) + XCTAssertEqual(api.listRequests.count, 0) + } + + /// 测试列表分页和最后一页停止请求。 + func testListPaginationStopsAtLastPage() async { + let api = MockPunchPointService() + api.pages = [ + ListPayload(total: 2, list: [PunchPointItem(id: 1, name: "A")]), + ListPayload(total: 2, list: [PunchPointItem(id: 2, name: "B")]) + ] + let viewModel = PunchPointListViewModel() + + await viewModel.reload(scenicId: 88, api: api) + await viewModel.loadMore(scenicId: 88, api: api) + await viewModel.loadMore(scenicId: 88, api: api) + + XCTAssertEqual(viewModel.items.map(\.id), [1, 2]) + XCTAssertEqual(api.listRequests.map(\.page), [1, 2]) + } + + /// 测试删除成功后刷新列表。 + func testDeleteRefreshesList() async { + let api = MockPunchPointService() + let viewModel = PunchPointListViewModel() + + let success = await viewModel.delete(PunchPointItem(id: 9, name: "A"), scenicId: 88, api: api) + + XCTAssertTrue(success) + XCTAssertEqual(api.deletedIds, [9]) + XCTAssertEqual(api.listRequests.count, 1) + } + + /// 测试新建打卡点会先上传本地图片,再提交最终 OSS URL。 + func testEditorUploadsImagesBeforeSubmit() async { + let api = MockPunchPointService() + let uploader = MockPunchPointUploader() + let viewModel = PunchPointEditorViewModel() + viewModel.name = "入口" + viewModel.applyLocation(latitude: 30.1, longitude: 120.2, address: "入口") + viewModel.addLocalImages([PunchPointLocalImage(data: Data([1]), fileName: "a.jpg")]) + + let success = await viewModel.submit(scenicId: 88, api: api, uploadService: uploader) + + XCTAssertTrue(success) + XCTAssertEqual(uploader.uploadedFileNames, ["a.jpg"]) + XCTAssertEqual(api.addRequests.first?.guideImages.first, "https://cdn.example.com/a.jpg") + } + + /// 测试上传失败时不提交打卡点接口。 + func testEditorUploadFailureDoesNotSubmit() async { + let api = MockPunchPointService() + let uploader = MockPunchPointUploader() + uploader.shouldFail = true + let viewModel = PunchPointEditorViewModel() + viewModel.name = "入口" + viewModel.applyLocation(latitude: 30.1, longitude: 120.2, address: "入口") + viewModel.addLocalImages([PunchPointLocalImage(data: Data([1]), fileName: "a.jpg")]) + + let success = await viewModel.submit(scenicId: 88, api: api, uploadService: uploader) + + XCTAssertFalse(success) + XCTAssertEqual(api.addRequests.count, 0) + } +} + +/// 打卡点 API 测试用 URLSession。 +private final class PunchPointRecordingSession: URLSessionProtocol { + private var responses: [Data] + private(set) var requests: [URLRequest] = [] + + /// 初始化测试 Session。 + init(data: Data) { + responses = [data] + } + + /// 初始化多个响应的测试 Session。 + init(responses: [Data]) { + self.responses = responses + } + + /// 记录请求并返回响应。 + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + requests.append(request) + let data = responses.isEmpty ? Data(#"{"code":100000,"data":{}}"#.utf8) : responses.removeFirst() + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (data, response) + } +} + +@MainActor +/// 打卡点服务测试替身。 +private final class MockPunchPointService: PunchPointServing { + var pages: [ListPayload] = [ListPayload(total: 0, list: [])] + var listRequests: [(scenicId: Int, status: Int, page: Int, pageSize: Int)] = [] + var addRequests: [AddPunchPointRequest] = [] + var editRequests: [EditPunchPointRequest] = [] + var deletedIds: [Int] = [] + + func punchPointList(scenicId: Int, status: Int, page: Int, pageSize: Int) async throws -> ListPayload { + listRequests.append((scenicId, status, page, pageSize)) + return pages.isEmpty ? ListPayload(total: 0, list: []) : pages.removeFirst() + } + + func punchPointInfo(id: Int) async throws -> PunchPointItem { + PunchPointItem(id: id, name: "详情") + } + + func addPunchPoint(_ request: AddPunchPointRequest) async throws { + addRequests.append(request) + } + + func editPunchPoint(_ request: EditPunchPointRequest) async throws { + editRequests.append(request) + } + + func deletePunchPoint(id: Int) async throws { + deletedIds.append(id) + } +} + +@MainActor +/// OSS 上传测试替身。 +private final class MockPunchPointUploader: OSSUploadServing { + var shouldFail = false + var uploadedFileNames: [String] = [] + + func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { + if shouldFail { throw NSError(domain: "upload", code: 1) } + uploadedFileNames.append(fileName) + onProgress(100) + return "https://cdn.example.com/\(fileName)" + } + + func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } + func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } +} + +/// 从请求中提取 query 字典。 +private func queryItems(from request: URLRequest) -> [String: String] { + guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return [:] + } + return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in + item.value.map { (item.name, $0) } + }) +} + +/// 从请求体中解析 JSON 字典。 +private func bodyObject(from request: URLRequest) throws -> [String: Any] { + let body = try XCTUnwrap(request.httpBody) + return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) +} diff --git a/功能同步Checklist.md b/功能同步Checklist.md index 0f4e887..32ebc7d 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -30,7 +30,7 @@ | 完成 | 登录页 | 登录 UI、手机号/密码登录、协议勾选、上次手机号恢复。 | 后续如有验证码登录再迁移。 | | 完成 | 多账号选择 | 登录后多账号选择、临时 token 内存保存、选择后写正式 token。 | 继续跟随后端账号结构变化维护。 | | 完成 | 账号上下文 | 用户信息、角色权限、景区列表、门店列表、当前景区/门店选择和恢复。 | 后续业务模块按当前上下文取数。 | -| 完成 | 景点/打卡点上下文 | 当前景区下景点/打卡点懒加载,失败不影响登录态。 | 具体打卡点管理页面尚未迁移。 | +| 完成 | 景点/打卡点上下文 | 当前景区下景点/打卡点懒加载,失败不影响登录态。 | 打卡点管理页面已接入,后续可继续增强地图交互。 | | 完成 | 权限菜单数据 | 递归解析角色权限、URI 去重、按旧工程顺序排序。 | 新 URI 出现时补路由映射或标记不支持。 | ## 首页 @@ -91,7 +91,7 @@ | 完成 | 任务管理 | 任务列表、筛选、分页、任务详情、发布任务入口。 | 旧工程里订单/核销订单拼成待办任务的逻辑本轮未迁移,订单仍归订单 Tab 管理。 | | 完成 | 发布任务 | 发布表单、选择订单、选择云盘文件、本地图片/视频 OSS 上传、提交任务。 | 完整云盘资产管理已由相册云盘模块接管,任务页只保留发布所需选择能力。 | | 部分完成 | 日程管理 | 入口已识别,进入占位页。 | 迁移日程列表、日历和详情。 | -| 部分完成 | 打卡点管理 | 入口已识别,景点上下文已具备。 | 迁移打卡点列表、创建/编辑、图片上传。 | +| 完成 | 打卡点管理 | 列表、筛选、分页、详情、二维码、新建、编辑、删除、前台定位选点和图片 OSS 上传已接入。 | 后续可把高德地图完整选点 UI 替换进编辑页。 | | 部分完成 | 项目管理 | `pm` / `pm_manager` / `project_edit` 已识别,进入占位页。 | 迁移项目列表、详情、编辑、新建。 | | 完成 | 相册云盘 | 云盘文件/文件夹浏览、搜索筛选、排序、分页、上传、预览、下载、重命名、移动、删除、传输记录。 | 云盘下载只保存到 App 沙盒 `Documents/CloudDownloads`,不写入系统相册。 | | 完成 | 相册管理 | 相册列表、搜索筛选、日期筛选、创建相册、相册详情、图片/视频列表、预览、设置封面、删除文件、编辑名称/备注。 | 后续如旧工程补充更多相册运营动作再继续迁移。 | @@ -103,7 +103,7 @@ | 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 | | 部分完成 | 景区结算 | 入口已识别,进入占位页。 | 迁移结算列表、审核、详情。 | | 部分完成 | 运营区域 | 入口已识别,进入占位页。 | 迁移区域列表、地图/范围配置。 | -| 部分完成 | 位置上报 | 首页已有位置上报卡;具体页面入口进入占位。 | 迁移上报提交、历史记录、定位权限。 | +| 完成 | 位置上报 | 当前位置、标记点、在线状态、立即上报、提醒设置、历史记录、筛选和分页已接入。 | 本轮不做后台定位、离线队列或推送提醒。 | | 部分完成 | 注册邀请 | 入口已识别,进入占位页。 | 迁移邀请摄影师、邀请记录。 | | 部分完成 | 飞手认证 | 入口已识别,进入占位页。 | 迁移飞手认证页面。 | | 不迁移 | 飞控/DJI | `fly` / `pilot_controller` 已明确为 iOS 不支持。 | 不再迁移。 | @@ -130,7 +130,7 @@ xcodebuild build -workspace suixinkan.xcworkspace -scheme suixinkan -destination ## 下一批建议迁移顺序 -1. 打卡点管理/位置上报:依赖当前景区和景点上下文,当前上下文已经稳定。 +1. 项目管理/排班/邀请:依赖当前景区和角色权限,能继续按首页入口逐个迁移。 2. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾。 3. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试。 4. 项目管理:样片上传已具备轻量项目选择,后续可迁移完整项目列表、详情和编辑。