新增景区权限模块,集成 AMap CocoaPods 并支持模拟器

将景区选择与权限申请流程接入首页路由,集成高德 SDK 用于真机构建,并通过 Podfile post_install 钩子保证 arm64 模拟器可编译。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 10:15:26 +08:00
parent 5d4613f317
commit b61bf0d070
416 changed files with 18641 additions and 38 deletions

View File

@ -0,0 +1,189 @@
//
// PermissionApplyStatusView.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import SwiftUI
///
struct PermissionApplyStatusView: View {
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
@State private var viewModel = PermissionApplyStatusViewModel()
@State private var editingPending: RoleApplyPendingResponse?
let applyCode: String?
///
init(applyCode: String? = nil) {
self.applyCode = applyCode
}
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.small) {
if let pending = viewModel.pending {
statusCard(pending)
roleCard(pending)
scenicCard(pending)
} else if viewModel.loadFailed {
ContentUnavailableView(
"申请状态加载失败",
systemImage: "exclamationmark.triangle",
description: Text(viewModel.loadFailureReason ?? "请检查网络后重试")
)
Button("重新加载") {
Task { await viewModel.load(api: scenicPermissionAPI, applyCode: applyCode) }
}
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
.buttonStyle(.plain)
} else {
ContentUnavailableView("暂无审核中的申请", systemImage: "doc.text.magnifyingglass")
}
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.navigationTitle("权限申请")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if let pending = viewModel.pending, pending.status == 3 {
ToolbarItem(placement: .topBarTrailing) {
Button {
editingPending = pending
} label: {
Label("去编辑", systemImage: "pencil")
}
}
}
}
.navigationDestination(item: $editingPending) { pending in
PermissionApplyView(initialPending: pending)
}
.task {
await viewModel.load(api: scenicPermissionAPI, applyCode: applyCode)
}
}
///
private func statusCard(_ pending: RoleApplyPendingResponse) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack {
Label(statusLabel(pending), systemImage: statusIcon(pending.status))
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(statusColor(pending.status))
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 28)
.background(statusColor(pending.status).opacity(0.12), in: Capsule())
Spacer()
}
row("申请编号", pending.code)
row("提交时间", pending.createdAt)
row("审核时间", pending.auditedAt ?? "--")
row("审核人", pending.auditedBy ?? "--")
row("审核备注", pending.auditNote ?? "--")
}
.padding(AppMetrics.Spacing.small)
.background(statusColor(pending.status).opacity(0.08), in: RoundedRectangle(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12).stroke(statusColor(pending.status).opacity(0.35), lineWidth: 1)
}
}
///
private func roleCard(_ pending: RoleApplyPendingResponse) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Text("新增角色")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
Text(pending.roleName)
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
.foregroundStyle(AppDesign.primary)
.padding(.horizontal, AppMetrics.Spacing.small)
.padding(.vertical, AppMetrics.Spacing.xSmall)
.background(AppDesign.primarySoft, in: Capsule())
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
///
private func scenicCard(_ pending: RoleApplyPendingResponse) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack {
Text("新增景区")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
Spacer()
Text("\(pending.scenicList.count)")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
if pending.scenicList.isEmpty {
Text("未选择景区")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 90), spacing: AppMetrics.Spacing.xSmall)], alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
ForEach(pending.scenicList) { scenic in
Text(scenic.name)
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.primary)
.padding(.horizontal, AppMetrics.Spacing.small)
.padding(.vertical, AppMetrics.Spacing.xSmall)
.background(AppDesign.primarySoft, in: Capsule())
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.small)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
private func row(_ title: String, _ value: String?) -> some View {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Text("\(title)")
.foregroundStyle(AppDesign.textSecondary)
Spacer()
Text(nonEmpty(value) ?? "--")
.foregroundStyle(AppDesign.textPrimary)
.multilineTextAlignment(.trailing)
}
.font(.system(size: AppMetrics.FontSize.footnote))
}
private func statusLabel(_ pending: RoleApplyPendingResponse) -> String {
nonEmpty(pending.statusLabel) ?? {
switch pending.status {
case 2: return "已通过"
case 3: return "已驳回"
case 1: 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 func nonEmpty(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed
}
}

View File

@ -0,0 +1,414 @@
//
// PermissionApplyView.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import SwiftUI
///
struct PermissionApplyView: View {
@Environment(PermissionContext.self) private var permissionContext
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
@State private var viewModel: PermissionApplyViewModel
@State private var activePicker: PermissionPickerSheet?
///
init(initialPending: RoleApplyPendingResponse? = nil) {
_viewModel = State(initialValue: PermissionApplyViewModel(initialPending: initialPending))
}
var body: some View {
VStack(spacing: 0) {
NavigationLink(value: AppRoute.home(.scenicApplication)) {
NewScenicBanner()
}
.buttonStyle(.plain)
ScrollView {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.large) {
roleSection
scenicSection
if !viewModel.existingRoleInfos.isEmpty {
existingRoleSection
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
.padding(AppMetrics.Spacing.medium)
}
submitBar
}
.background(Color(hex: 0xF4F4F4).ignoresSafeArea())
.navigationTitle("申请权限")
.navigationBarTitleDisplayMode(.inline)
.task {
viewModel.bootstrap(rolePermissions: permissionContext.rolePermissions)
await viewModel.loadScenicListIfNeeded(api: scenicPermissionAPI)
}
.sheet(item: $activePicker) { picker in
PermissionPickerSheetView(
picker: picker,
roleOptions: viewModel.roleOptions,
selectedRoleId: viewModel.selectedRoleId,
scenicOptions: viewModel.scenicOptions,
selectedCount: viewModel.selectedCount,
onSelectRole: { role in
if viewModel.selectedRoleId != role.id {
viewModel.selectRole(id: role.id)
Task { await viewModel.loadScenicListIfNeeded(api: scenicPermissionAPI, force: true) }
}
activePicker = nil
},
onToggleScenic: { scenic in
viewModel.toggleScenic(id: scenic.id)
}
)
.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 roleSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack {
Text("角色信息")
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
Spacer()
Text("单选")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Text("请选择您需要申请权限的角色")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.placeholder)
pickerDisplay(
title: viewModel.selectedRoleName ?? "请选择角色",
subtitle: "点击选择申请角色",
isPlaceholder: viewModel.selectedRoleId == nil,
isDisabled: viewModel.roleOptions.isEmpty
) {
guard !viewModel.roleOptions.isEmpty else { return }
activePicker = .role
}
if let notes = viewModel.selectedRoleNotes, !notes.isEmpty {
Text(notes)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
.padding(AppMetrics.Spacing.small)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 10))
}
}
}
private var scenicSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack {
Text("选择景区")
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
Spacer()
Text("可多选")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Text("请选择您需要申请权限的景区")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.placeholder)
pickerDisplay(
title: viewModel.loadingScenics ? "加载景区中..." : viewModel.selectedScenicSummary,
subtitle: viewModel.selectedRoleId == nil ? "请先选择角色" : "点击选择一个或多个景区",
isPlaceholder: viewModel.selectedCount == 0,
isDisabled: viewModel.selectedRoleId == nil || viewModel.loadingScenics || viewModel.scenicOptions.isEmpty
) {
guard viewModel.selectedRoleId != nil else {
viewModel.message = "请先选择角色"
return
}
guard !viewModel.loadingScenics, !viewModel.scenicOptions.isEmpty else { return }
activePicker = .scenic
}
if viewModel.scenicLoadFailed && viewModel.scenicOptions.isEmpty {
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(AppDesign.warning)
Text("景区列表加载失败")
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textSecondary)
Spacer()
Button("重新加载") {
Task { await viewModel.loadScenicListIfNeeded(api: scenicPermissionAPI, force: true) }
}
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
.buttonStyle(.plain)
}
.padding(AppMetrics.Spacing.small)
.background(Color(hex: 0xFFF7ED), in: RoundedRectangle(cornerRadius: 8))
}
}
}
private var existingRoleSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("已有角色信息")
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
ForEach(viewModel.existingRoleInfos) { info in
ExistingRolePermissionRow(info: info)
}
}
.padding(AppMetrics.Spacing.small)
.background(Color(hex: 0xF8F9FB), in: RoundedRectangle(cornerRadius: 12))
}
private var submitBar: some View {
Button(viewModel.submitting ? "提交中..." : "提交审核") {
Task { await viewModel.submit(api: scenicPermissionAPI) }
}
.disabled(!viewModel.canSubmit)
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.sheetButtonHeight)
.background(viewModel.canSubmit ? AppDesign.primary : Color(hex: 0xD8D8D8), in: RoundedRectangle(cornerRadius: 8))
.padding(AppMetrics.Spacing.medium)
.background(.white)
}
///
private func pickerDisplay(
title: String,
subtitle: String,
isPlaceholder: Bool,
isDisabled: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
HStack(spacing: AppMetrics.Spacing.small) {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: isPlaceholder ? .regular : .semibold))
.foregroundStyle(isPlaceholder ? AppDesign.placeholder : AppDesign.textPrimary)
.lineLimit(1)
Text(subtitle)
.font(.system(size: 11))
.foregroundStyle(AppDesign.textSecondary)
.lineLimit(1)
}
Spacer()
Image(systemName: "chevron.down")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(isDisabled ? Color(hex: 0xC7CDD6) : AppDesign.primary)
}
.padding(.horizontal, AppMetrics.Spacing.substantial)
.frame(height: 58)
.background(Color(hex: 0xFBFCFE), in: RoundedRectangle(cornerRadius: 10))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(isDisabled ? Color(hex: 0xE5E7EB) : AppDesign.primary.opacity(0.28), lineWidth: 1)
}
}
.buttonStyle(.plain)
.disabled(isDisabled)
}
}
///
private struct NewScenicBanner: View {
var body: some View {
HStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: "plus.circle.fill")
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
.foregroundStyle(AppDesign.primary)
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
Text("没有找到想申请的景区?")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text("提交景区资料后等待平台审核")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textSecondary)
}
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(height: 64)
.background(.white)
}
}
///
private struct ExistingRolePermissionRow: View {
let info: ExistingRolePermissionInfo
@State private var expanded = true
var body: some View {
VStack(spacing: 0) {
Button {
withAnimation(.easeInOut(duration: 0.16)) {
expanded.toggle()
}
} label: {
HStack {
Text(info.name)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
Spacer()
Image(systemName: expanded ? "chevron.up" : "chevron.down")
.foregroundStyle(AppDesign.textSecondary)
}
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(height: 48)
}
.buttonStyle(.plain)
if expanded && !info.scenics.isEmpty {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 90), spacing: AppMetrics.Spacing.xSmall)], alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
ForEach(info.scenics.prefix(6)) { scenic in
Text(scenic.name)
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.success)
.lineLimit(1)
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 34)
.background(Color(hex: 0xF0FAEE), in: RoundedRectangle(cornerRadius: 4))
}
}
.padding(AppMetrics.Spacing.small)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(hex: 0xF8F9FB))
}
}
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
}
///
private enum PermissionPickerSheet: Identifiable {
case role
case scenic
var id: String {
switch self {
case .role: "role"
case .scenic: "scenic"
}
}
var title: String {
switch self {
case .role: "选择角色"
case .scenic: "选择景区"
}
}
}
///
private struct PermissionPickerSheetView: View {
@Environment(\.dismiss) private var dismiss
let picker: PermissionPickerSheet
let roleOptions: [PermissionRoleOption]
let selectedRoleId: Int?
let scenicOptions: [PermissionScenicOption]
let selectedCount: Int
let onSelectRole: (PermissionRoleOption) -> Void
let onToggleScenic: (PermissionScenicOption) -> Void
var body: some View {
NavigationStack {
ScrollView {
LazyVStack(spacing: AppMetrics.Spacing.xSmall) {
switch picker {
case .role:
ForEach(roleOptions) { role in
roleRow(role)
}
case .scenic:
ForEach(scenicOptions) { scenic in
scenicRow(scenic)
}
}
}
.padding(AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.navigationTitle(picker.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("关闭") { dismiss() }
}
if picker == .scenic {
ToolbarItem(placement: .confirmationAction) {
Button("完成(\(selectedCount))") { dismiss() }
}
}
}
}
}
///
private func roleRow(_ role: PermissionRoleOption) -> some View {
Button { onSelectRole(role) } label: {
HStack(spacing: AppMetrics.Spacing.small) {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
Text(role.name)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
if !role.notes.isEmpty {
Text(role.notes)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
.lineLimit(2)
}
}
Spacer()
Image(systemName: selectedRoleId == role.id ? "checkmark.circle.fill" : "circle")
.font(.system(size: AppMetrics.ControlSize.passwordIcon, weight: .semibold))
.foregroundStyle(selectedRoleId == role.id ? AppDesign.primary : Color(hex: 0xCBD5E1))
}
.padding(AppMetrics.Spacing.substantial)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
///
private func scenicRow(_ scenic: PermissionScenicOption) -> some View {
Button { onToggleScenic(scenic) } label: {
HStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: scenic.selected ? "checkmark.square.fill" : "square")
.font(.system(size: AppMetrics.ControlSize.passwordIcon, weight: .semibold))
.foregroundStyle(scenic.disabled ? Color(hex: 0xCBD5E1) : (scenic.selected ? AppDesign.primary : Color(hex: 0xCBD5E1)))
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
Text(scenic.name)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(scenic.disabled ? AppDesign.textSecondary : AppDesign.textPrimary)
.lineLimit(1)
if scenic.disabled {
Text("已有该角色权限")
.font(.system(size: 11))
.foregroundStyle(AppDesign.textSecondary)
}
}
Spacer()
}
.padding(AppMetrics.Spacing.substantial)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
.opacity(scenic.disabled ? 0.62 : 1)
}
.buttonStyle(.plain)
.disabled(scenic.disabled)
}
}

