// // ScenicApplicationView.swift // suixinkan // // Created by Codex on 2026/6/23. // import PhotosUI import SwiftUI /// 景区申请页面,支持新增景区入驻资料、图片上传和审核状态回填。 struct ScenicApplicationView: View { @Environment(\.scenicPermissionAPI) private var scenicPermissionAPI @Environment(\.ossUploadService) private var ossUploadService @StateObject 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 { AppContentUnavailableView( "景区申请初始化失败", 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, 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 } } }