// // PunchPointViews.swift // suixinkan // // Created by Codex on 2026/6/24. // import PhotosUI import SwiftUI import UIKit /// 打卡点列表页面,展示当前景区下的打卡点并提供新增入口。 struct PunchPointListView: View { @EnvironmentObject private var accountContext: AccountContext @Environment(\.accountContextAPI) private var accountContextAPI @Environment(\.punchPointAPI) private var punchPointAPI @EnvironmentObject private var router: RouterPath @EnvironmentObject private var scenicSpotContext: ScenicSpotContext @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading @StateObject 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? @EnvironmentObject private var accountContext: AccountContext @Environment(\.accountContextAPI) private var accountContextAPI @Environment(\.punchPointAPI) private var punchPointAPI @EnvironmentObject private var router: RouterPath @EnvironmentObject private var scenicSpotContext: ScenicSpotContext @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.globalLoading) private var globalLoading @StateObject 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? @EnvironmentObject private var accountContext: AccountContext @Environment(\.accountContextAPI) private var accountContextAPI @Environment(\.punchPointAPI) private var punchPointAPI @Environment(\.ossUploadService) private var uploadService @EnvironmentObject private var scenicSpotContext: ScenicSpotContext @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss @Environment(\.globalLoading) private var globalLoading @StateObject private var detailLoader = PunchPointListViewModel() @StateObject 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.apply(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)) } }