// // WildPhotographerReportSubmitViews // suixinkan // // Created by Codex on 2026/7/1. // import MapKit import SwiftUI import UIKit struct WildPhotographerReportSubmitView: View { @StateObject private var viewModel: WildPhotographerReportSubmitViewModel private let homeViewModel: WildPhotographerReportHomeViewModel @Environment(\.dismiss) private var dismiss @Environment(\.horizontalSizeClass) private var horizontalSizeClass @EnvironmentObject private var toastCenter: ToastCenter @State private var shouldReturnToHome = false init(homeViewModel: WildPhotographerReportHomeViewModel) { self.homeViewModel = homeViewModel _viewModel = StateObject(wrappedValue: WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)) } var body: some View { ZStack(alignment: .bottom) { Group { switch viewModel.pageState { case .normal: formContent case .loading: loadingContent case .empty: stateContent( title: "暂无定位信息", systemImage: "location.slash", message: "请先确认举报位置后再提交举报。", actionTitle: "恢复演示数据" ) { withAnimation(.easeInOut(duration: 0.2)) { viewModel.restoreDemoDataFromEmpty() } } case .error: stateContent( title: "提交页加载失败", systemImage: "wifi.exclamationmark", message: "证据上传组件暂时不可用,请稍后重试。", actionTitle: "重新加载" ) { withAnimation(.easeInOut(duration: 0.2)) { viewModel.retryLoadAfterError() } } } } .animation(.easeInOut(duration: 0.22), value: viewModel.pageState) } .navigationTitle("提交举报") .navigationBarTitleDisplayMode(.inline) #if DEBUG .toolbar { WildReportStateToolbar(selection: $viewModel.pageState) } #endif .background(Color(hex: 0xF8FAFF).ignoresSafeArea()) .onChange(of: viewModel.validationMessage) { message in guard let message else { return } toastCenter.show(message) viewModel.validationMessage = nil } .background(successNavigationLink) .onAppear { viewModel.loadInitialLocationIfNeeded() } .onChange(of: viewModel.description) { newValue in viewModel.updateDescription(newValue) } .onChange(of: viewModel.submitResult) { newValue in guard newValue == nil, shouldReturnToHome else { return } shouldReturnToHome = false dismiss() } } private var successNavigationLink: some View { NavigationLink( isActive: Binding( get: { viewModel.submitResult != nil }, set: { isActive in if !isActive { viewModel.clearSubmitResult() } } ) ) { if let result = viewModel.submitResult { WildPhotographerReportSuccessView( result: result, homeViewModel: homeViewModel, onReturnHome: { viewModel.clearSubmitResult() shouldReturnToHome = true } ) } else { EmptyView() } } label: { EmptyView() } .hidden() } private var formContent: some View { ScrollView { VStack(spacing: 12) { realNameBanner formStepCard(index: 1, title: "举报类型") { reportTypeSelector } evidenceFormCard formStepCard(index: 3, title: "举报说明") { descriptionEditor } formStepCard(index: 4, title: "举报位置") { locationCard } formStepCard(index: 5, title: "摄影师微信号/手机号", optional: true) { TextField("请输入摄影师微信号或手机号(选填)", text: $viewModel.contact) .keyboardType(.default) .appInputFieldStyle(cornerRadius: 10, minHeight: 48) } formStepCard(index: 6, title: "线下微信/支付宝支付截图", optional: true) { paymentUploadRow } submitButton } .frame(maxWidth: contentMaxWidth) .padding(.horizontal, 18) .padding(.top, 14) .padding(.bottom, 26) } .scrollDismissesKeyboard(.interactively) } private var realNameBanner: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: "checkmark.shield.fill") .font(.system(size: 22, weight: .semibold)) .foregroundStyle(AppDesign.primary) .frame(width: 34, height: 34) .background(AppDesign.primary.opacity(0.10), in: Circle()) (Text("实名登录") .foregroundColor(AppDesign.primary) .fontWeight(.bold) + Text("后提交,") .foregroundColor(AppDesign.textPrimary) + Text("景区公安") .foregroundColor(AppDesign.primary) .fontWeight(.bold) + Text("将接收并处理") .foregroundColor(AppDesign.textPrimary)) .font(.system(size: 16, weight: .medium)) .lineSpacing(4) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) } .padding(16) .background(Color(hex: 0xF6FAFF), in: RoundedRectangle(cornerRadius: 16)) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(Color(hex: 0xBFD7FF), lineWidth: 1) ) } private var evidenceFormCard: some View { formStepCard(index: 2, title: "上传现场证据") { evidenceUploadBlock( icon: "photo.fill", title: "上传图片", subtitle: "支持 JPG、PNG 格式,最多 9 张", attachments: $viewModel.images, maxCount: 9, kind: .image ) Divider() .padding(.vertical, 2) evidenceUploadBlock( icon: "video.fill", title: "上传视频", subtitle: "支持 MP4 格式,时长不超过 60 秒", attachments: $viewModel.videos, maxCount: 3, kind: .video ) } } private var reportTypeSelector: some View { // 三个举报类型保持单选交互,由 ViewModel 统一保存当前选择。 LazyVGrid( columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 3), spacing: 8 ) { ForEach(WildReportType.allCases) { type in reportTypeOption(type) } } } private func reportTypeOption(_ type: WildReportType) -> some View { let isSelected = viewModel.selectedReportType == type return Button { withAnimation(.easeInOut(duration: 0.2)) { viewModel.selectedReportType = type } } label: { VStack(spacing: 6) { Image(systemName: type.iconName) .font(.system(size: 18, weight: .semibold)) Text(type.rawValue) .font(.system(size: 13, weight: .bold)) .lineLimit(1) .minimumScaleFactor(0.8) } .foregroundStyle(isSelected ? .white : AppDesign.textPrimary) .frame(maxWidth: .infinity) .frame(height: 72) .background( isSelected ? AppDesign.primary : Color(hex: 0xF6FAFF), in: RoundedRectangle(cornerRadius: 12) ) .overlay( RoundedRectangle(cornerRadius: 12) .stroke(isSelected ? AppDesign.primary : Color(hex: 0xC8DDFF), lineWidth: 1) ) } .buttonStyle(.plain) .accessibilityLabel("举报类型\(type.rawValue)") } private func formStepCard( index: Int, title: String, optional: Bool = false, @ViewBuilder content: () -> Content ) -> some View { VStack(alignment: .leading, spacing: 14) { formSectionTitle(index: index, title: title, optional: optional) content() } .padding(16) .background(.white, in: RoundedRectangle(cornerRadius: 16)) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(AppDesign.border, lineWidth: 1) ) } private func evidenceUploadBlock( icon: String, title: String, subtitle: String, attachments: Binding<[WildReportAttachment]>, maxCount: Int, kind: WildReportAttachmentKind ) -> some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 12) { Image(systemName: icon) .font(.system(size: 22, weight: .semibold)) .foregroundStyle(AppDesign.primary) .frame(width: 42, height: 42) .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 12)) VStack(alignment: .leading, spacing: 3) { Text(title) .font(.system(size: 16, weight: .bold)) .foregroundStyle(AppDesign.textPrimary) Text(subtitle) .font(.system(size: 13)) .foregroundStyle(AppDesign.textSecondary) } Spacer(minLength: 0) } ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(attachments.wrappedValue) { item in attachmentTile(item) { attachments.wrappedValue.removeAll { $0.id == item.id } } } ForEach(0.. Void ) -> some View { VStack(spacing: 16) { AppContentUnavailableView(title, systemImage: systemImage, description: Text(message)) Button(actionTitle, action: action) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) .frame(width: 132, height: 42) .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 10)) } .frame(maxWidth: contentMaxWidth) .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, 20) .background(Color(hex: 0xF8FAFF)) } private func formSectionTitle(index: Int, title: String, optional: Bool = false) -> some View { HStack(spacing: 8) { Capsule() .fill(AppDesign.primary) .frame(width: 4, height: 20) Text("\(index). \(title)") .font(.system(size: 17, weight: .bold)) .foregroundStyle(AppDesign.textPrimary) if optional { Text("(选填)") .font(.system(size: 14)) .foregroundStyle(AppDesign.textSecondary) } } } private func attachmentTile(_ item: WildReportAttachment, onRemove: @escaping () -> Void) -> some View { ZStack(alignment: .topTrailing) { ZStack { LinearGradient( colors: item.kind == .video ? [Color(hex: 0xB7D8FF), Color(hex: 0x4C8FEF)] : [Color(hex: 0xCFE6FF), Color(hex: 0x7EB4FF)], startPoint: .topLeading, endPoint: .bottomTrailing ) Image(systemName: item.kind == .video ? "play.circle.fill" : "mountain.2.fill") .font(.system(size: item.kind == .video ? 30 : 26, weight: .semibold)) .foregroundStyle(.white.opacity(0.95)) } .frame(width: 58, height: 58) .clipShape(RoundedRectangle(cornerRadius: 10)) Button(action: onRemove) { Image(systemName: "xmark.circle.fill") .font(.system(size: 18, weight: .bold)) .foregroundStyle(Color(hex: 0x8D93A1), .white) } .buttonStyle(.plain) .offset(x: 6, y: -6) } .accessibilityLabel("移除\(item.title)") } private func addAttachmentTile(action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: "plus") .font(.system(size: 30, weight: .light)) .foregroundStyle(Color(hex: 0x8D93A1)) .frame(width: 58, height: 58) .background(.white, in: RoundedRectangle(cornerRadius: 10)) .overlay( RoundedRectangle(cornerRadius: 10) .stroke( Color(hex: 0xCDD5E1), style: StrokeStyle(lineWidth: 1, dash: [5, 4]) ) ) } .buttonStyle(.plain) .accessibilityLabel("添加证据") } private var contentMaxWidth: CGFloat { horizontalSizeClass == .regular ? 760 : .infinity } } struct WildPhotographerReportSuccessView: View { let homeViewModel: WildPhotographerReportHomeViewModel let onReturnHome: () -> Void @StateObject private var viewModel: WildPhotographerReportSuccessViewModel @Environment(\.horizontalSizeClass) private var horizontalSizeClass @EnvironmentObject private var toastCenter: ToastCenter init( result: WildReportSubmitResult, homeViewModel: WildPhotographerReportHomeViewModel, onReturnHome: @escaping () -> Void ) { self.homeViewModel = homeViewModel self.onReturnHome = onReturnHome _viewModel = StateObject(wrappedValue: WildPhotographerReportSuccessViewModel(result: result)) } var body: some View { ScrollView { VStack(spacing: 12) { heroSection progressCard detailsCard actionButtons } .frame(maxWidth: contentMaxWidth) .padding(.horizontal, 18) .padding(.top, 14) .padding(.bottom, 28) } .navigationTitle("举报成功") .navigationBarTitleDisplayMode(.inline) .background(Color(hex: 0xF8FAFF).ignoresSafeArea()) } private var heroSection: some View { VStack(spacing: 14) { ZStack { Circle() .fill(AppDesign.primary.opacity(0.12)) .frame(width: 78, height: 78) Circle() .fill(AppDesign.primary) .frame(width: 58, height: 58) Image(systemName: "checkmark") .font(.system(size: 28, weight: .bold)) .foregroundStyle(.white) } Text("举报已提交") .font(.system(size: 24, weight: .heavy)) .foregroundStyle(AppDesign.textPrimary) Text("景区公安已收到线索,将尽快前往现场核实处理") .font(.system(size: 14)) .foregroundStyle(AppDesign.textSecondary) .multilineTextAlignment(.center) .lineSpacing(4) .padding(.horizontal, 16) } .frame(maxWidth: .infinity) .padding(.vertical, 24) .padding(.horizontal, 16) .background( LinearGradient( colors: [.white, Color(hex: 0xEEF6FF)], startPoint: .topLeading, endPoint: .bottomTrailing ), in: RoundedRectangle(cornerRadius: 18) ) .overlay( RoundedRectangle(cornerRadius: 18) .stroke(Color(hex: 0xBFD7FF), lineWidth: 1) ) } private var progressCard: some View { VStack(spacing: 14) { HStack(alignment: .top, spacing: 0) { ForEach(Array(viewModel.progressSteps.enumerated()), id: \.offset) { index, step in VStack(spacing: 8) { progressNode(isCompleted: index == 0, isCurrent: false) Text(step) .font(.system(size: 11, weight: index == 0 ? .semibold : .regular)) .foregroundStyle(index == 0 ? AppDesign.primary : Color(hex: 0x9CA3AF)) .multilineTextAlignment(.center) .lineLimit(2) .frame(maxWidth: .infinity) } if index < viewModel.progressSteps.count - 1 { progressConnector(isCompleted: index == 0) .padding(.top, 11) } } } } .padding(.horizontal, 12) .padding(.vertical, 16) .background(.white, in: RoundedRectangle(cornerRadius: 16)) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(AppDesign.border, lineWidth: 1) ) } private func progressNode(isCompleted: Bool, isCurrent: Bool) -> some View { ZStack { Circle() .stroke(isCompleted ? AppDesign.primary : Color(hex: 0xD1D5DB), lineWidth: 2) .frame(width: 24, height: 24) if isCompleted { Circle() .fill(AppDesign.primary) .frame(width: isCurrent ? 24 : 20, height: isCurrent ? 24 : 20) if isCurrent { Circle() .fill(.white) .frame(width: 8, height: 8) } else { Image(systemName: "checkmark") .font(.system(size: 10, weight: .bold)) .foregroundStyle(.white) } } } } private func progressConnector(isCompleted: Bool) -> some View { Group { if isCompleted { Rectangle() .fill(AppDesign.primary) .frame(height: 2) } else { HStack(spacing: 3) { ForEach(0..<5, id: \.self) { _ in Circle() .fill(Color(hex: 0xD1D5DB)) .frame(width: 3, height: 3) } } } } .frame(maxWidth: .infinity) } private var detailsCard: some View { VStack(spacing: 0) { detailInfoRow( icon: "doc.text.fill", title: "举报编号", value: viewModel.result.record.id, valueColor: AppDesign.primary, showsCopy: true ) Divider().padding(.leading, 56) detailInfoRow( icon: "tag.fill", title: "举报类型", value: viewModel.result.record.reportType.rawValue ) Divider().padding(.leading, 56) detailInfoRow( icon: "mappin.circle.fill", title: "举报位置", value: viewModel.result.record.location ) Divider().padding(.leading, 56) detailInfoRow( icon: "photo.on.rectangle.angled", title: "已上传", value: viewModel.uploadSummary ) } .background(.white, in: RoundedRectangle(cornerRadius: 16)) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(AppDesign.border, lineWidth: 1) ) } private func detailInfoRow( icon: String, title: String, value: String, valueColor: Color = AppDesign.textPrimary, showsCopy: Bool = false ) -> some View { HStack(spacing: 14) { Image(systemName: icon) .font(.system(size: 22, weight: .semibold)) .foregroundStyle(AppDesign.primary) .frame(width: 42, height: 42) .background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 10)) VStack(alignment: .leading, spacing: 4) { Text(title) .font(.system(size: 13)) .foregroundStyle(AppDesign.textSecondary) Text(value) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(valueColor) .lineLimit(2) } Spacer(minLength: 8) if showsCopy { Button { _ = viewModel.copyReportID() toastCenter.show("举报编号已复制") } label: { Image(systemName: "doc.on.doc") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(AppDesign.primary) .frame(width: 34, height: 34) .background(AppDesign.primarySoft, in: Circle()) } .buttonStyle(.plain) .accessibilityLabel("复制举报编号") } } .padding(.horizontal, 16) .padding(.vertical, 14) } private var actionButtons: some View { VStack(spacing: 12) { NavigationLink { WildPhotographerReportDetailView(record: viewModel.result.record, homeViewModel: homeViewModel) } label: { Text("查看处理进度") .font(.system(size: 20, weight: .bold)) .foregroundStyle(.white) .frame(maxWidth: .infinity) .frame(height: 54) .background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 14)) } .buttonStyle(.plain) Button(action: onReturnHome) { Text("返回首页") .font(.system(size: 20, weight: .bold)) .foregroundStyle(AppDesign.primary) .frame(maxWidth: .infinity) .frame(height: 54) .background(.white, in: RoundedRectangle(cornerRadius: 14)) .overlay( RoundedRectangle(cornerRadius: 14) .stroke(AppDesign.primary, lineWidth: 1.2) ) } .buttonStyle(.plain) } } private var contentMaxWidth: CGFloat { horizontalSizeClass == .regular ? 760 : .infinity } }