将景区选择与权限申请流程接入首页路由,集成高德 SDK 用于真机构建,并通过 Podfile post_install 钩子保证 arm64 模拟器可编译。 Co-authored-by: Cursor <cursoragent@cursor.com>
415 lines
17 KiB
Swift
415 lines
17 KiB
Swift
//
|
||
// 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)
|
||
}
|
||
}
|