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

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,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
)
}
}