Files
suixinkan_ios_new/suixinkan/Features/ScenicPermission/Views/ScenicApplicationView.swift
汉秋 b61bf0d070 新增景区权限模块,集成 AMap CocoaPods 并支持模拟器
将景区选择与权限申请流程接入首页路由,集成高德 SDK 用于真机构建,并通过 Podfile post_install 钩子保证 arm64 模拟器可编译。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 10:15:26 +08:00

533 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// ScenicApplicationView.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import PhotosUI
import SwiftUI
///
struct ScenicApplicationView: View {
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
@Environment(OSSUploadService.self) private var ossUploadService
@State private var viewModel = ScenicApplicationViewModel()
@State private var pickedImageItems: [PhotosPickerItem] = []
@State private var activeLocationPicker: ScenicLocationPicker?
var body: some View {
VStack(spacing: 0) {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
if viewModel.loadFailed {
ContentUnavailableView(
"景区申请初始化失败",
systemImage: "exclamationmark.triangle",
description: Text(viewModel.loadFailureReason ?? "请检查网络后重试")
)
Button("重新加载") {
Task { await viewModel.loadInitial(api: scenicPermissionAPI) }
}
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
.buttonStyle(.plain)
} else {
warmTip
formSection
}
}
.padding(AppMetrics.Spacing.medium)
}
if !viewModel.loadFailed {
agreementSection
}
}
.background(Color(hex: 0xF4F4F4).ignoresSafeArea())
.navigationTitle("景区申请")
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.loadInitial(api: scenicPermissionAPI)
}
.onChange(of: pickedImageItems) { _, items in
guard !items.isEmpty else { return }
Task {
await importPickedImages(items)
pickedImageItems = []
}
}
.sheet(item: $activeLocationPicker) { picker in
ScenicLocationSelectionSheet(
picker: picker,
provinces: viewModel.provinces,
cities: viewModel.cities,
selectedProvince: viewModel.selectedProvince,
selectedCity: viewModel.selectedCity,
onSelectProvince: { item in
viewModel.selectedProvince = item.name
viewModel.onProvinceChange()
activeLocationPicker = nil
},
onSelectCity: { item in
viewModel.selectedCity = item.name
activeLocationPicker = nil
}
)
.presentationDetents([.medium, .large])
}
.alert("提示", isPresented: Binding(
get: { viewModel.message != nil },
set: { if !$0 { viewModel.message = nil } }
)) {
Button("知道了", role: .cancel) {}
} message: {
Text(viewModel.message ?? "")
}
}
private var warmTip: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Text("温馨提示")
.font(.system(size: AppMetrics.FontSize.body, weight: .bold))
Text("如果你想入驻更多的景区,平台可以帮你跟景区官方谈合作;如果已经有的景区,只要符合入驻条件即可申请。")
.font(.system(size: AppMetrics.FontSize.subheadline))
.lineSpacing(AppMetrics.LineSpacing.title)
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.medium)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
}
private var formSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.large) {
if let pending = viewModel.pending {
pendingStatusCard(pending)
}
if viewModel.pendingLoadFailed && viewModel.pending == nil {
pendingFailureRow
}
inputField("景区名称", text: $viewModel.scenicName, disabled: viewModel.isReadOnly)
uploadSection
locationSection
coopTypeSection
inputField("备注信息 (\(viewModel.remark.count)/50)", text: $viewModel.remark, axis: .vertical, disabled: viewModel.isReadOnly)
.onChange(of: viewModel.remark) { _, value in
if value.count > 50 {
viewModel.remark = String(value.prefix(50))
}
}
if let pending = viewModel.pending, pending.status == 3 {
Text("驳回原因:\(pending.auditNote ?? pending.rejectReason ?? "--")")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(Color(hex: 0xDC2626))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.small)
.background(Color(hex: 0xFEF2F2), in: RoundedRectangle(cornerRadius: 8))
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
private var pendingFailureRow: some View {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(AppDesign.warning)
Text("待审核记录加载失败,暂未展示历史申请状态")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
Spacer()
Button("重试") {
Task { await viewModel.reloadPending(api: scenicPermissionAPI) }
}
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.buttonStyle(.plain)
}
.padding(AppMetrics.Spacing.small)
.background(Color(hex: 0xFFF7ED), in: RoundedRectangle(cornerRadius: 8))
}
private var uploadSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack {
Text("景区图片")
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
Spacer()
Text("\(viewModel.uploadPlaceholderCount)/20")
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
}
LazyVGrid(columns: [GridItem(.adaptive(minimum: 92), spacing: AppMetrics.Spacing.xSmall)], spacing: AppMetrics.Spacing.xSmall) {
ForEach(viewModel.remoteImageURLs, id: \.self) { url in
scenicImageCell(url: url, local: nil, progress: nil) {
viewModel.removeRemoteImage(url)
}
}
ForEach(viewModel.localImages) { image in
scenicImageCell(url: image.uploadedURL, local: image.data, progress: image.uploading ? image.progress : nil) {
viewModel.removeLocalImage(id: image.id)
}
}
if viewModel.uploadPlaceholderCount < 20 && !viewModel.isReadOnly {
PhotosPicker(
selection: $pickedImageItems,
maxSelectionCount: max(0, 20 - viewModel.uploadPlaceholderCount),
matching: .images
) {
VStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: "plus")
.font(.system(size: AppMetrics.FontSize.title, weight: .semibold))
Text("上传图片")
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
}
.foregroundStyle(AppDesign.primary)
.frame(height: 92)
.frame(maxWidth: .infinity)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 10))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(AppDesign.primary.opacity(0.25), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
}
}
.buttonStyle(.plain)
}
}
}
}
private var locationSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Text("景区位置")
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
HStack(spacing: AppMetrics.Spacing.small) {
Button {
activeLocationPicker = .province
} label: {
pickerBox(title: viewModel.selectedProvince.isEmpty ? "省份" : viewModel.selectedProvince)
}
.buttonStyle(.plain)
.disabled(viewModel.isReadOnly)
Button {
guard !viewModel.selectedProvince.isEmpty else {
viewModel.message = "请先选择省份"
return
}
activeLocationPicker = .city
} label: {
pickerBox(title: viewModel.selectedCity.isEmpty ? "城市" : viewModel.selectedCity)
}
.buttonStyle(.plain)
.disabled(viewModel.isReadOnly)
}
}
}
private var coopTypeSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("合作类型")
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
VStack(spacing: AppMetrics.Spacing.xSmall) {
coopButton(title: "我是摄影师,我想入驻", tag: 1)
coopButton(title: "我是景区资源方,可以合作", tag: 2)
coopButton(title: "我有摄影师团队,带队入驻", tag: 3)
coopButton(title: "我是旅拍店,想合作", tag: 4)
coopButton(title: "我已经在景区运营旅拍了,想使用软件,提高效率", tag: 5)
}
}
}
private var agreementSection: some View {
VStack(spacing: AppMetrics.Spacing.small) {
HStack(spacing: AppMetrics.Spacing.small) {
Button {
viewModel.agreed.toggle()
} label: {
Image(systemName: viewModel.agreed ? "checkmark.square.fill" : "square")
.font(.system(size: AppMetrics.ControlSize.passwordIcon))
.foregroundStyle(viewModel.agreed ? AppDesign.primary : AppDesign.textSecondary)
}
.buttonStyle(.plain)
.disabled(viewModel.isReadOnly)
Text("已阅读并同意《用户须知》与《隐私政策》")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(2)
Spacer()
}
Button(viewModel.submitting ? "提交中..." : "确认提交") {
Task { await viewModel.submit(api: scenicPermissionAPI, uploader: ossUploadService) }
}
.disabled(viewModel.isSubmitLocked)
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 46)
.background(viewModel.canSubmit ? AppDesign.primary : Color(hex: 0xD8D8D8), in: RoundedRectangle(cornerRadius: 8))
}
.padding(AppMetrics.Spacing.medium)
.background(.white)
}
///
private func inputField(_ title: String, text: Binding<String>, axis: Axis = .horizontal, disabled: Bool = false) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text(title)
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
TextField("", text: text, prompt: Text("请输入\(title)").foregroundColor(AppDesign.placeholder), axis: axis)
.disabled(disabled)
.font(.system(size: AppMetrics.FontSize.subheadline))
.lineLimit(axis == .vertical ? 3...5 : 1...1)
.appInputFieldStyle(cornerRadius: 8, minHeight: axis == .vertical ? 100 : 50)
}
}
///
private func pickerBox(title: String) -> some View {
HStack {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(title == "省份" || title == "城市" ? AppDesign.placeholder : AppDesign.textPrimary)
.lineLimit(1)
Spacer()
Image(systemName: "chevron.down")
.foregroundStyle(AppDesign.textSecondary)
}
.padding(.horizontal, AppMetrics.Spacing.substantial)
.frame(height: 50)
.background(Color(hex: 0xFBFCFE), in: RoundedRectangle(cornerRadius: 10))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(Color(hex: 0xE2E8F0), lineWidth: 1)
}
}
///
private func coopButton(title: String, tag: Int) -> some View {
Button {
viewModel.coopType = tag
} label: {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(viewModel.coopType == tag ? .white : AppDesign.textSecondary)
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(minHeight: tag == 5 ? 70 : 48)
.background(viewModel.coopType == tag ? AppDesign.primary : .white, in: RoundedRectangle(cornerRadius: 8))
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(viewModel.coopType == tag ? AppDesign.primary : Color(hex: 0xE2E8F0), lineWidth: 1)
}
}
.buttonStyle(.plain)
.disabled(viewModel.isReadOnly)
}
///
private func pendingStatusCard(_ pending: ScenicApplicationPendingResponse) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Label(statusText(pending.status), systemImage: statusIcon(pending.status))
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(statusColor(pending.status))
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 28)
.background(statusColor(pending.status).opacity(0.12), in: Capsule())
metaRow("申请编号", pending.code.isEmpty ? "--" : pending.code)
metaRow("提交时间", pending.createdAt.isEmpty ? "--" : pending.createdAt)
}
.padding(AppMetrics.Spacing.small)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
}
///
private func scenicImageCell(url: String?, local data: Data?, progress: Int?, onDelete: @escaping () -> Void) -> some View {
ZStack(alignment: .topTrailing) {
ZStack {
if let data, let image = UIImage(data: data) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else if let url, !url.isEmpty {
RemoteImage(urlString: url) {
imagePlaceholder
}
} else {
imagePlaceholder
}
if let progress {
Color.black.opacity(0.38)
ProgressView(value: Double(progress), total: 100)
.tint(.white)
.padding(AppMetrics.Spacing.xSmall)
}
}
.frame(height: 92)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 10))
if !viewModel.isReadOnly {
Button(action: onDelete) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: AppMetrics.FontSize.title2, weight: .semibold))
.foregroundStyle(.white, Color.black.opacity(0.55))
.padding(AppMetrics.Spacing.xxSmall)
}
.buttonStyle(.plain)
}
}
}
private var imagePlaceholder: some View {
ZStack {
Color(hex: 0xF1F5F9)
Image(systemName: "photo.fill")
.font(.system(size: AppMetrics.FontSize.title, weight: .semibold))
.foregroundStyle(Color(hex: 0x94A3B8))
}
}
///
private func importPickedImages(_ items: [PhotosPickerItem]) async {
for item in items {
guard viewModel.uploadPlaceholderCount < 20 else { return }
do {
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
viewModel.addLocalImage(data: data, fileName: "scenic_\(UUID().uuidString).jpg")
} catch {
viewModel.message = error.localizedDescription
}
}
}
private func metaRow(_ title: String, _ value: String) -> some View {
HStack {
Text("\(title)")
.foregroundStyle(AppDesign.textSecondary)
Text(value)
.foregroundStyle(AppDesign.textPrimary)
Spacer()
}
.font(.system(size: AppMetrics.FontSize.caption))
}
private func statusText(_ status: Int) -> String {
switch status {
case 1: return "待审核"
case 2: return "已通过"
case 3: return "已驳回"
case 9: return "已取消"
default: return "未提交"
}
}
private func statusIcon(_ status: Int) -> String {
switch status {
case 2: return "checkmark.circle.fill"
case 3: return "xmark.octagon.fill"
case 1: return "clock.badge.exclamationmark.fill"
default: return "questionmark.circle.fill"
}
}
private func statusColor(_ status: Int) -> Color {
switch status {
case 2: return AppDesign.success
case 3: return Color(hex: 0xDC2626)
case 1: return AppDesign.warning
default: return AppDesign.textSecondary
}
}
}
///
private enum ScenicLocationPicker: Identifiable {
case province
case city
var id: String {
switch self {
case .province: "province"
case .city: "city"
}
}
var title: String {
switch self {
case .province: "选择省份"
case .city: "选择城市"
}
}
}
///
private struct ScenicLocationSelectionSheet: View {
@Environment(\.dismiss) private var dismiss
let picker: ScenicLocationPicker
let provinces: [ScenicAreaNode]
let cities: [ScenicAreaNode]
let selectedProvince: String
let selectedCity: String
let onSelectProvince: (ScenicAreaNode) -> Void
let onSelectCity: (ScenicAreaNode) -> Void
private var items: [ScenicAreaNode] {
picker == .province ? provinces : cities
}
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: AppMetrics.Spacing.xSmall) {
ForEach(items) { item in
Button {
switch picker {
case .province:
onSelectProvince(item)
case .city:
onSelectCity(item)
}
dismiss()
} label: {
HStack(spacing: AppMetrics.Spacing.small) {
Text(item.name)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: isSelected(item) ? .semibold : .regular))
.foregroundStyle(AppDesign.textPrimary)
Spacer()
if isSelected(item) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(AppDesign.primary)
}
}
.padding(.horizontal, AppMetrics.Spacing.substantial)
.frame(height: 48)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.navigationTitle(picker.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("关闭") { dismiss() }
}
}
}
}
///
private func isSelected(_ item: ScenicAreaNode) -> Bool {
switch picker {
case .province:
item.name == selectedProvince
case .city:
item.name == selectedCity
}
}
}