新增打卡点与位置上报模块,并接入首页路由

将打卡点管理与位置上报从占位页迁移为完整 MVVM 流程,包含前台定位支持与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 11:08:41 +08:00
parent 403a3eefa6
commit abcac9bfdf
22 changed files with 2698 additions and 12 deletions

View File

@ -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 的映射,并同步补充单元测试。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 和路由测试。测试失败时先修复问题,再继续迁移后续功能。

View File

@ -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 ""
}
/// StringDouble 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
}
}

View File

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

View File

@ -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<Date?>) -> 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))
}
}

View File

@ -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<PunchPointItem>
///
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<PunchPointItem> {
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
}
}

View File

@ -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 ""
}
/// StringDouble 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
}
/// StringDouble 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
}
}

View File

@ -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 和路由测试。测试失败时先修复问题,再继续迁移后续功能。

View File

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

View File

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