View File

@ -0,0 +1,532 @@
//
// 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
}
}
}

View File

@ -0,0 +1,243 @@
//
// ScenicSelectionView.swift
// suixinkan
//
// Created by Codex on 2026/6/23.
//
import CoreLocation
import SwiftUI
///
struct ScenicSelectionView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(PermissionContext.self) private var permissionContext
@Environment(AccountSnapshotStore.self) private var snapshotStore
@Environment(\.dismiss) private var dismiss
@State private var viewModel = ScenicSelectionViewModel()
@State private var locationProvider = ScenicSelectionLocationProvider()
var body: some View {
VStack(spacing: 0) {
applyBanner
searchBar
currentLocationBar
content
}
.background(AppDesign.backgroundColor.ignoresSafeArea())
.navigationTitle("景区选择")
.navigationBarTitleDisplayMode(.inline)
.task {
viewModel.reload(from: accountContext)
requestLocation()
}
}
private var applyBanner: some View {
NavigationLink(value: AppRoute.home(.permissionApply)) {
HStack(spacing: 10) {
Image(systemName: "building.2.fill")
.font(.system(size: AppMetrics.ControlSize.checkboxIcon, weight: .semibold))
.foregroundStyle(AppDesign.warning)
Text("希望开通更多景区权限")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(AppDesign.warning)
.lineLimit(1)
Spacer()
Text("立即申请")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
Image(systemName: "chevron.right")
.font(.system(size: AppMetrics.FontSize.caption, weight: .bold))
}
.foregroundStyle(AppDesign.warning)
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(height: 56)
.background(Color(hex: 0xFFF0E2))
}
.buttonStyle(.plain)
}
private var searchBar: some View {
HStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: "magnifyingglass")
.font(.system(size: AppMetrics.ControlSize.checkboxIcon))
.foregroundStyle(AppDesign.placeholder)
TextField("搜索景区", text: $viewModel.searchQuery)
.font(.system(size: AppMetrics.FontSize.subheadline))
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.tint(AppDesign.primary)
}
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(height: AppMetrics.ControlSize.inputHeight)
.background(Color(hex: 0xF2F4F8), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
.padding(.horizontal, AppMetrics.Spacing.medium)
.padding(.top, AppMetrics.Spacing.medium)
}
private var currentLocationBar: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: "location.fill")
.font(.system(size: AppMetrics.ControlSize.smallIcon, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text(viewModel.currentLocationText)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
Button("当前定位地址") {
requestLocation()
}
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(AppDesign.primary)
.buttonStyle(.plain)
}
if let warning = viewModel.locationWarning {
Text(warning)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.warning)
.lineLimit(2)
}
}
.padding(.horizontal, AppMetrics.Spacing.medium)
.padding(.vertical, AppMetrics.Spacing.small)
}
@ViewBuilder
private var content: some View {
ScrollView {
if viewModel.filteredItems.isEmpty {
ContentUnavailableView(
"暂无可选景区",
systemImage: "mountain.2",
description: Text("可尝试清空搜索关键词或刷新定位。")
)
.frame(maxWidth: .infinity, minHeight: 240)
.padding(.top, AppMetrics.Spacing.xLarge)
} else {
LazyVStack(spacing: AppMetrics.Spacing.small) {
ForEach(viewModel.filteredItems) { scenic in
ScenicSelectionCard(
scenic: scenic,
isSelected: accountContext.currentScenic?.id == scenic.id
) {
viewModel.select(
scenicId: scenic.id,
accountContext: accountContext,
permissionContext: permissionContext,
snapshotStore: snapshotStore
)
dismiss()
}
}
}
.padding(.horizontal, AppMetrics.Spacing.large)
.padding(.bottom, AppMetrics.Spacing.xLarge)
}
}
}
/// ViewModel
private func requestLocation() {
viewModel.currentLocationText = "定位中..."
viewModel.locationWarning = nil
locationProvider.onLocation = { location in
Task { @MainActor in
viewModel.applyCurrentLocation(
latitude: location.coordinate.latitude,
longitude: location.coordinate.longitude
)
}
}
locationProvider.onFailure = { message in
Task { @MainActor in
viewModel.applyLocationFailure(message)
}
}
locationProvider.request()
}
}
///
private struct ScenicSelectionCard: View {
let scenic: ScenicSelectionItem
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: AppMetrics.Spacing.small) {
RemoteImage(urlString: scenic.coverURLString) {
ZStack {
Color(hex: 0xF0F0F0)
Image(systemName: "photo")
.font(.system(size: AppMetrics.FontSize.title, weight: .medium))
.foregroundStyle(Color(hex: 0xAAB2BD))
}
}
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Text(scenic.name)
.font(.system(size: AppMetrics.FontSize.body, weight: .bold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
statusBadge
}
HStack(spacing: AppMetrics.Spacing.xxSmall) {
Text(scenic.distanceText)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textPrimary)
.fixedSize()
Text(scenic.address)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
.lineLimit(1)
}
if isSelected {
Label("当前景区", systemImage: "checkmark.circle.fill")
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(AppDesign.primary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(AppMetrics.Spacing.small)
.frame(minHeight: 104)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
.overlay {
RoundedRectangle(cornerRadius: 8)
.stroke(isSelected ? AppDesign.primary : Color(hex: 0xEEF2F7), lineWidth: isSelected ? 2 : 1)
}
.shadow(color: .black.opacity(isSelected ? 0.08 : 0.03), radius: 8, x: 0, y: 3)
}
.buttonStyle(.plain)
}
@ViewBuilder
private var statusBadge: some View {
if scenic.isClosest {
Text("距离最近")
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, AppMetrics.Spacing.xSmall)
.frame(height: 26)
.background(Color(hex: 0xE74C3C), in: RoundedRectangle(cornerRadius: 4))
} else if scenic.isClosed {
Text("暂未营业")
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(AppDesign.textSecondary)
.padding(.horizontal, AppMetrics.Spacing.xSmall)
.frame(height: 26)
.background(Color(hex: 0xE0E0E0), in: RoundedRectangle(cornerRadius: 4))
}
}
}
private extension AppDesign {
static let backgroundColor = Color(hex: 0xF5F7FA)
}