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 <cursoragent@cursor.com>
This commit is contained in:
@ -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 的映射,并同步补充单元测试。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
39
suixinkan/Features/LocationReport/LocationReport.md
Normal file
39
suixinkan/Features/LocationReport/LocationReport.md
Normal 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 和路由测试。测试失败时先修复问题,再继续迁移后续功能。
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}()
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
88
suixinkan/Features/PunchPoint/API/PunchPointAPI.swift
Normal file
88
suixinkan/Features/PunchPoint/API/PunchPointAPI.swift
Normal 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
|
||||
}
|
||||
}
|
||||
252
suixinkan/Features/PunchPoint/Models/PunchPointModels.swift
Normal file
252
suixinkan/Features/PunchPoint/Models/PunchPointModels.swift
Normal 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 ""
|
||||
}
|
||||
|
||||
/// 将 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
|
||||
}
|
||||
}
|
||||
37
suixinkan/Features/PunchPoint/PunchPoint.md
Normal file
37
suixinkan/Features/PunchPoint/PunchPoint.md
Normal 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 和路由测试。测试失败时先修复问题,再继续迁移后续功能。
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
592
suixinkan/Features/PunchPoint/Views/PunchPointViews.swift
Normal file
592
suixinkan/Features/PunchPoint/Views/PunchPointViews.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user