新增举报摄影师模块并接入首页路由,桌面应用名改为随心瞰商家版。
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user