新增景区权限模块,集成 AMap CocoaPods 并支持模拟器
将景区选择与权限申请流程接入首页路由,集成高德 SDK 用于真机构建,并通过 Podfile post_install 钩子保证 arm64 模拟器可编译。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user