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

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