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

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

View File

@ -0,0 +1,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
}
}