// // PermissionApplyView.swift // suixinkan // // Created by Codex on 2026/6/23. // import SwiftUI /// 权限申请页面,支持申请某角色在更多景区下的权限。 struct PermissionApplyView: View { @EnvironmentObject private var permissionContext: PermissionContext @Environment(\.scenicPermissionAPI) private var scenicPermissionAPI @StateObject private var viewModel: PermissionApplyViewModel @State private var activePicker: PermissionPickerSheet? /// 初始化权限申请页面,可传入驳回记录用于编辑。 init(initialPending: RoleApplyPendingResponse? = nil) { _viewModel = StateObject(wrappedValue: 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) } }