新增举报摄影师模块并接入首页路由,桌面应用名改为随心瞰商家版。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-01 16:51:22 +08:00
parent 19d681192c
commit fcb86d43f5
25 changed files with 5515 additions and 48 deletions

View File

@ -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
}
}