Add ScenicPermission module and AMap CocoaPods with simulator support.

Integrate scenic selection and permission flows into home routing, add AMap SDK for device builds while keeping arm64 simulators compilable via Podfile post_install hooks.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 10:15:26 +08:00
parent e8bc9b7f44
commit c5374666de
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)
}