新增举报摄影师模块并接入首页路由,桌面应用名改为随心瞰商家版。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,992 @@
|
||||
//
|
||||
// WildPhotographerReportDetailViews
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/7/1.
|
||||
//
|
||||
|
||||
import MapKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct WildPhotographerReportDetailView: View {
|
||||
@StateObject private var viewModel: WildPhotographerReportDetailViewModel
|
||||
@ObservedObject var homeViewModel: WildPhotographerReportHomeViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@State private var previewImageURL: String?
|
||||
|
||||
init(record: WildReportRecord, homeViewModel: WildPhotographerReportHomeViewModel) {
|
||||
self.homeViewModel = homeViewModel
|
||||
_viewModel = StateObject(
|
||||
wrappedValue: WildPhotographerReportDetailViewModel(record: record, homeViewModel: homeViewModel)
|
||||
)
|
||||
}
|
||||
|
||||
private var supplementaryItems: [WildReportSupplementaryEvidence] {
|
||||
homeViewModel.supplementaryEvidences(for: viewModel.record.id)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
summaryCard
|
||||
reportInfoCard
|
||||
if viewModel.showHandlerInfo, let handlerInfo = viewModel.handlerInfo {
|
||||
handlerInfoCard(handlerInfo)
|
||||
}
|
||||
evidenceCard
|
||||
if !supplementaryItems.isEmpty {
|
||||
supplementaryEvidenceCard
|
||||
}
|
||||
timelineCard
|
||||
}
|
||||
.frame(maxWidth: contentMaxWidth)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 120)
|
||||
}
|
||||
.navigationTitle("举报详情")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(Color(hex: 0xF8FAFF).ignoresSafeArea())
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
bottomActionBar
|
||||
}
|
||||
.onChange(of: viewModel.actionMessage) { message in
|
||||
guard let message else { return }
|
||||
toastCenter.show(message)
|
||||
viewModel.actionMessage = nil
|
||||
}
|
||||
.fullScreenCover(item: Binding(
|
||||
get: { previewImageURL.map { WildReportPreviewImageURL(url: $0) } },
|
||||
set: { previewImageURL = $0?.url }
|
||||
)) { item in
|
||||
WildReportMapURLImagePreviewView(imageURL: item.url) {
|
||||
previewImageURL = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryCard: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
Image(systemName: "exclamationmark.shield.fill")
|
||||
.font(.system(size: 30, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 62, height: 62)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 16))
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(viewModel.heroTitle)
|
||||
.font(.system(size: 23, weight: .heavy))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Text(viewModel.scenicAreaName)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 27)
|
||||
.background(AppDesign.primarySoft, in: Capsule())
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button {
|
||||
viewModel.shareReport()
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
Text("分享")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(width: 48, height: 48)
|
||||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("分享举报详情")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("举报编号")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(viewModel.record.id)
|
||||
.font(.system(size: 19, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(viewModel.displayStatusText)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(viewModel.displayStatusColor)
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 38)
|
||||
.background(viewModel.displayStatusColor.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var reportInfoCard: some View {
|
||||
VStack(spacing: 0) {
|
||||
reportInfoRow(title: "举报类型", value: viewModel.record.reportType.rawValue)
|
||||
Divider().padding(.leading, 18)
|
||||
reportInfoRow(title: "举报说明", value: viewModel.descriptionText)
|
||||
Divider().padding(.leading, 18)
|
||||
reportInfoRow(title: "微信号/手机号", value: viewModel.contactText)
|
||||
}
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func reportInfoRow(title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(value)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
|
||||
private func handlerInfoCard(_ info: WildReportHandlerInfo) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("处理信息")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
handlerInfoRow(label: "处理人", value: info.handlerName)
|
||||
Divider().padding(.leading, 18)
|
||||
handlerInfoPhoneRow(info: info)
|
||||
Divider().padding(.leading, 18)
|
||||
handlerInfoRow(label: "处理时间", value: info.processingAt)
|
||||
Divider().padding(.leading, 18)
|
||||
handlerInfoRow(label: "处理完成时间", value: info.processedAt)
|
||||
Divider().padding(.leading, 18)
|
||||
handlerInfoMultilineRow(label: "处理意见", value: info.processOpinion)
|
||||
|
||||
if !info.completionMedia.isEmpty {
|
||||
Divider().padding(.leading, 18)
|
||||
handlerCompletionMediaSection(info: info)
|
||||
}
|
||||
}
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func handlerInfoRow(label: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
|
||||
private func handlerInfoMultilineRow(label: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(label)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(value)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineSpacing(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
|
||||
private func handlerInfoPhoneRow(info: WildReportHandlerInfo) -> some View {
|
||||
HStack {
|
||||
Text("处理人电话")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Spacer()
|
||||
Button {
|
||||
guard let url = URL(string: "tel://\(info.handlerPhone)") else { return }
|
||||
UIApplication.shared.open(url)
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(info.maskedHandlerPhone)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
Image(systemName: "phone.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 14)
|
||||
}
|
||||
|
||||
private func handlerCompletionMediaSection(info: WildReportHandlerInfo) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "photo.on.rectangle.angled")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
Text("处理凭证")
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
Spacer()
|
||||
Text(info.completionMediaSummary)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 14)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(info.completionMedia) { item in
|
||||
Button {
|
||||
if item.kind == .image {
|
||||
previewImageURL = item.coverURL
|
||||
} else {
|
||||
viewModel.showMediaPreview(kind: "处理凭证视频")
|
||||
}
|
||||
} label: {
|
||||
handlerCompletionMediaThumbnail(item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
}
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlerCompletionMediaThumbnail(_ item: WildReportCompletionMediaItem) -> some View {
|
||||
ZStack {
|
||||
AsyncImage(url: URL(string: item.coverURL)) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
default:
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(hex: 0xE5E7EB))
|
||||
.overlay {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 108, height: 108)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
if item.kind == .video {
|
||||
Color.black.opacity(0.28)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 28, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if let duration = item.duration, !duration.isEmpty {
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(duration)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 3)
|
||||
.background(.black.opacity(0.45), in: Capsule())
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 108, height: 108)
|
||||
}
|
||||
|
||||
private var supplementaryEvidenceCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("补充证据记录")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 16)
|
||||
|
||||
ForEach(Array(supplementaryItems.enumerated()), id: \.element.id) { index, item in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text(item.submitTime)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
Spacer()
|
||||
Text("第\(supplementaryItems.count - index)次补充")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
Text(item.summaryText)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
if index < supplementaryItems.count - 1 {
|
||||
Divider().padding(.leading, 18)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var evidenceCard: some View {
|
||||
VStack(spacing: 0) {
|
||||
if !viewModel.reporterMediaItems.isEmpty {
|
||||
reporterMaterialsSection
|
||||
Divider().padding(.horizontal, 18)
|
||||
}
|
||||
|
||||
evidenceSectionHeader(icon: "mappin.circle.fill", title: "位置", value: viewModel.detailAddress) {
|
||||
viewModel.showLocationPreview()
|
||||
}
|
||||
locationPreview
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var reporterMaterialsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Button {
|
||||
viewModel.showMediaPreview(kind: "举报人材料")
|
||||
} label: {
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "photo.on.rectangle.angled")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
Text("举报人材料")
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
Spacer()
|
||||
Text(viewModel.reporterMediaSummary)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0xB6BECA))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 14)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(viewModel.reporterMediaItems) { item in
|
||||
Button {
|
||||
viewModel.showReporterMediaPreview(item)
|
||||
} label: {
|
||||
reporterMediaThumbnail(item)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
}
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
}
|
||||
|
||||
private func reporterMediaThumbnail(_ item: WildReportReporterMediaItem) -> some View {
|
||||
Group {
|
||||
switch item {
|
||||
case .image(let index):
|
||||
WildReportEvidenceImagePreview(index: index)
|
||||
case .video:
|
||||
WildReportEvidenceVideoPreview()
|
||||
}
|
||||
}
|
||||
.frame(width: 108, height: 108)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private func evidenceSectionHeader(icon: String, title: String, value: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(width: 50, height: 50)
|
||||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0xB6BECA))
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var locationPreview: some View {
|
||||
WildReportEvidenceMapPreview(
|
||||
scenicName: viewModel.scenicAreaName,
|
||||
detailAddress: viewModel.detailAddress
|
||||
)
|
||||
.frame(height: 98)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
|
||||
private var timelineCard: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(viewModel.progressSteps.enumerated()), id: \.element.id) { index, step in
|
||||
timelineRow(step, isLast: index == viewModel.progressSteps.count - 1)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.vertical, 18)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 18))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func timelineRow(_ step: WildReportProgressStep, isLast: Bool) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(spacing: 0) {
|
||||
timelineNode(for: step.state)
|
||||
if !isLast {
|
||||
Rectangle()
|
||||
.fill(step.state == .pending ? Color(hex: 0xD1D5DB).opacity(0.8) : AppDesign.primary)
|
||||
.frame(width: step.state == .pending ? 1 : 2)
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.frame(width: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(step.title)
|
||||
.font(.system(size: 16, weight: step.state == .current ? .bold : .semibold))
|
||||
.foregroundStyle(step.state == .pending ? Color(hex: 0x9CA3AF) : AppDesign.textPrimary)
|
||||
if step.state != .pending && !step.timeText.isEmpty {
|
||||
Text(step.timeText)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(step.state == .current ? AppDesign.primary : AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.bottom, isLast ? 0 : 18)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
|
||||
private func timelineNode(for state: WildReportProgressStepState) -> some View {
|
||||
ZStack {
|
||||
switch state {
|
||||
case .completed:
|
||||
Circle()
|
||||
.fill(AppDesign.primary)
|
||||
.frame(width: 24, height: 24)
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
case .current:
|
||||
Circle()
|
||||
.stroke(AppDesign.primary, lineWidth: 2)
|
||||
.frame(width: 24, height: 24)
|
||||
Circle()
|
||||
.fill(AppDesign.primary)
|
||||
.frame(width: 10, height: 10)
|
||||
case .pending:
|
||||
Circle()
|
||||
.stroke(Color(hex: 0xD1D5DB), lineWidth: 2)
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomActionBar: some View {
|
||||
HStack(spacing: 12) {
|
||||
NavigationLink {
|
||||
WildReportSupplementEvidenceView(
|
||||
reportID: viewModel.record.id,
|
||||
homeViewModel: homeViewModel
|
||||
)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "doc.badge.plus")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
Text("补充证据")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(AppDesign.primary, lineWidth: 1.2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
NavigationLink {
|
||||
WildReportRiskMapView(
|
||||
record: viewModel.record,
|
||||
riskPoints: homeViewModel.riskPoints
|
||||
)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "map.fill")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
Text("查看附近风险地图")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
.background(.white.opacity(0.96))
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(Color.black.opacity(0.06))
|
||||
.frame(height: 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
private var contentMaxWidth: CGFloat {
|
||||
horizontalSizeClass == .regular ? 760 : .infinity
|
||||
}
|
||||
}
|
||||
|
||||
private struct WildReportEvidenceImagePreview: View {
|
||||
let index: Int
|
||||
|
||||
private var gradientColors: [Color] {
|
||||
switch index {
|
||||
case 1:
|
||||
return [Color(hex: 0xD7E8F8), Color(hex: 0x7E9CB4)]
|
||||
case 2:
|
||||
return [Color(hex: 0xBFD9EE), Color(hex: 0x435B70)]
|
||||
default:
|
||||
return [Color(hex: 0xC7DFEC), Color(hex: 0x2D3D4B)]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
LinearGradient(
|
||||
colors: gradientColors,
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
Image(systemName: "mountain.2.fill")
|
||||
.font(.system(size: 52, weight: .regular))
|
||||
.foregroundStyle(.white.opacity(0.32))
|
||||
.offset(x: -16, y: -8)
|
||||
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.offset(x: -10, y: -18)
|
||||
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: 44, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x101827).opacity(0.78))
|
||||
.offset(x: -24 + CGFloat(index * 12), y: 10)
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
|
||||
private struct WildReportEvidenceVideoPreview: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WildReportEvidenceImagePreview(index: 2)
|
||||
|
||||
Circle()
|
||||
.stroke(.white, lineWidth: 2.5)
|
||||
.frame(width: 42, height: 42)
|
||||
.background(.black.opacity(0.08), in: Circle())
|
||||
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.offset(x: 2)
|
||||
|
||||
Text("00:32")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.shadow(color: .black.opacity(0.25), radius: 4, y: 2)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
|
||||
.padding(10)
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
|
||||
private struct WildReportEvidenceMapPreview: View {
|
||||
let scenicName: String
|
||||
let detailAddress: String
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [Color(hex: 0xE1F4D8), Color(hex: 0xBDE6C7)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
mapPath(in: proxy.size)
|
||||
.stroke(
|
||||
Color(hex: 0x65A6D7).opacity(0.75),
|
||||
style: StrokeStyle(lineWidth: 1.4, dash: [5, 5])
|
||||
)
|
||||
|
||||
mapPathSecondary(in: proxy.size)
|
||||
.stroke(
|
||||
Color.white.opacity(0.88),
|
||||
style: StrokeStyle(lineWidth: 5, lineCap: .round)
|
||||
)
|
||||
mapPathSecondary(in: proxy.size)
|
||||
.stroke(Color(hex: 0x99C59C), lineWidth: 1)
|
||||
|
||||
Text(detailAddress.replacingOccurrences(of: "附近", with: ""))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x64748B))
|
||||
.position(x: proxy.size.width * 0.47, y: proxy.size.height * 0.32)
|
||||
|
||||
Text(scenicName)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x418C48))
|
||||
.position(x: proxy.size.width * 0.78, y: proxy.size.height * 0.58)
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(hex: 0xEF4444).opacity(0.18))
|
||||
.frame(width: 64, height: 64)
|
||||
Circle()
|
||||
.fill(Color(hex: 0xEF4444).opacity(0.22))
|
||||
.frame(width: 44, height: 44)
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.font(.system(size: 36, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0xEF4444), .white)
|
||||
}
|
||||
.position(x: proxy.size.width * 0.53, y: proxy.size.height * 0.54)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func mapPath(in size: CGSize) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: size.width * 0.05, y: size.height * 0.32))
|
||||
path.addCurve(
|
||||
to: CGPoint(x: size.width * 0.95, y: size.height * 0.22),
|
||||
control1: CGPoint(x: size.width * 0.28, y: size.height * 0.62),
|
||||
control2: CGPoint(x: size.width * 0.56, y: size.height * 0.02)
|
||||
)
|
||||
return path
|
||||
}
|
||||
|
||||
private func mapPathSecondary(in size: CGSize) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: size.width * 0.08, y: size.height * 0.72))
|
||||
path.addCurve(
|
||||
to: CGPoint(x: size.width * 0.88, y: size.height * 0.78),
|
||||
control1: CGPoint(x: size.width * 0.30, y: size.height * 0.42),
|
||||
control2: CGPoint(x: size.width * 0.56, y: size.height * 0.92)
|
||||
)
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
struct WildReportSupplementEvidenceView: View {
|
||||
let reportID: String
|
||||
@ObservedObject var homeViewModel: WildPhotographerReportHomeViewModel
|
||||
|
||||
@StateObject private var viewModel: WildReportSupplementEvidenceViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
|
||||
init(reportID: String, homeViewModel: WildPhotographerReportHomeViewModel) {
|
||||
self.reportID = reportID
|
||||
self.homeViewModel = homeViewModel
|
||||
_viewModel = StateObject(
|
||||
wrappedValue: WildReportSupplementEvidenceViewModel(
|
||||
reportID: reportID,
|
||||
homeViewModel: homeViewModel
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
supplementTextCard
|
||||
supplementUploadCard(
|
||||
icon: "photo.fill",
|
||||
title: "上传图片",
|
||||
subtitle: "支持 JPG、PNG 格式,最多 9 张",
|
||||
attachments: $viewModel.images,
|
||||
maxCount: 9,
|
||||
onAdd: viewModel.addImage
|
||||
)
|
||||
supplementUploadCard(
|
||||
icon: "video.fill",
|
||||
title: "上传视频",
|
||||
subtitle: "支持 MP4 格式,最多 3 段",
|
||||
attachments: $viewModel.videos,
|
||||
maxCount: 3,
|
||||
onAdd: viewModel.addVideo
|
||||
)
|
||||
submitButton
|
||||
}
|
||||
.frame(maxWidth: contentMaxWidth)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
.navigationTitle("补充证据")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(Color(hex: 0xF8FAFF).ignoresSafeArea())
|
||||
.onChange(of: viewModel.validationMessage) { message in
|
||||
guard let message else { return }
|
||||
toastCenter.show(message)
|
||||
viewModel.validationMessage = nil
|
||||
}
|
||||
.onChange(of: viewModel.didSubmit) { didSubmit in
|
||||
guard didSubmit else { return }
|
||||
dismiss()
|
||||
}
|
||||
.onChange(of: viewModel.text) { newValue in
|
||||
viewModel.updateText(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
private var supplementTextCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("补充说明")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
|
||||
ZStack(alignment: .topLeading) {
|
||||
TextEditor(text: $viewModel.text)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 10)
|
||||
.frame(minHeight: 112)
|
||||
.scrollContentBackground(.hidden)
|
||||
|
||||
if viewModel.text.isEmpty {
|
||||
Text("请补充新的线索说明...")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Color(hex: 0x9CA3AF))
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 18)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
Text("\(viewModel.text.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)
|
||||
)
|
||||
}
|
||||
.padding(16)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func supplementUploadCard(
|
||||
icon: String,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
attachments: Binding<[WildReportAttachment]>,
|
||||
maxCount: Int,
|
||||
onAdd: @escaping () -> Void
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text(subtitle)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(attachments.wrappedValue) { attachment in
|
||||
supplementAttachmentTile(attachment.title) {
|
||||
attachments.wrappedValue.removeAll { $0.id == attachment.id }
|
||||
}
|
||||
}
|
||||
|
||||
if attachments.wrappedValue.count < maxCount {
|
||||
supplementAddTile(action: onAdd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func supplementAttachmentTile(_ title: String, onRemove: @escaping () -> Void) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(AppDesign.primarySoft)
|
||||
.frame(width: 72, height: 72)
|
||||
.overlay {
|
||||
Text(title)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(6)
|
||||
}
|
||||
|
||||
Button(action: onRemove) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(Color(hex: 0x6B7280))
|
||||
.background(Circle().fill(.white))
|
||||
}
|
||||
.offset(x: 6, y: -6)
|
||||
}
|
||||
}
|
||||
|
||||
private func supplementAddTile(action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.stroke(AppDesign.primary, style: StrokeStyle(lineWidth: 1.2, dash: [4, 4]))
|
||||
.frame(width: 72, height: 72)
|
||||
.overlay {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var submitButton: some View {
|
||||
Button {
|
||||
viewModel.submit()
|
||||
} label: {
|
||||
Text("提交补充证据")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var contentMaxWidth: CGFloat {
|
||||
horizontalSizeClass == .regular ? 760 : .infinity
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,451 @@
|
||||
//
|
||||
// WildPhotographerReportHomeViews
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/7/1.
|
||||
//
|
||||
|
||||
import MapKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct WildPhotographerReportHomeView: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@StateObject private var viewModel = WildPhotographerReportHomeViewModel()
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch viewModel.pageState {
|
||||
case .normal:
|
||||
normalContent
|
||||
case .loading:
|
||||
loadingContent
|
||||
case .empty:
|
||||
stateContent(
|
||||
title: viewModel.emptyTitle,
|
||||
systemImage: viewModel.emptyImage,
|
||||
message: viewModel.emptyMessage,
|
||||
actionTitle: "恢复演示数据"
|
||||
) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
viewModel.restoreDemoData()
|
||||
}
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var normalContent: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
reportTab
|
||||
}
|
||||
.frame(maxWidth: contentMaxWidth)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
}
|
||||
|
||||
private var reportTab: some View {
|
||||
VStack(spacing: 12) {
|
||||
heroCard
|
||||
riskMapEntry
|
||||
noticeCard
|
||||
trustBadges
|
||||
}
|
||||
}
|
||||
|
||||
private var riskMapEntry: some View {
|
||||
NavigationLink {
|
||||
WildReportRiskMapView(
|
||||
record: viewModel.reports.first ?? WildPhotographerReportMockStore.seedReports[0],
|
||||
riskPoints: viewModel.riskPoints
|
||||
)
|
||||
} label: {
|
||||
HStack(spacing: 16) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(AppDesign.primarySoft)
|
||||
Image(systemName: "map.fill")
|
||||
.font(.system(size: 26, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
.frame(width: 54, height: 54)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("附近风险地图")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text("查看附近摄影师风险点位")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0xB6BECA))
|
||||
}
|
||||
.padding(16)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var heroCard: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
HStack(alignment: .top, spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("实名举报")
|
||||
.font(.system(size: 26, weight: .heavy))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text("摄影师")
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.font(.system(size: 26, weight: .heavy))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
|
||||
Spacer(minLength: 10)
|
||||
|
||||
WildReportHeroIllustration()
|
||||
.frame(width: 112, height: 112)
|
||||
.padding(.top, -2)
|
||||
}
|
||||
|
||||
Text("可上传图片、视频、文字和位置,景区公安将接收并现场处理。")
|
||||
.font(.system(size: 15, weight: .regular))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineSpacing(4)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
NavigationLink {
|
||||
WildPhotographerReportSubmitView(homeViewModel: viewModel)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.shield.fill")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
Text("立即举报")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
NavigationLink {
|
||||
WildPhotographerReportListView(homeViewModel: viewModel)
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "doc.text.fill")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
Text("我的举报")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color(hex: 0xBFDAFF), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [.white, Color(hex: 0xEEF6FF)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
in: RoundedRectangle(cornerRadius: 20)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(Color(hex: 0xBFD7FF), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: Color(hex: 0x0F172A).opacity(0.05), radius: 14, y: 8)
|
||||
}
|
||||
|
||||
private var noticeCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "list.clipboard.fill")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
Text("举报须知")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
NavigationLink {
|
||||
WildReportRuleView()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "shield.lefthalf.filled")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Text("举报规则")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 30)
|
||||
.background(AppDesign.primarySoft, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
bullet("需实名登录")
|
||||
bullet("请尽量上传清晰证据")
|
||||
bullet("恶意举报将被追责")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var trustBadges: some View {
|
||||
HStack(spacing: 10) {
|
||||
trustBadge(icon: "person.crop.circle.badge.checkmark", title: "实名", subtitle: "身份核验 真实可靠", tint: AppDesign.primary)
|
||||
trustBadge(icon: "checkmark.shield.fill", title: "安全", subtitle: "数据加密 隐私保护", tint: AppDesign.success)
|
||||
trustBadge(icon: "person.text.rectangle.fill", title: "景区公安处理", subtitle: "专业受理 依法处置", tint: AppDesign.primary)
|
||||
}
|
||||
}
|
||||
|
||||
private var loadingContent: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(Color(hex: 0xE8EEF7))
|
||||
.frame(height: 250)
|
||||
ForEach(0..<3, id: \.self) { _ in
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color(hex: 0xE8EEF7))
|
||||
.frame(height: 86)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: contentMaxWidth)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 28)
|
||||
.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)
|
||||
.padding(.bottom, 28)
|
||||
.background(Color(hex: 0xF8FAFF))
|
||||
}
|
||||
|
||||
private func bullet(_ text: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(AppDesign.primary)
|
||||
.frame(width: 6, height: 6)
|
||||
Text(text)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Color(hex: 0x4B5563))
|
||||
}
|
||||
}
|
||||
|
||||
private func trustBadge(icon: String, title: String, subtitle: String, tint: Color) -> some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(tint.opacity(0.10), in: Circle())
|
||||
|
||||
Text(title)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(subtitle)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.frame(minHeight: 86)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 14))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var contentMaxWidth: CGFloat {
|
||||
horizontalSizeClass == .regular ? 760 : .infinity
|
||||
}
|
||||
}
|
||||
|
||||
private struct WildReportRuleView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
ruleHero
|
||||
ruleSection(
|
||||
icon: "person.badge.shield.checkmark.fill",
|
||||
title: "实名提交",
|
||||
items: [
|
||||
"举报人需完成实名登录后提交。",
|
||||
"平台仅向景区公安处理人员展示必要核验信息。",
|
||||
"举报记录将用于后续处理进度追踪。"
|
||||
]
|
||||
)
|
||||
ruleSection(
|
||||
icon: "photo.on.rectangle.angled",
|
||||
title: "证据要求",
|
||||
items: [
|
||||
"请尽量上传清晰图片、视频、位置和文字说明。",
|
||||
"证据应与疑似违规揽客、线下收费、未备案拍摄服务相关。",
|
||||
"支付截图可作为辅助证据,敏感信息可适当遮挡。"
|
||||
]
|
||||
)
|
||||
ruleSection(
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
title: "禁止恶意举报",
|
||||
items: [
|
||||
"禁止捏造事实、冒用他人信息或重复恶意举报。",
|
||||
"恶意举报将被记录并可能承担相应责任。",
|
||||
"如信息有误,可在举报详情中补充说明。"
|
||||
]
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
.navigationTitle("举报规则")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(Color(hex: 0xF8FAFF).ignoresSafeArea())
|
||||
}
|
||||
|
||||
private var ruleHero: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Label("规则说明", systemImage: "shield.checkered")
|
||||
.font(.system(size: 19, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text("请根据现场真实情况提交举报,景区公安将结合证据和位置进行核查处理。")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [.white, Color(hex: 0xEEF6FF)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
in: RoundedRectangle(cornerRadius: 18)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.stroke(Color(hex: 0xCFE1FF), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private func ruleSection(icon: String, title: String, items: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 10))
|
||||
Text(title)
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(items, id: \.self) { item in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Circle()
|
||||
.fill(AppDesign.primary)
|
||||
.frame(width: 5, height: 5)
|
||||
.padding(.top, 7)
|
||||
Text(item)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineSpacing(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,361 @@
|
||||
//
|
||||
// WildPhotographerReportListViews
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/7/1.
|
||||
//
|
||||
|
||||
import MapKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private struct WildReportRiskDetailView: View {
|
||||
let point: WildReportRiskPoint
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
WildReportSectionHeader(icon: "mappin.and.ellipse", title: point.title, subtitle: point.location)
|
||||
|
||||
detailCard {
|
||||
detailRow("位置", point.location)
|
||||
detailRow("风险等级", point.riskLevel)
|
||||
detailRow("最近举报时间", point.latestTime)
|
||||
detailRow("举报次数", "\(point.evidenceCount)次")
|
||||
}
|
||||
|
||||
detailCard {
|
||||
Text("风险说明")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text(point.description)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineSpacing(5)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.navigationTitle("风险点详情")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(AppDesign.background.ignoresSafeArea())
|
||||
}
|
||||
|
||||
private func detailCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
content()
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
|
||||
private func detailRow(_ title: String, _ value: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WildReportRiskPointListView: View {
|
||||
@ObservedObject var homeViewModel: WildPhotographerReportHomeViewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
WildReportSectionHeader(
|
||||
icon: "map.fill",
|
||||
title: "附近风险地图",
|
||||
subtitle: "查看附近摄影师风险点位"
|
||||
)
|
||||
|
||||
ForEach(homeViewModel.riskPoints) { point in
|
||||
NavigationLink {
|
||||
WildReportRiskDetailView(point: point)
|
||||
} label: {
|
||||
riskPointRow(point)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: contentMaxWidth)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
.navigationTitle("附近风险地图")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(Color(hex: 0xF8FAFF).ignoresSafeArea())
|
||||
}
|
||||
|
||||
private func riskPointRow(_ point: WildReportRiskPoint) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "mappin.and.ellipse")
|
||||
.font(.system(size: 21, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(point.title)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
Text(point.location)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(1)
|
||||
Text("\(point.distance) · \(point.evidenceCount)条线索 · \(point.latestTime)")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0xB6BECA))
|
||||
}
|
||||
.padding(14)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var contentMaxWidth: CGFloat {
|
||||
horizontalSizeClass == .regular ? 760 : .infinity
|
||||
}
|
||||
}
|
||||
|
||||
struct WildPhotographerReportListView: View {
|
||||
@ObservedObject var homeViewModel: WildPhotographerReportHomeViewModel
|
||||
@StateObject private var viewModel: WildPhotographerReportListViewModel
|
||||
|
||||
init(homeViewModel: WildPhotographerReportHomeViewModel) {
|
||||
self.homeViewModel = homeViewModel
|
||||
_viewModel = StateObject(wrappedValue: WildPhotographerReportListViewModel(homeViewModel: homeViewModel))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
WildPhotographerReportListContent(homeViewModel: homeViewModel, viewModel: viewModel)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 14)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.navigationTitle("我的举报")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(Color(hex: 0xF8FAFF).ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
struct WildPhotographerReportListContent: View {
|
||||
@ObservedObject var homeViewModel: WildPhotographerReportHomeViewModel
|
||||
@ObservedObject var viewModel: WildPhotographerReportListViewModel
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@State private var selectedRecord: WildReportRecord?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
filterTabBar
|
||||
|
||||
if viewModel.filteredReports.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ForEach(viewModel.filteredReports) { record in
|
||||
WildReportListCard(
|
||||
record: record,
|
||||
onOpen: {
|
||||
selectedRecord = record
|
||||
},
|
||||
onShare: {
|
||||
viewModel.shareReport(record)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(viewModel.footerTip)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Color(hex: 0x9CA3AF))
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.appNavigationDestination(item: $selectedRecord) { record in
|
||||
WildPhotographerReportDetailView(record: record, homeViewModel: homeViewModel)
|
||||
}
|
||||
.onChange(of: viewModel.shareMessage) { message in
|
||||
guard let message else { return }
|
||||
toastCenter.show(message)
|
||||
viewModel.shareMessage = nil
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.22), value: viewModel.selectedFilter)
|
||||
.animation(.easeInOut(duration: 0.22), value: viewModel.filteredReports.count)
|
||||
}
|
||||
|
||||
private var filterTabBar: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(WildReportListFilter.allCases) { filter in
|
||||
Button {
|
||||
viewModel.selectFilter(filter)
|
||||
} label: {
|
||||
Text(filter.rawValue)
|
||||
.font(.system(size: 13, weight: viewModel.selectedFilter == filter ? .bold : .medium))
|
||||
.foregroundStyle(viewModel.selectedFilter == filter ? AppDesign.primary : Color(hex: 0x6B7280))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
viewModel.selectedFilter == filter
|
||||
? Color(hex: 0xEAF3FF)
|
||||
: Color.clear,
|
||||
in: RoundedRectangle(cornerRadius: 8)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "doc.text.magnifyingglass")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(Color(hex: 0xB6BECA))
|
||||
Text("暂无相关举报记录")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 48)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
}
|
||||
|
||||
struct WildReportListCard: View {
|
||||
let record: WildReportRecord
|
||||
let onOpen: () -> Void
|
||||
let onShare: () -> Void
|
||||
|
||||
private var scenicName: String {
|
||||
WildReportLocationParser.split(record.location).scenic
|
||||
}
|
||||
|
||||
private var detailAddress: String {
|
||||
WildReportLocationParser.split(record.location).detail
|
||||
}
|
||||
|
||||
private var iconTint: Color {
|
||||
record.status.displayColor
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 13) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(iconTint.opacity(0.12))
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(iconTint)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top) {
|
||||
Text(record.title)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 8)
|
||||
HStack(spacing: 6) {
|
||||
Text(record.status.listDisplayText)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(record.status.listDisplayColor)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 24)
|
||||
.background(record.status.listDisplayColor.opacity(0.12), in: Capsule())
|
||||
|
||||
Button {
|
||||
onShare()
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(AppDesign.primarySoft, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("分享举报")
|
||||
}
|
||||
}
|
||||
|
||||
Text(scenicName)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 22)
|
||||
.background(AppDesign.primary.opacity(0.10), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
listMetaRow(title: "举报编号", value: record.id)
|
||||
listMetaRow(title: "举报时间", value: record.submitTime)
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "mappin.and.ellipse")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x9CA3AF))
|
||||
Text(detailAddress)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0xC4CAD4))
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
.padding(15)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
onOpen()
|
||||
}
|
||||
}
|
||||
|
||||
private func listMetaRow(title: String, value: String) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Color(hex: 0x9CA3AF))
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,803 @@
|
||||
//
|
||||
// WildPhotographerReportMapViews
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/7/1.
|
||||
//
|
||||
|
||||
import MapKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct WildReportRiskMapView: View {
|
||||
let record: WildReportRecord
|
||||
let riskPoints: [WildReportRiskPoint]
|
||||
|
||||
@StateObject private var viewModel: WildReportRiskMapViewModel
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
init(record: WildReportRecord, riskPoints: [WildReportRiskPoint]) {
|
||||
self.record = record
|
||||
self.riskPoints = riskPoints
|
||||
_viewModel = StateObject(wrappedValue: WildReportRiskMapViewModel(record: record, riskPoints: riskPoints))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
mapSection
|
||||
legendBar
|
||||
Divider()
|
||||
bottomPanel
|
||||
}
|
||||
.navigationTitle("附近风险地图")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(Color(hex: 0xF8FAFF).ignoresSafeArea())
|
||||
.fullScreenCover(item: $viewModel.previewMedia) { media in
|
||||
WildReportMapMediaPreviewView(media: media) {
|
||||
viewModel.dismissMediaPreview()
|
||||
}
|
||||
}
|
||||
.fullScreenCover(item: $viewModel.previewImageURL) { item in
|
||||
WildReportMapURLImagePreviewView(imageURL: item.url) {
|
||||
viewModel.dismissImagePreview()
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .bottom) {
|
||||
if let toast = viewModel.toastMessage {
|
||||
Text(toast)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(Color.black.opacity(0.78), in: Capsule())
|
||||
.padding(.bottom, 24)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) {
|
||||
viewModel.clearToast()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mapSection: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
Map(
|
||||
coordinateRegion: $viewModel.region,
|
||||
annotationItems: viewModel.markers
|
||||
) { marker in
|
||||
MapAnnotation(coordinate: marker.coordinate) {
|
||||
Button {
|
||||
viewModel.selectMarker(marker)
|
||||
} label: {
|
||||
WildReportMapMarkerView(
|
||||
marker: marker,
|
||||
isSelected: viewModel.selectedMarkerID == marker.id
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "mountain.2.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Text(viewModel.scenicAreaName)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 30)
|
||||
.background(.white.opacity(0.94), in: Capsule())
|
||||
.padding(12)
|
||||
}
|
||||
.frame(height: horizontalSizeClass == .regular ? 420 : 340)
|
||||
}
|
||||
|
||||
private var legendBar: some View {
|
||||
HStack(spacing: 0) {
|
||||
legendItem(color: AppDesign.primary, title: "我的位置")
|
||||
legendItem(color: AppDesign.danger, title: "疑似打野")
|
||||
legendItem(color: Color(hex: 0xF59E0B), title: "处理中线索")
|
||||
legendItem(color: AppDesign.success, title: "内部摄影师")
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 12)
|
||||
.background(.white)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var bottomPanel: some View {
|
||||
ScrollView(.vertical, showsIndicators: true) {
|
||||
VStack(spacing: 0) {
|
||||
if let marker = viewModel.selectedMarker {
|
||||
if case .activeClue(let clue) = marker.detail {
|
||||
WildReportRadarResultCardView(
|
||||
clue: clue,
|
||||
onPreviewImage: { url in
|
||||
viewModel.showImagePreview(url)
|
||||
},
|
||||
onNavigate: {
|
||||
viewModel.navigateToSelectedMarker()
|
||||
},
|
||||
onShare: {
|
||||
viewModel.shareSelectedClue()
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Text("点击地图标记查看详情")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .top)
|
||||
}
|
||||
.frame(minHeight: 0, maxHeight: .infinity)
|
||||
.background(Color(hex: 0xF8FAFF))
|
||||
}
|
||||
|
||||
private func legendItem(color: Color, title: String) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
WildReportMapDot(color: color, size: 10)
|
||||
Text(title)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WildReportRadarResultCardView: View {
|
||||
let clue: WildReportRadarActiveClue
|
||||
let onPreviewImage: (String) -> Void
|
||||
let onNavigate: () -> Void
|
||||
let onShare: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
headerSection
|
||||
|
||||
if clue.type == .blue, let nearestAbnormalText = clue.nearestAbnormalText {
|
||||
compareText(nearestAbnormalText)
|
||||
}
|
||||
|
||||
if clue.type == .green {
|
||||
photographerProfile
|
||||
}
|
||||
|
||||
if clue.type == .red {
|
||||
reporterInfo
|
||||
}
|
||||
|
||||
if clue.type == .red, !clue.reportContents.isEmpty {
|
||||
reportContentsSection
|
||||
}
|
||||
|
||||
if clue.type == .green, let summary = clue.summary {
|
||||
compareText(summary)
|
||||
}
|
||||
|
||||
if clue.type == .green, let detail = clue.detail {
|
||||
detailSection(detail)
|
||||
}
|
||||
|
||||
if clue.type == .red, !clue.images.isEmpty {
|
||||
imagesSection
|
||||
}
|
||||
|
||||
if clue.type == .red, clue.processingStarted, let record = clue.record {
|
||||
processingPanel(record: record)
|
||||
}
|
||||
|
||||
if clue.completed, let completionPhoto = clue.completionPhoto {
|
||||
completionProofSection(completionPhoto)
|
||||
}
|
||||
|
||||
if !clue.completed {
|
||||
actionButtons
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 16))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.stroke(AppDesign.border, lineWidth: 1)
|
||||
)
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "location.fill")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(headerIconColor, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if clue.type == .red {
|
||||
Text("线索 ID:\(clue.id)")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
HStack(spacing: 12) {
|
||||
if let distanceText = clue.distanceText {
|
||||
Text("距离:距离您\(distanceText)")
|
||||
}
|
||||
if let updatedAt = clue.updatedAt {
|
||||
Text("时间:\(updatedAt)")
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
} else {
|
||||
Text(clue.displayTitle)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
if clue.type == .green {
|
||||
HStack(spacing: 12) {
|
||||
if let distance = clue.distance {
|
||||
Text("距蓝点 \(distance)")
|
||||
}
|
||||
if let direction = clue.direction {
|
||||
Text(direction)
|
||||
}
|
||||
if let updatedAt = clue.updatedAt {
|
||||
Text(updatedAt)
|
||||
}
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Text(clue.statusText)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(statusColor)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 24)
|
||||
.background(statusColor.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
private var photographerProfile: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: clue.avatar ?? "person.crop.circle.fill")
|
||||
.font(.system(size: 42, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.success)
|
||||
.frame(width: 56, height: 56)
|
||||
.background(AppDesign.success.opacity(0.12), in: Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if let name = clue.name {
|
||||
Text(name)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
if let storeName = clue.storeName {
|
||||
Text(storeName)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.success)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var reporterInfo: some View {
|
||||
VStack(spacing: 10) {
|
||||
infoLabelRow(label: "举报人", value: clue.reporterName ?? "-")
|
||||
HStack {
|
||||
Text("举报人手机号")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Spacer()
|
||||
if let phone = clue.reporterPhone, let masked = clue.maskedReporterPhone {
|
||||
Button {
|
||||
callPhone(phone)
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(masked)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
Image(systemName: "phone.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(hex: 0xF9FAFB), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private var reportContentsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(Array(clue.reportContents.enumerated()), id: \.offset) { _, item in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.time)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(item.content)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var imagesSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("现场图片/视频")
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(clue.images, id: \.self) { url in
|
||||
Button {
|
||||
onPreviewImage(url)
|
||||
} label: {
|
||||
AsyncImage(url: URL(string: url)) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
default:
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(hex: 0xE5E7EB))
|
||||
.overlay {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 108, height: 108)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processingPanel(record: WildReportRadarProcessRecord) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
infoLabelRow(label: "处理人", value: record.handlerName)
|
||||
infoLabelRow(
|
||||
label: "状态",
|
||||
value: clue.completed ? "已处理" : "正在处理中",
|
||||
valueColor: clue.completed ? AppDesign.success : Color(hex: 0xF59E0B)
|
||||
)
|
||||
HStack {
|
||||
Text("联系电话")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Spacer()
|
||||
Button {
|
||||
callPhone(record.phone)
|
||||
} label: {
|
||||
HStack(spacing: 6) {
|
||||
Text(record.maskedPhone)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
Image(systemName: "phone.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("处理进度")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
|
||||
ForEach(Array(clue.processingTimeline.enumerated()), id: \.offset) { _, item in
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Circle()
|
||||
.fill(timelineDotColor(item.status))
|
||||
.frame(width: 10, height: 10)
|
||||
.padding(.top, 4)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text(item.time)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(hex: 0xFFFBEB), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private func completionProofSection(_ photoURL: String) -> some View {
|
||||
Button {
|
||||
onPreviewImage(photoURL)
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
AsyncImage(url: URL(string: photoURL)) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
default:
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(hex: 0xE5E7EB))
|
||||
}
|
||||
}
|
||||
.frame(width: 72, height: 72)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("处理照片已上传")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Text("点击可查看本次处理凭证")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var actionButtons: some View {
|
||||
HStack(spacing: 10) {
|
||||
Button(action: onNavigate) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "map.fill")
|
||||
Text(clue.actionText)
|
||||
}
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if clue.type == .red, !clue.completed {
|
||||
Button(action: onShare) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
Text("分享")
|
||||
}
|
||||
.font(.system(size: 15, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.background(AppDesign.success, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func compareText(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineSpacing(4)
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(hex: 0xF3F4F6), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private func detailSection(_ detail: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "doc.text.fill")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.top, 2)
|
||||
Text(detail)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
}
|
||||
|
||||
private func infoLabelRow(label: String, value: String, valueColor: Color = AppDesign.textPrimary) -> some View {
|
||||
HStack {
|
||||
Text(label)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(valueColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var headerIconColor: Color {
|
||||
if clue.type == .red, clue.processingStarted, !clue.completed {
|
||||
return Color(hex: 0xF59E0B)
|
||||
}
|
||||
switch clue.type {
|
||||
case .blue: return AppDesign.primary
|
||||
case .green: return AppDesign.success
|
||||
case .red: return AppDesign.danger
|
||||
}
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
if clue.completed { return AppDesign.success }
|
||||
if clue.processingStarted && !clue.completed { return Color(hex: 0xF59E0B) }
|
||||
switch clue.type {
|
||||
case .blue: return AppDesign.primary
|
||||
case .green: return AppDesign.success
|
||||
case .red: return AppDesign.danger
|
||||
}
|
||||
}
|
||||
|
||||
private func timelineDotColor(_ status: String) -> Color {
|
||||
switch status {
|
||||
case "done": return AppDesign.success
|
||||
case "current": return Color(hex: 0xF59E0B)
|
||||
default: return Color(hex: 0xD1D5DB)
|
||||
}
|
||||
}
|
||||
|
||||
private func callPhone(_ phone: String) {
|
||||
guard let url = URL(string: "tel://\(phone)") else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
struct WildReportMapURLImagePreviewView: View {
|
||||
let imageURL: String
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 18) {
|
||||
Spacer()
|
||||
|
||||
AsyncImage(url: URL(string: imageURL)) { phase in
|
||||
switch phase {
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxWidth: 320, maxHeight: 320)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
default:
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
}
|
||||
}
|
||||
|
||||
Text("演示环境图片预览")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onClose) {
|
||||
Text("关闭")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 132, height: 44)
|
||||
.background(.white.opacity(0.18), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.bottom, 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WildReportMapMediaPreviewView: View {
|
||||
let media: WildReportMapMediaItem
|
||||
let onClose: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.black.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 18) {
|
||||
Spacer()
|
||||
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [media.tint.opacity(0.45), media.tint.opacity(0.18)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 280, height: 280)
|
||||
|
||||
Image(systemName: media.systemImage)
|
||||
.font(.system(size: 72, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if media.isVideo {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 56, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.92))
|
||||
}
|
||||
}
|
||||
|
||||
Text(media.title)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text(media.isVideo ? "演示环境视频预览" : "演示环境图片预览")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: onClose) {
|
||||
Text("关闭")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 132, height: 44)
|
||||
.background(.white.opacity(0.18), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.bottom, 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WildReportMapMarkerView: View {
|
||||
let marker: WildReportMapMarker
|
||||
let isSelected: Bool
|
||||
|
||||
private let dotSize: CGFloat = 14
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
if marker.kind == .selfLocation {
|
||||
Text(marker.title)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.white, in: Capsule())
|
||||
.shadow(color: .black.opacity(0.08), radius: 4, y: 2)
|
||||
}
|
||||
|
||||
ZStack {
|
||||
WildReportMapPulseRings(color: markerColor)
|
||||
|
||||
if isSelected {
|
||||
Circle()
|
||||
.stroke(markerColor.opacity(0.45), lineWidth: 2)
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
|
||||
Circle()
|
||||
.fill(markerColor)
|
||||
.frame(width: dotSize, height: dotSize)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(.white, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.frame(width: 56, height: 56)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
|
||||
private var markerColor: Color {
|
||||
switch marker.kind {
|
||||
case .selfLocation: return AppDesign.primary
|
||||
case .wildPhotographer: return AppDesign.danger
|
||||
case .processingClue: return Color(hex: 0xF59E0B)
|
||||
case .peerPhotographer: return AppDesign.success
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WildReportMapPulseRings: View {
|
||||
let color: Color
|
||||
|
||||
@State private var animatePulse = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ForEach(0..<3, id: \.self) { index in
|
||||
Circle()
|
||||
.stroke(color.opacity(0.32), lineWidth: 2)
|
||||
.frame(
|
||||
width: animatePulse ? dotSize + 14 + CGFloat(index * 12) : dotSize + 6,
|
||||
height: animatePulse ? dotSize + 14 + CGFloat(index * 12) : dotSize + 6
|
||||
)
|
||||
.opacity(animatePulse ? 0 : 0.72)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.easeOut(duration: 2).repeatForever(autoreverses: false)) {
|
||||
animatePulse = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let dotSize: CGFloat = 14
|
||||
}
|
||||
|
||||
private struct WildReportMapDot: View {
|
||||
let color: Color
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(.white, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("举报摄影师") {
|
||||
NavigationStack {
|
||||
WildPhotographerReportHomeView()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("提交举报") {
|
||||
NavigationStack {
|
||||
WildPhotographerReportSubmitView(homeViewModel: WildPhotographerReportHomeViewModel())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("举报成功") {
|
||||
NavigationStack {
|
||||
WildPhotographerReportSuccessView(
|
||||
result: WildReportSubmitResult(
|
||||
record: WildPhotographerReportMockStore.seedReports[0],
|
||||
imageCount: 3,
|
||||
videoCount: 1
|
||||
),
|
||||
homeViewModel: WildPhotographerReportHomeViewModel(),
|
||||
onReturnHome: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("举报详情") {
|
||||
NavigationStack {
|
||||
WildPhotographerReportDetailView(
|
||||
record: WildPhotographerReportMockStore.seedReports[0],
|
||||
homeViewModel: WildPhotographerReportHomeViewModel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("附近风险地图") {
|
||||
NavigationStack {
|
||||
WildReportRiskMapView(
|
||||
record: WildPhotographerReportMockStore.seedReports[0],
|
||||
riskPoints: WildPhotographerReportMockStore.seedRiskPoints
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
//
|
||||
// WildPhotographerReportSharedViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/7/1.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 举报模块通用区块标题。
|
||||
struct WildReportSectionHeader: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(width: 46, height: 46)
|
||||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 12))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
Text(subtitle)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
|
||||
/// 举报首页主视觉插画。
|
||||
struct WildReportHeroIllustration: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(AppDesign.primary.opacity(0.10))
|
||||
.frame(width: 104, height: 104)
|
||||
|
||||
RoundedRectangle(cornerRadius: 26)
|
||||
.fill(.white.opacity(0.88))
|
||||
.frame(width: 78, height: 78)
|
||||
.shadow(color: Color(hex: 0x0F172A).opacity(0.06), radius: 10, y: 4)
|
||||
|
||||
Image(systemName: "exclamationmark.shield.fill")
|
||||
.font(.system(size: 42, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
|
||||
Image(systemName: "camera.fill")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x1D4D89))
|
||||
.frame(width: 34, height: 34)
|
||||
.background(Color(hex: 0xEAF3FF), in: Circle())
|
||||
.offset(x: 36, y: 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Debug 工具栏,用于切换页面加载/空态/异常态。
|
||||
struct WildReportStateToolbar: ToolbarContent {
|
||||
@Binding var selection: WildReportPageState
|
||||
|
||||
var body: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Menu {
|
||||
ForEach(WildReportPageState.allCases) { state in
|
||||
Button(state.title) {
|
||||
selection = state
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
}
|
||||
.accessibilityLabel("切换页面状态")
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@ -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