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:
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