新增举报摄影师模块并接入首页路由,桌面应用名改为随心瞰商家版。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,816 @@
|
||||
//
|
||||
// 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<Content: View>(
|
||||
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..<max(0, min(3, maxCount - attachments.wrappedValue.count)), id: \.self) { _ in
|
||||
addAttachmentTile {
|
||||
guard attachments.wrappedValue.count < maxCount else { return }
|
||||
attachments.wrappedValue.append(
|
||||
WildReportAttachment(
|
||||
kind: kind,
|
||||
title: "\(title)\(attachments.wrappedValue.count + 1)"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var descriptionEditor: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
TextEditor(text: $viewModel.description)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 10)
|
||||
.frame(minHeight: 112)
|
||||
.scrollContentBackground(.hidden)
|
||||
|
||||
if viewModel.description.isEmpty {
|
||||
Text("请描述摄影师的位置、特征、行为...")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Color(hex: 0x9CA3AF))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 18)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
Text("\(viewModel.description.count)/500")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Color(hex: 0x6B7280))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
|
||||
.padding(.trailing, 12)
|
||||
.padding(.bottom, 12)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.background(Color(hex: 0xFBFCFE), in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(AppDesign.inputBorder, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var locationCard: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.font(.system(size: 30, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(AppDesign.primary.opacity(0.10), in: Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(viewModel.locationTitleText)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.78)
|
||||
Text(viewModel.locationSubtitleText)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Button("更新定位") {
|
||||
viewModel.refreshCurrentLocation()
|
||||
}
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(width: 82, height: 34)
|
||||
.background(.white, in: Capsule())
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(AppDesign.primary, lineWidth: 1)
|
||||
)
|
||||
.disabled(viewModel.isUpdatingLocation)
|
||||
.opacity(viewModel.isUpdatingLocation ? 0.6 : 1)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(hex: 0xF6FAFF), in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color(hex: 0xC8DDFF), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var paymentUploadRow: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "creditcard.fill")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("上传线下微信/支付宝支付截图(选填)")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text("支持 JPG、PNG 格式,最多 3 张")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let first = viewModel.paymentImages.first {
|
||||
attachmentTile(first) {
|
||||
viewModel.paymentImages.removeAll { $0.id == first.id }
|
||||
}
|
||||
} else {
|
||||
addAttachmentTile {
|
||||
viewModel.paymentImages.append(WildReportAttachment(kind: .payment, title: "支付截图1"))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(hex: 0xFBFCFE), in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(AppDesign.inputBorder, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var submitButton: some View {
|
||||
Button {
|
||||
viewModel.submitReport()
|
||||
} label: {
|
||||
Text("提交举报")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 14))
|
||||
.shadow(color: AppDesign.primary.opacity(0.22), radius: 12, y: 6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
private var loadingContent: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(Color(hex: 0xE8EEF7))
|
||||
.frame(height: 64)
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.fill(Color(hex: 0xE8EEF7))
|
||||
.frame(height: 600)
|
||||
}
|
||||
.frame(maxWidth: contentMaxWidth)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.redacted(reason: .placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
private func stateContent(
|
||||
title: String,
|
||||
systemImage: String,
|
||||
message: String,
|
||||
actionTitle: String,
|
||||
action: @escaping () -> 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user