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,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)
}
}