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,189 @@
|
||||
//
|
||||
// PermissionApplyStatusView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 权限申请状态页面,展示审核中、通过或驳回的角色景区权限申请。
|
||||
struct PermissionApplyStatusView: View {
|
||||
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
|
||||
@State private var viewModel = PermissionApplyStatusViewModel()
|
||||
@State private var editingPending: RoleApplyPendingResponse?
|
||||
let applyCode: String?
|
||||
|
||||
/// 初始化权限申请状态页面,可传入指定申请编号。
|
||||
init(applyCode: String? = nil) {
|
||||
self.applyCode = applyCode
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
if let pending = viewModel.pending {
|
||||
statusCard(pending)
|
||||
roleCard(pending)
|
||||
scenicCard(pending)
|
||||
} else if viewModel.loadFailed {
|
||||
ContentUnavailableView(
|
||||
"申请状态加载失败",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(viewModel.loadFailureReason ?? "请检查网络后重试")
|
||||
)
|
||||
Button("重新加载") {
|
||||
Task { await viewModel.load(api: scenicPermissionAPI, applyCode: applyCode) }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
ContentUnavailableView("暂无审核中的申请", systemImage: "doc.text.magnifyingglass")
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
|
||||
.navigationTitle("权限申请")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if let pending = viewModel.pending, pending.status == 3 {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
editingPending = pending
|
||||
} label: {
|
||||
Label("去编辑", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $editingPending) { pending in
|
||||
PermissionApplyView(initialPending: pending)
|
||||
}
|
||||
.task {
|
||||
await viewModel.load(api: scenicPermissionAPI, applyCode: applyCode)
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建审核状态卡片。
|
||||
private func statusCard(_ pending: RoleApplyPendingResponse) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
HStack {
|
||||
Label(statusLabel(pending), systemImage: statusIcon(pending.status))
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(statusColor(pending.status))
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.frame(height: 28)
|
||||
.background(statusColor(pending.status).opacity(0.12), in: Capsule())
|
||||
Spacer()
|
||||
}
|
||||
row("申请编号", pending.code)
|
||||
row("提交时间", pending.createdAt)
|
||||
row("审核时间", pending.auditedAt ?? "--")
|
||||
row("审核人", pending.auditedBy ?? "--")
|
||||
row("审核备注", pending.auditNote ?? "--")
|
||||
}
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.background(statusColor(pending.status).opacity(0.08), in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12).stroke(statusColor(pending.status).opacity(0.35), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建角色卡片。
|
||||
private func roleCard(_ pending: RoleApplyPendingResponse) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text("新增角色")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
Text(pending.roleName)
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.padding(.vertical, AppMetrics.Spacing.xSmall)
|
||||
.background(AppDesign.primarySoft, in: Capsule())
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
/// 构建景区卡片。
|
||||
private func scenicCard(_ pending: RoleApplyPendingResponse) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
HStack {
|
||||
Text("新增景区")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
Spacer()
|
||||
Text("\(pending.scenicList.count) 个")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
if pending.scenicList.isEmpty {
|
||||
Text("未选择景区")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
} else {
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 90), spacing: AppMetrics.Spacing.xSmall)], alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
ForEach(pending.scenicList) { scenic in
|
||||
Text(scenic.name)
|
||||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.padding(.vertical, AppMetrics.Spacing.xSmall)
|
||||
.background(AppDesign.primarySoft, in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private func row(_ title: String, _ value: String?) -> some View {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text("\(title):")
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Spacer()
|
||||
Text(nonEmpty(value) ?? "--")
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||
}
|
||||
|
||||
private func statusLabel(_ pending: RoleApplyPendingResponse) -> String {
|
||||
nonEmpty(pending.statusLabel) ?? {
|
||||
switch pending.status {
|
||||
case 2: return "已通过"
|
||||
case 3: return "已驳回"
|
||||
case 1: return "审核中"
|
||||
default: return "未知状态"
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
private func statusIcon(_ status: Int) -> String {
|
||||
switch status {
|
||||
case 2: return "checkmark.circle.fill"
|
||||
case 3: return "xmark.octagon.fill"
|
||||
case 1: return "clock.badge.exclamationmark.fill"
|
||||
default: return "questionmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: Int) -> Color {
|
||||
switch status {
|
||||
case 2: return AppDesign.success
|
||||
case 3: return Color(hex: 0xDC2626)
|
||||
case 1: return AppDesign.warning
|
||||
default: return AppDesign.textSecondary
|
||||
}
|
||||
}
|
||||
|
||||
/// 去除空白字符后返回非空字符串。
|
||||
private func nonEmpty(_ value: String?) -> String? {
|
||||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,532 @@
|
||||
//
|
||||
// ScenicApplicationView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
/// 景区申请页面,支持新增景区入驻资料、图片上传和审核状态回填。
|
||||
struct ScenicApplicationView: View {
|
||||
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
|
||||
@Environment(OSSUploadService.self) private var ossUploadService
|
||||
@State private var viewModel = ScenicApplicationViewModel()
|
||||
@State private var pickedImageItems: [PhotosPickerItem] = []
|
||||
@State private var activeLocationPicker: ScenicLocationPicker?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.medium) {
|
||||
if viewModel.loadFailed {
|
||||
ContentUnavailableView(
|
||||
"景区申请初始化失败",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(viewModel.loadFailureReason ?? "请检查网络后重试")
|
||||
)
|
||||
Button("重新加载") {
|
||||
Task { await viewModel.loadInitial(api: scenicPermissionAPI) }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
warmTip
|
||||
formSection
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
}
|
||||
if !viewModel.loadFailed {
|
||||
agreementSection
|
||||
}
|
||||
}
|
||||
.background(Color(hex: 0xF4F4F4).ignoresSafeArea())
|
||||
.navigationTitle("景区申请")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await viewModel.loadInitial(api: scenicPermissionAPI)
|
||||
}
|
||||
.onChange(of: pickedImageItems) { _, items in
|
||||
guard !items.isEmpty else { return }
|
||||
Task {
|
||||
await importPickedImages(items)
|
||||
pickedImageItems = []
|
||||
}
|
||||
}
|
||||
.sheet(item: $activeLocationPicker) { picker in
|
||||
ScenicLocationSelectionSheet(
|
||||
picker: picker,
|
||||
provinces: viewModel.provinces,
|
||||
cities: viewModel.cities,
|
||||
selectedProvince: viewModel.selectedProvince,
|
||||
selectedCity: viewModel.selectedCity,
|
||||
onSelectProvince: { item in
|
||||
viewModel.selectedProvince = item.name
|
||||
viewModel.onProvinceChange()
|
||||
activeLocationPicker = nil
|
||||
},
|
||||
onSelectCity: { item in
|
||||
viewModel.selectedCity = item.name
|
||||
activeLocationPicker = nil
|
||||
}
|
||||
)
|
||||
.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 warmTip: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text("温馨提示")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .bold))
|
||||
Text("如果你想入驻更多的景区,平台可以帮你跟景区官方谈合作;如果已经有的景区,只要符合入驻条件即可申请。")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.lineSpacing(AppMetrics.LineSpacing.title)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var formSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.large) {
|
||||
if let pending = viewModel.pending {
|
||||
pendingStatusCard(pending)
|
||||
}
|
||||
if viewModel.pendingLoadFailed && viewModel.pending == nil {
|
||||
pendingFailureRow
|
||||
}
|
||||
inputField("景区名称", text: $viewModel.scenicName, disabled: viewModel.isReadOnly)
|
||||
uploadSection
|
||||
locationSection
|
||||
coopTypeSection
|
||||
inputField("备注信息 (\(viewModel.remark.count)/50)", text: $viewModel.remark, axis: .vertical, disabled: viewModel.isReadOnly)
|
||||
.onChange(of: viewModel.remark) { _, value in
|
||||
if value.count > 50 {
|
||||
viewModel.remark = String(value.prefix(50))
|
||||
}
|
||||
}
|
||||
if let pending = viewModel.pending, pending.status == 3 {
|
||||
Text("驳回原因:\(pending.auditNote ?? pending.rejectReason ?? "--")")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(Color(hex: 0xDC2626))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.background(Color(hex: 0xFEF2F2), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var pendingFailureRow: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundStyle(AppDesign.warning)
|
||||
Text("待审核记录加载失败,暂未展示历史申请状态")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Spacer()
|
||||
Button("重试") {
|
||||
Task { await viewModel.reloadPending(api: scenicPermissionAPI) }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.background(Color(hex: 0xFFF7ED), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var uploadSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
HStack {
|
||||
Text("景区图片")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
|
||||
Spacer()
|
||||
Text("\(viewModel.uploadPlaceholderCount)/20")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 92), spacing: AppMetrics.Spacing.xSmall)], spacing: AppMetrics.Spacing.xSmall) {
|
||||
ForEach(viewModel.remoteImageURLs, id: \.self) { url in
|
||||
scenicImageCell(url: url, local: nil, progress: nil) {
|
||||
viewModel.removeRemoteImage(url)
|
||||
}
|
||||
}
|
||||
ForEach(viewModel.localImages) { image in
|
||||
scenicImageCell(url: image.uploadedURL, local: image.data, progress: image.uploading ? image.progress : nil) {
|
||||
viewModel.removeLocalImage(id: image.id)
|
||||
}
|
||||
}
|
||||
if viewModel.uploadPlaceholderCount < 20 && !viewModel.isReadOnly {
|
||||
PhotosPicker(
|
||||
selection: $pickedImageItems,
|
||||
maxSelectionCount: max(0, 20 - viewModel.uploadPlaceholderCount),
|
||||
matching: .images
|
||||
) {
|
||||
VStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: AppMetrics.FontSize.title, weight: .semibold))
|
||||
Text("上传图片")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(height: 92)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(AppDesign.primary.opacity(0.25), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var locationSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text("景区位置")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Button {
|
||||
activeLocationPicker = .province
|
||||
} label: {
|
||||
pickerBox(title: viewModel.selectedProvince.isEmpty ? "省份" : viewModel.selectedProvince)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.isReadOnly)
|
||||
|
||||
Button {
|
||||
guard !viewModel.selectedProvince.isEmpty else {
|
||||
viewModel.message = "请先选择省份"
|
||||
return
|
||||
}
|
||||
activeLocationPicker = .city
|
||||
} label: {
|
||||
pickerBox(title: viewModel.selectedCity.isEmpty ? "城市" : viewModel.selectedCity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.isReadOnly)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var coopTypeSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text("合作类型")
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
|
||||
VStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
coopButton(title: "我是摄影师,我想入驻", tag: 1)
|
||||
coopButton(title: "我是景区资源方,可以合作", tag: 2)
|
||||
coopButton(title: "我有摄影师团队,带队入驻", tag: 3)
|
||||
coopButton(title: "我是旅拍店,想合作", tag: 4)
|
||||
coopButton(title: "我已经在景区运营旅拍了,想使用软件,提高效率", tag: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var agreementSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Button {
|
||||
viewModel.agreed.toggle()
|
||||
} label: {
|
||||
Image(systemName: viewModel.agreed ? "checkmark.square.fill" : "square")
|
||||
.font(.system(size: AppMetrics.ControlSize.passwordIcon))
|
||||
.foregroundStyle(viewModel.agreed ? AppDesign.primary : AppDesign.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.isReadOnly)
|
||||
Text("已阅读并同意《用户须知》与《隐私政策》")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(2)
|
||||
Spacer()
|
||||
}
|
||||
Button(viewModel.submitting ? "提交中..." : "确认提交") {
|
||||
Task { await viewModel.submit(api: scenicPermissionAPI, uploader: ossUploadService) }
|
||||
}
|
||||
.disabled(viewModel.isSubmitLocked)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 46)
|
||||
.background(viewModel.canSubmit ? AppDesign.primary : Color(hex: 0xD8D8D8), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white)
|
||||
}
|
||||
|
||||
/// 构建输入区。
|
||||
private func inputField(_ title: String, text: Binding<String>, axis: Axis = .horizontal, disabled: Bool = false) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .medium))
|
||||
TextField("", text: text, prompt: Text("请输入\(title)").foregroundColor(AppDesign.placeholder), axis: axis)
|
||||
.disabled(disabled)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.lineLimit(axis == .vertical ? 3...5 : 1...1)
|
||||
.appInputFieldStyle(cornerRadius: 8, minHeight: axis == .vertical ? 100 : 50)
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建地区选择框。
|
||||
private func pickerBox(title: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(title == "省份" || title == "城市" ? AppDesign.placeholder : AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.down")
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.substantial)
|
||||
.frame(height: 50)
|
||||
.background(Color(hex: 0xFBFCFE), in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(Color(hex: 0xE2E8F0), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建合作类型按钮。
|
||||
private func coopButton(title: String, tag: Int) -> some View {
|
||||
Button {
|
||||
viewModel.coopType = tag
|
||||
} label: {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||||
.foregroundStyle(viewModel.coopType == tag ? .white : AppDesign.textSecondary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.frame(minHeight: tag == 5 ? 70 : 48)
|
||||
.background(viewModel.coopType == tag ? AppDesign.primary : .white, in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(viewModel.coopType == tag ? AppDesign.primary : Color(hex: 0xE2E8F0), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.isReadOnly)
|
||||
}
|
||||
|
||||
/// 构建待审核状态卡片。
|
||||
private func pendingStatusCard(_ pending: ScenicApplicationPendingResponse) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Label(statusText(pending.status), systemImage: statusIcon(pending.status))
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(statusColor(pending.status))
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.frame(height: 28)
|
||||
.background(statusColor(pending.status).opacity(0.12), in: Capsule())
|
||||
metaRow("申请编号", pending.code.isEmpty ? "--" : pending.code)
|
||||
metaRow("提交时间", pending.createdAt.isEmpty ? "--" : pending.createdAt)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// 构建图片格。
|
||||
private func scenicImageCell(url: String?, local data: Data?, progress: Int?, onDelete: @escaping () -> Void) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ZStack {
|
||||
if let data, let image = UIImage(data: data) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else if let url, !url.isEmpty {
|
||||
RemoteImage(urlString: url) {
|
||||
imagePlaceholder
|
||||
}
|
||||
} else {
|
||||
imagePlaceholder
|
||||
}
|
||||
if let progress {
|
||||
Color.black.opacity(0.38)
|
||||
ProgressView(value: Double(progress), total: 100)
|
||||
.tint(.white)
|
||||
.padding(AppMetrics.Spacing.xSmall)
|
||||
}
|
||||
}
|
||||
.frame(height: 92)
|
||||
.frame(maxWidth: .infinity)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
if !viewModel.isReadOnly {
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: AppMetrics.FontSize.title2, weight: .semibold))
|
||||
.foregroundStyle(.white, Color.black.opacity(0.55))
|
||||
.padding(AppMetrics.Spacing.xxSmall)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var imagePlaceholder: some View {
|
||||
ZStack {
|
||||
Color(hex: 0xF1F5F9)
|
||||
Image(systemName: "photo.fill")
|
||||
.font(.system(size: AppMetrics.FontSize.title, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x94A3B8))
|
||||
}
|
||||
}
|
||||
|
||||
/// 导入用户从系统相册选择的图片数据。
|
||||
private func importPickedImages(_ items: [PhotosPickerItem]) async {
|
||||
for item in items {
|
||||
guard viewModel.uploadPlaceholderCount < 20 else { return }
|
||||
do {
|
||||
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
|
||||
viewModel.addLocalImage(data: data, fileName: "scenic_\(UUID().uuidString).jpg")
|
||||
} catch {
|
||||
viewModel.message = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func metaRow(_ title: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text("\(title):")
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(value)
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Spacer()
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
}
|
||||
|
||||
private func statusText(_ status: Int) -> String {
|
||||
switch status {
|
||||
case 1: return "待审核"
|
||||
case 2: return "已通过"
|
||||
case 3: return "已驳回"
|
||||
case 9: return "已取消"
|
||||
default: return "未提交"
|
||||
}
|
||||
}
|
||||
|
||||
private func statusIcon(_ status: Int) -> String {
|
||||
switch status {
|
||||
case 2: return "checkmark.circle.fill"
|
||||
case 3: return "xmark.octagon.fill"
|
||||
case 1: return "clock.badge.exclamationmark.fill"
|
||||
default: return "questionmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func statusColor(_ status: Int) -> Color {
|
||||
switch status {
|
||||
case 2: return AppDesign.success
|
||||
case 3: return Color(hex: 0xDC2626)
|
||||
case 1: return AppDesign.warning
|
||||
default: return AppDesign.textSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 景区申请地区选择器类型。
|
||||
private enum ScenicLocationPicker: Identifiable {
|
||||
case province
|
||||
case city
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
case .province: "province"
|
||||
case .city: "city"
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .province: "选择省份"
|
||||
case .city: "选择城市"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 景区申请省市选择弹层。
|
||||
private struct ScenicLocationSelectionSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let picker: ScenicLocationPicker
|
||||
let provinces: [ScenicAreaNode]
|
||||
let cities: [ScenicAreaNode]
|
||||
let selectedProvince: String
|
||||
let selectedCity: String
|
||||
let onSelectProvince: (ScenicAreaNode) -> Void
|
||||
let onSelectCity: (ScenicAreaNode) -> Void
|
||||
|
||||
private var items: [ScenicAreaNode] {
|
||||
picker == .province ? provinces : cities
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
ForEach(items) { item in
|
||||
Button {
|
||||
switch picker {
|
||||
case .province:
|
||||
onSelectProvince(item)
|
||||
case .city:
|
||||
onSelectCity(item)
|
||||
}
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Text(item.name)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: isSelected(item) ? .semibold : .regular))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Spacer()
|
||||
if isSelected(item) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.substantial)
|
||||
.frame(height: 48)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
|
||||
.navigationTitle(picker.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("关闭") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断地区节点是否已选中。
|
||||
private func isSelected(_ item: ScenicAreaNode) -> Bool {
|
||||
switch picker {
|
||||
case .province:
|
||||
item.name == selectedProvince
|
||||
case .city:
|
||||
item.name == selectedCity
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,243 @@
|
||||
//
|
||||
// ScenicSelectionView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/23.
|
||||
//
|
||||
|
||||
import CoreLocation
|
||||
import SwiftUI
|
||||
|
||||
/// 景区选择页面,支持搜索、定位距离展示和切换当前景区。
|
||||
struct ScenicSelectionView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(AccountSnapshotStore.self) private var snapshotStore
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = ScenicSelectionViewModel()
|
||||
@State private var locationProvider = ScenicSelectionLocationProvider()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
applyBanner
|
||||
searchBar
|
||||
currentLocationBar
|
||||
content
|
||||
}
|
||||
.background(AppDesign.backgroundColor.ignoresSafeArea())
|
||||
.navigationTitle("景区选择")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
viewModel.reload(from: accountContext)
|
||||
requestLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private var applyBanner: some View {
|
||||
NavigationLink(value: AppRoute.home(.permissionApply)) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "building.2.fill")
|
||||
.font(.system(size: AppMetrics.ControlSize.checkboxIcon, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.warning)
|
||||
Text("希望开通更多景区权限")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||||
.foregroundStyle(AppDesign.warning)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text("立即申请")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(AppDesign.warning)
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.frame(height: 56)
|
||||
.background(Color(hex: 0xFFF0E2))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var searchBar: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: AppMetrics.ControlSize.checkboxIcon))
|
||||
.foregroundStyle(AppDesign.placeholder)
|
||||
TextField("搜索景区", text: $viewModel.searchQuery)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.tint(AppDesign.primary)
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.frame(height: AppMetrics.ControlSize.inputHeight)
|
||||
.background(Color(hex: 0xF2F4F8), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.padding(.top, AppMetrics.Spacing.medium)
|
||||
}
|
||||
|
||||
private var currentLocationBar: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Image(systemName: "location.fill")
|
||||
.font(.system(size: AppMetrics.ControlSize.smallIcon, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text(viewModel.currentLocationText)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Button("当前定位地址") {
|
||||
requestLocation()
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
if let warning = viewModel.locationWarning {
|
||||
Text(warning)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.warning)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
ScrollView {
|
||||
if viewModel.filteredItems.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"暂无可选景区",
|
||||
systemImage: "mountain.2",
|
||||
description: Text("可尝试清空搜索关键词或刷新定位。")
|
||||
)
|
||||
.frame(maxWidth: .infinity, minHeight: 240)
|
||||
.padding(.top, AppMetrics.Spacing.xLarge)
|
||||
} else {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||||
ForEach(viewModel.filteredItems) { scenic in
|
||||
ScenicSelectionCard(
|
||||
scenic: scenic,
|
||||
isSelected: accountContext.currentScenic?.id == scenic.id
|
||||
) {
|
||||
viewModel.select(
|
||||
scenicId: scenic.id,
|
||||
accountContext: accountContext,
|
||||
permissionContext: permissionContext,
|
||||
snapshotStore: snapshotStore
|
||||
)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.large)
|
||||
.padding(.bottom, AppMetrics.Spacing.xLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 请求当前位置,并把定位结果回写 ViewModel。
|
||||
private func requestLocation() {
|
||||
viewModel.currentLocationText = "定位中..."
|
||||
viewModel.locationWarning = nil
|
||||
locationProvider.onLocation = { location in
|
||||
Task { @MainActor in
|
||||
viewModel.applyCurrentLocation(
|
||||
latitude: location.coordinate.latitude,
|
||||
longitude: location.coordinate.longitude
|
||||
)
|
||||
}
|
||||
}
|
||||
locationProvider.onFailure = { message in
|
||||
Task { @MainActor in
|
||||
viewModel.applyLocationFailure(message)
|
||||
}
|
||||
}
|
||||
locationProvider.request()
|
||||
}
|
||||
}
|
||||
|
||||
/// 景区选择卡片,展示封面、名称、地址、距离和状态。
|
||||
private struct ScenicSelectionCard: View {
|
||||
let scenic: ScenicSelectionItem
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
RemoteImage(urlString: scenic.coverURLString) {
|
||||
ZStack {
|
||||
Color(hex: 0xF0F0F0)
|
||||
Image(systemName: "photo")
|
||||
.font(.system(size: AppMetrics.FontSize.title, weight: .medium))
|
||||
.foregroundStyle(Color(hex: 0xAAB2BD))
|
||||
}
|
||||
}
|
||||
.frame(width: 80, height: 80)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(scenic.name)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
statusBadge
|
||||
}
|
||||
HStack(spacing: AppMetrics.Spacing.xxSmall) {
|
||||
Text(scenic.distanceText)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.fixedSize()
|
||||
Text(scenic.address)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if isSelected {
|
||||
Label("当前景区", systemImage: "checkmark.circle.fill")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.frame(minHeight: 104)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(isSelected ? AppDesign.primary : Color(hex: 0xEEF2F7), lineWidth: isSelected ? 2 : 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(isSelected ? 0.08 : 0.03), radius: 8, x: 0, y: 3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBadge: some View {
|
||||
if scenic.isClosest {
|
||||
Text("距离最近")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, AppMetrics.Spacing.xSmall)
|
||||
.frame(height: 26)
|
||||
.background(Color(hex: 0xE74C3C), in: RoundedRectangle(cornerRadius: 4))
|
||||
} else if scenic.isClosed {
|
||||
Text("暂未营业")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.padding(.horizontal, AppMetrics.Spacing.xSmall)
|
||||
.frame(height: 26)
|
||||
.background(Color(hex: 0xE0E0E0), in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppDesign {
|
||||
static let backgroundColor = Color(hex: 0xF5F7FA)
|
||||
}
|
||||
Reference in New Issue
Block a user