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:
@ -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