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

将打卡点管理与位置上报从占位页迁移为完整 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

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